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

scottyaslan 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 41e4779bc5 NIFI-13059: (#8661)
41e4779bc5 is described below

commit 41e4779bc560bf80e9e0fde253ae89525f115cf7
Author: Matt Gilman <matt.c.gil...@gmail.com>
AuthorDate: Thu Apr 18 18:32:24 2024 -0400

    NIFI-13059: (#8661)
    
    - Adding support for copying and pasting on the canvas.
    
    This closes #8661
---
 .../service/canvas-context-menu.service.ts         |  63 ++++--
 .../flow-designer/service/canvas-utils.service.ts  |  68 ++++++-
 .../flow-designer/service/canvas-view.service.ts   | 104 ++++++++--
 .../pages/flow-designer/service/flow.service.ts    |  28 ---
 .../pages/flow-designer/service/snippet.service.ts | 118 ++++++++++++
 .../pages/flow-designer/state/flow/flow.actions.ts |  12 ++
 .../pages/flow-designer/state/flow/flow.effects.ts | 212 ++++++++++++++++-----
 .../pages/flow-designer/state/flow/flow.reducer.ts |  48 +++++
 .../flow-designer/state/flow/flow.selectors.ts     |   2 +
 .../app/pages/flow-designer/state/flow/index.ts    |  27 ++-
 .../operation-control.component.html               |   4 +-
 .../operation-control.component.ts                 |  49 ++++-
 .../new-canvas-item/new-canvas-item.component.ts   |  39 +---
 .../property-tip/property-tip.component.html       |   6 +-
 14 files changed, 624 insertions(+), 156 deletions(-)

diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts
index ec00ba526d..ba80a35559 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts
@@ -54,11 +54,14 @@ import {
     startCurrentProcessGroup,
     stopComponents,
     stopCurrentProcessGroup,
-    stopVersionControlRequest
+    stopVersionControlRequest,
+    copy,
+    paste
 } from '../state/flow/flow.actions';
 import { ComponentType } from '../../../state/shared';
 import {
     ConfirmStopVersionControlRequest,
+    CopyComponentRequest,
     DeleteComponentRequest,
     MoveComponentRequest,
     OpenChangeVersionDialogRequest,
@@ -76,6 +79,7 @@ import { getComponentStateAndOpenDialog } from 
'../../../state/component-state/c
 import { navigateToComponentDocumentation } from 
'../../../state/documentation/documentation.actions';
 import * as d3 from 'd3';
 import { Client } from '../../../service/client.service';
+import { CanvasView } from './canvas-view.service';
 
 @Injectable({ providedIn: 'root' })
 export class CanvasContextMenu implements ContextMenuDefinitionProvider {
@@ -1173,12 +1177,12 @@ export class CanvasContextMenu implements 
ContextMenuDefinitionProvider {
                 }
             },
             {
-                condition: (selection: any) => {
+                condition: (selection: d3.Selection<any, any, any, any>) => {
                     return this.canvasUtils.isDisconnected(selection);
                 },
                 clazz: 'fa icon-group',
                 text: 'Group',
-                action: (selection: any) => {
+                action: (selection: d3.Selection<any, any, any, any>) => {
                     const moveComponents: MoveComponentRequest[] = [];
                     selection.each(function (d: any) {
                         moveComponents.push({
@@ -1212,25 +1216,55 @@ export class CanvasContextMenu implements 
ContextMenuDefinitionProvider {
                 isSeparator: true
             },
             {
-                condition: (selection: any) => {
-                    // TODO - isCopyable
-                    return false;
+                condition: (selection: d3.Selection<any, any, any, any>) => {
+                    return this.canvasUtils.isCopyable(selection);
                 },
                 clazz: 'fa fa-copy',
                 text: 'Copy',
-                action: () => {
-                    // TODO - copy
+                action: (selection: d3.Selection<any, any, any, any>) => {
+                    const origin = this.canvasUtils.getOrigin(selection);
+                    const dimensions = 
this.canvasView.getSelectionBoundingClientRect(selection);
+
+                    const components: CopyComponentRequest[] = [];
+                    selection.each((d) => {
+                        components.push({
+                            id: d.id,
+                            type: d.type,
+                            uri: d.uri,
+                            entity: d
+                        });
+                    });
+
+                    this.store.dispatch(
+                        copy({
+                            request: {
+                                components,
+                                origin,
+                                dimensions
+                            }
+                        })
+                    );
                 }
             },
             {
-                condition: (selection: any) => {
-                    // TODO - isPastable
-                    return false;
+                condition: () => {
+                    return this.canvasUtils.isPastable();
                 },
                 clazz: 'fa fa-paste',
                 text: 'Paste',
-                action: () => {
-                    // TODO - paste
+                action: (selection: d3.Selection<any, any, any, any>, event) 
=> {
+                    if (event) {
+                        const pasteLocation = 
this.canvasView.getCanvasPosition({ x: event.pageX, y: event.pageY });
+                        if (pasteLocation) {
+                            this.store.dispatch(
+                                paste({
+                                    request: {
+                                        pasteLocation
+                                    }
+                                })
+                            );
+                        }
+                    }
                 }
             },
             {
@@ -1325,7 +1359,8 @@ export class CanvasContextMenu implements 
ContextMenuDefinitionProvider {
     constructor(
         private store: Store<CanvasState>,
         private canvasUtils: CanvasUtils,
-        private client: Client
+        private client: Client,
+        private canvasView: CanvasView
     ) {
         this.allMenus = new Map<string, ContextMenuDefinition>();
         this.allMenus.set(this.ROOT_MENU.id, this.ROOT_MENU);
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
index 37b4e7adb8..24ede50236 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
@@ -24,6 +24,7 @@ import {
     selectBreadcrumbs,
     selectCanvasPermissions,
     selectConnections,
+    selectCopiedSnippet,
     selectCurrentProcessGroupId,
     selectParentProcessGroupId
 } from '../state/flow/flow.selectors';
@@ -39,7 +40,7 @@ import { selectCurrentUser } from 
'../../../state/current-user/current-user.sele
 import { FlowConfiguration } from '../../../state/flow-configuration';
 import { initialState as initialFlowConfigurationState } from 
'../../../state/flow-configuration/flow-configuration.reducer';
 import { selectFlowConfiguration } from 
'../../../state/flow-configuration/flow-configuration.selectors';
-import { VersionControlInformation } from '../state/flow';
+import { CopiedSnippet, VersionControlInformation } from '../state/flow';
 import { Overlay, OverlayRef } from '@angular/cdk/overlay';
 import { ComponentPortal } from '@angular/cdk/portal';
 
@@ -59,6 +60,7 @@ export class CanvasUtils {
     private flowConfiguration: FlowConfiguration | null = 
initialFlowConfigurationState.flowConfiguration;
     private connections: any[] = [];
     private breadcrumbs: BreadcrumbEntity | null = null;
+    private copiedSnippet: CopiedSnippet | null = null;
 
     private readonly humanizeDuration: Humanizer;
 
@@ -118,6 +120,13 @@ export class CanvasUtils {
             .subscribe((breadcrumbs) => {
                 this.breadcrumbs = breadcrumbs;
             });
+
+        this.store
+            .select(selectCopiedSnippet)
+            .pipe(takeUntilDestroyed(this.destroyRef))
+            .subscribe((copiedSnippet) => {
+                this.copiedSnippet = copiedSnippet;
+            });
     }
 
     public hasDownstream(selection: any): boolean {
@@ -807,14 +816,13 @@ export class CanvasUtils {
      *
      * @argument {selection} selection      The selection
      */
-    getOrigin(selection: any): Position {
-        const self: CanvasUtils = this;
+    public getOrigin(selection: d3.Selection<any, any, any, any>): Position {
         let x: number | undefined;
         let y: number | undefined;
 
-        selection.each(function (this: any, d: any) {
-            const selected: any = d3.select(this);
-            if (!self.isConnection(selected)) {
+        selection.each((d, i, nodes) => {
+            const selected: any = d3.select(nodes[i]);
+            if (!this.isConnection(selected)) {
                 if (x == null || d.position.x < x) {
                     x = d.position.x;
                 }
@@ -831,6 +839,54 @@ export class CanvasUtils {
         return { x, y };
     }
 
+    public isCopyable(selection: d3.Selection<any, any, any, any>): boolean {
+        // if nothing is selected return
+        if (selection.empty()) {
+            return false;
+        }
+
+        if (!this.canRead(selection)) {
+            return false;
+        }
+
+        // determine how many copyable components are selected
+        const copyable = selection.filter((d, i, nodes) => {
+            const selected = d3.select(nodes[i]);
+            if (this.isConnection(selected)) {
+                const sourceIncluded = !selection
+                    .filter((source) => {
+                        const sourceComponentId = 
this.getConnectionSourceComponentId(d);
+                        return sourceComponentId === source.id;
+                    })
+                    .empty();
+                const destinationIncluded = !selection
+                    .filter((destination) => {
+                        const destinationComponentId = 
this.getConnectionDestinationComponentId(d);
+                        return destinationComponentId === destination.id;
+                    })
+                    .empty();
+                return sourceIncluded && destinationIncluded;
+            } else {
+                return (
+                    this.isProcessor(selected) ||
+                    this.isFunnel(selected) ||
+                    this.isLabel(selected) ||
+                    this.isProcessGroup(selected) ||
+                    this.isRemoteProcessGroup(selected) ||
+                    this.isInputPort(selected) ||
+                    this.isOutputPort(selected)
+                );
+            }
+        });
+
+        // ensure everything selected is copyable
+        return selection.size() === copyable.size();
+    }
+
+    public isPastable(): boolean {
+        return this.canvasPermissions.canWrite && this.copiedSnippet != null;
+    }
+
     /**
      * Gets the name for this connection.
      *
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts
index bb8a844836..8614e150ed 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts
@@ -31,6 +31,7 @@ import { RemoteProcessGroupManager } from 
'./manager/remote-process-group-manage
 import { ConnectionManager } from './manager/connection-manager.service';
 import { deselectAllComponents } from '../state/flow/flow.actions';
 import { CanvasUtils } from './canvas-utils.service';
+import { Position } from '../state/shared';
 
 @Injectable({
     providedIn: 'root'
@@ -185,7 +186,6 @@ export class CanvasView {
 
     public isSelectedComponentOnScreen(): boolean {
         const canvasContainer: any = 
document.getElementById('canvas-container');
-
         if (canvasContainer == null) {
             return false;
         }
@@ -245,6 +245,55 @@ export class CanvasView {
         }
     }
 
+    /**
+     * Determines if a bounding box is fully 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.
+     *                                 If false, only part of the bounding box 
must be in the viewport.
+     * @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];
+
+        // get the normalized screen width and height
+        const screenWidth = canvasContainer.offsetWidth / this.k;
+        const screenHeight = canvasContainer.offsetHeight / this.k;
+
+        // calculate the screen bounds one screens worth in each direction
+        const screenLeft = -translate[0];
+        const screenTop = -translate[1];
+        const screenRight = screenLeft + screenWidth;
+        const screenBottom = screenTop + screenHeight;
+
+        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);
+
+        if (strict) {
+            return !(left < screenLeft || right > screenRight || top < 
screenTop || bottom > screenBottom);
+        } else {
+            return (
+                ((left > screenLeft && left < screenRight) || (right < 
screenRight && right > screenLeft)) &&
+                ((top > screenTop && top < screenBottom) || (bottom < 
screenBottom && bottom > screenTop))
+            );
+        }
+    }
+
     public updateCanvasVisibility(): void {
         const self: CanvasView = this;
         const canvasContainer: any = 
document.getElementById('canvas-container');
@@ -354,11 +403,6 @@ export class CanvasView {
     }
 
     public centerSelectedComponents(allowTransition: boolean): void {
-        const canvasContainer: any = 
document.getElementById('canvas-container');
-        if (canvasContainer == null) {
-            return;
-        }
-
         const selection: any = this.canvasUtils.getSelection();
         if (selection.empty()) {
             return;
@@ -368,7 +412,7 @@ export class CanvasView {
         if (selection.size() === 1) {
             bbox = this.getSingleSelectionBoundingClientRect(selection);
         } else {
-            bbox = this.getBulkSelectionBoundingClientRect(selection, 
canvasContainer);
+            bbox = this.getSelectionBoundingClientRect(selection);
         }
 
         this.allowTransition = allowTransition;
@@ -408,8 +452,13 @@ export class CanvasView {
     /**
      * Get a BoundingClientRect, normalized to the canvas, that encompasses 
all nodes in a given selection.
      */
-    private getBulkSelectionBoundingClientRect(selection: any, 
canvasContainer: any): any {
-        const canvasBoundingBox: any = canvasContainer.getBoundingClientRect();
+    public getSelectionBoundingClientRect(selection: any): any {
+        let yOffset = 0;
+
+        const canvasContainer: any = 
document.getElementById('canvas-container');
+        if (canvasContainer) {
+            yOffset = canvasContainer.getBoundingClientRect().top;
+        }
 
         const initialBBox: any = {
             x: Number.MAX_VALUE,
@@ -430,9 +479,9 @@ export class CanvasView {
 
         // normalize the bounding box with scale and translate
         bbox.x = (bbox.x - this.x) / this.k;
-        bbox.y = (bbox.y - canvasBoundingBox.top - this.y) / this.k;
+        bbox.y = (bbox.y - yOffset - this.y) / this.k;
         bbox.right = (bbox.right - this.x) / this.k;
-        bbox.bottom = (bbox.bottom - canvasBoundingBox.top - this.y) / this.k;
+        bbox.bottom = (bbox.bottom - yOffset - this.y) / this.k;
 
         bbox.width = bbox.right - bbox.x;
         bbox.height = bbox.bottom - bbox.y;
@@ -442,6 +491,37 @@ export class CanvasView {
         return bbox;
     }
 
+    public getCanvasPosition(position: Position): Position | null {
+        const canvasContainer: any = 
document.getElementById('canvas-container');
+        if (!canvasContainer) {
+            return null;
+        }
+
+        const rect = canvasContainer.getBoundingClientRect();
+
+        // translate the point onto the canvas
+        const canvasDropPoint = {
+            x: position.x - rect.left,
+            y: position.y - rect.top
+        };
+
+        // if the position is over the canvas fire an event to add the new item
+        if (
+            canvasDropPoint.x >= 0 &&
+            canvasDropPoint.x < rect.width &&
+            canvasDropPoint.y >= 0 &&
+            canvasDropPoint.y < rect.height
+        ) {
+            // adjust the x and y coordinates accordingly
+            const x = canvasDropPoint.x / this.k - this.x / this.k;
+            const y = canvasDropPoint.y / this.k - this.y / this.k;
+
+            return { x, y };
+        }
+
+        return null;
+    }
+
     private centerBoundingBox(boundingBox: any): void {
         let scale: number = this.k;
         if (boundingBox.scale != null) {
@@ -460,7 +540,7 @@ export class CanvasView {
      * @param {type} boundingBox
      * @returns {number[]}
      */
-    private getCenterForBoundingBox(boundingBox: any): number[] {
+    public getCenterForBoundingBox(boundingBox: any): number[] {
         let scale: number = this.k;
         if (boundingBox.scale != null) {
             scale = boundingBox.scale;
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/flow.service.ts
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/flow.service.ts
index 79be4a518c..08ee7b6c05 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/flow.service.ts
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/flow.service.ts
@@ -35,7 +35,6 @@ import {
     ReplayLastProvenanceEventRequest,
     RunOnceRequest,
     SaveToVersionControlRequest,
-    Snippet,
     StartComponentRequest,
     StartProcessGroupRequest,
     StopComponentRequest,
@@ -257,33 +256,6 @@ export class FlowService implements 
PropertyDescriptorRetriever {
         return 
this.httpClient.delete(this.nifiCommon.stripProtocol(deleteComponent.uri), { 
params });
     }
 
-    createSnippet(snippet: Snippet): Observable<any> {
-        return this.httpClient.post(`${FlowService.API}/snippets`, {
-            disconnectedNodeAcknowledged: 
this.clusterConnectionService.isDisconnectionAcknowledged(),
-            snippet
-        });
-    }
-
-    moveSnippet(snippetId: string, groupId: string): Observable<any> {
-        const payload: any = {
-            disconnectedNodeAcknowledged: 
this.clusterConnectionService.isDisconnectionAcknowledged(),
-            snippet: {
-                id: snippetId,
-                parentGroupId: groupId
-            }
-        };
-        return this.httpClient.put(`${FlowService.API}/snippets/${snippetId}`, 
payload);
-    }
-
-    deleteSnippet(snippetId: string): Observable<any> {
-        const params = new HttpParams({
-            fromObject: {
-                disconnectedNodeAcknowledged: 
this.clusterConnectionService.isDisconnectionAcknowledged()
-            }
-        });
-        return 
this.httpClient.delete(`${FlowService.API}/snippets/${snippetId}`, { params });
-    }
-
     replayLastProvenanceEvent(request: ReplayLastProvenanceEventRequest): 
Observable<any> {
         return 
this.httpClient.post(`${FlowService.API}/provenance-events/latest/replays`, 
request);
     }
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/snippet.service.ts
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/snippet.service.ts
new file mode 100644
index 0000000000..411ea5e975
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/snippet.service.ts
@@ -0,0 +1,118 @@
+/*
+ * 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 { Observable } from 'rxjs';
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Snippet, SnippetComponentRequest } from '../state/flow';
+import { ClusterConnectionService } from 
'../../../service/cluster-connection.service';
+import { ComponentType } from '../../../state/shared';
+import { Client } from '../../../service/client.service';
+import { Position } from '../state/shared';
+
+@Injectable({ providedIn: 'root' })
+export class SnippetService {
+    private static readonly API: string = '../nifi-api';
+
+    constructor(
+        private httpClient: HttpClient,
+        private client: Client,
+        private clusterConnectionService: ClusterConnectionService
+    ) {}
+
+    marshalSnippet(components: SnippetComponentRequest[], processGroupId: 
string): Snippet {
+        return components.reduce(
+            (snippet, component) => {
+                switch (component.type) {
+                    case ComponentType.Processor:
+                        snippet.processors[component.id] = 
this.client.getRevision(component.entity);
+                        break;
+                    case ComponentType.InputPort:
+                        snippet.inputPorts[component.id] = 
this.client.getRevision(component.entity);
+                        break;
+                    case ComponentType.OutputPort:
+                        snippet.outputPorts[component.id] = 
this.client.getRevision(component.entity);
+                        break;
+                    case ComponentType.ProcessGroup:
+                        snippet.processGroups[component.id] = 
this.client.getRevision(component.entity);
+                        break;
+                    case ComponentType.RemoteProcessGroup:
+                        snippet.remoteProcessGroups[component.id] = 
this.client.getRevision(component.entity);
+                        break;
+                    case ComponentType.Funnel:
+                        snippet.funnels[component.id] = 
this.client.getRevision(component.entity);
+                        break;
+                    case ComponentType.Label:
+                        snippet.labels[component.id] = 
this.client.getRevision(component.entity);
+                        break;
+                    case ComponentType.Connection:
+                        snippet.connections[component.id] = 
this.client.getRevision(component.entity);
+                        break;
+                }
+                return snippet;
+            },
+            {
+                parentGroupId: processGroupId,
+                processors: {},
+                funnels: {},
+                inputPorts: {},
+                outputPorts: {},
+                remoteProcessGroups: {},
+                processGroups: {},
+                connections: {},
+                labels: {}
+            } as Snippet
+        );
+    }
+
+    createSnippet(snippet: Snippet): Observable<any> {
+        return this.httpClient.post(`${SnippetService.API}/snippets`, {
+            disconnectedNodeAcknowledged: 
this.clusterConnectionService.isDisconnectionAcknowledged(),
+            snippet
+        });
+    }
+
+    moveSnippet(snippetId: string, groupId: string): Observable<any> {
+        const payload: any = {
+            disconnectedNodeAcknowledged: 
this.clusterConnectionService.isDisconnectionAcknowledged(),
+            snippet: {
+                id: snippetId,
+                parentGroupId: groupId
+            }
+        };
+        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: {
+                disconnectedNodeAcknowledged: 
this.clusterConnectionService.isDisconnectionAcknowledged()
+            }
+        });
+        return 
this.httpClient.delete(`${SnippetService.API}/snippets/${snippetId}`, { params 
});
+    }
+}
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
index 030d9a01fc..7e38808152 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
@@ -21,6 +21,8 @@ import {
     ChangeVersionDialogRequest,
     ComponentEntity,
     ConfirmStopVersionControlRequest,
+    CopiedSnippet,
+    CopyRequest,
     CreateComponentRequest,
     CreateComponentResponse,
     CreateConnection,
@@ -64,6 +66,8 @@ import {
     OpenGroupComponentsDialogRequest,
     OpenLocalChangesDialogRequest,
     OpenSaveVersionDialogRequest,
+    PasteRequest,
+    PasteResponse,
     RefreshRemoteProcessGroupRequest,
     ReplayLastProvenanceEventRequest,
     RpgManageRemotePortsRequest,
@@ -476,6 +480,14 @@ export const moveComponents = createAction(
     props<{ request: MoveComponentsRequest }>()
 );
 
+export const copy = createAction(`${CANVAS_PREFIX} Copy`, props<{ request: 
CopyRequest }>());
+
+export const copySuccess = createAction(`${CANVAS_PREFIX} Copy Success`, 
props<{ copiedSnippet: CopiedSnippet }>());
+
+export const paste = createAction(`${CANVAS_PREFIX} Paste`, props<{ request: 
PasteRequest }>());
+
+export const pasteSuccess = createAction(`${CANVAS_PREFIX} Paste Success`, 
props<{ response: PasteResponse }>());
+
 /*
     Delete Component Actions
  */
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
index 4e6f79db87..cac1991f97 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
@@ -39,14 +39,17 @@ import {
     tap
 } from 'rxjs';
 import {
+    CopyComponentRequest,
     CreateProcessGroupDialogRequest,
     DeleteComponentResponse,
     GroupComponentsDialogRequest,
     ImportFromRegistryDialogRequest,
     LoadProcessGroupRequest,
     LoadProcessGroupResponse,
+    MoveComponentRequest,
     SaveVersionDialogRequest,
     SaveVersionRequest,
+    SelectedComponent,
     Snippet,
     StopVersionControlRequest,
     StopVersionControlResponse,
@@ -61,6 +64,7 @@ import { Action, Store } from '@ngrx/store';
 import {
     selectAnySelectedComponentIds,
     selectChangeVersionRequest,
+    selectCopiedSnippet,
     selectCurrentParameterContext,
     selectCurrentProcessGroupId,
     selectMaxZIndex,
@@ -119,6 +123,8 @@ import { LocalChangesDialog } from 
'../../ui/canvas/items/flow/local-changes-dia
 import { ClusterConnectionService } from 
'../../../../service/cluster-connection.service';
 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';
 
 @Injectable()
 export class FlowEffects {
@@ -134,6 +140,7 @@ export class FlowEffects {
         private birdseyeView: BirdseyeView,
         private connectionManager: ConnectionManager,
         private clusterConnectionService: ClusterConnectionService,
+        private snippetService: SnippetService,
         private router: Router,
         private dialog: MatDialog,
         private propertyTableHelperService: PropertyTableHelperService,
@@ -1816,53 +1823,11 @@ export class FlowEffects {
             map((action) => action.request),
             concatLatestFrom(() => 
this.store.select(selectCurrentProcessGroupId)),
             mergeMap(([request, processGroupId]) => {
-                const components: any[] = request.components;
-
-                const snippet: Snippet = components.reduce(
-                    (snippet, component) => {
-                        switch (component.type) {
-                            case ComponentType.Processor:
-                                snippet.processors[component.id] = 
this.client.getRevision(component.entity);
-                                break;
-                            case ComponentType.InputPort:
-                                snippet.inputPorts[component.id] = 
this.client.getRevision(component.entity);
-                                break;
-                            case ComponentType.OutputPort:
-                                snippet.outputPorts[component.id] = 
this.client.getRevision(component.entity);
-                                break;
-                            case ComponentType.ProcessGroup:
-                                snippet.processGroups[component.id] = 
this.client.getRevision(component.entity);
-                                break;
-                            case ComponentType.RemoteProcessGroup:
-                                snippet.remoteProcessGroups[component.id] = 
this.client.getRevision(component.entity);
-                                break;
-                            case ComponentType.Funnel:
-                                snippet.funnels[component.id] = 
this.client.getRevision(component.entity);
-                                break;
-                            case ComponentType.Label:
-                                snippet.labels[component.id] = 
this.client.getRevision(component.entity);
-                                break;
-                            case ComponentType.Connection:
-                                snippet.connections[component.id] = 
this.client.getRevision(component.entity);
-                                break;
-                        }
-                        return snippet;
-                    },
-                    {
-                        parentGroupId: processGroupId,
-                        processors: {},
-                        funnels: {},
-                        inputPorts: {},
-                        outputPorts: {},
-                        remoteProcessGroups: {},
-                        processGroups: {},
-                        connections: {},
-                        labels: {}
-                    } as Snippet
-                );
+                const components: MoveComponentRequest[] = request.components;
+                const snippet = this.snippetService.marshalSnippet(components, 
processGroupId);
 
-                return from(this.flowService.createSnippet(snippet)).pipe(
-                    switchMap((response) => 
this.flowService.moveSnippet(response.snippet.id, request.groupId)),
+                return from(this.snippetService.createSnippet(snippet)).pipe(
+                    switchMap((response) => 
this.snippetService.moveSnippet(response.snippet.id, request.groupId)),
                     map(() => {
                         const deleteResponses: DeleteComponentResponse[] = [];
 
@@ -1884,6 +1849,157 @@ export class FlowEffects {
         )
     );
 
+    copy$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(FlowActions.copy),
+            map((action) => action.request),
+            concatLatestFrom(() => 
this.store.select(selectCurrentProcessGroupId)),
+            switchMap(([request, processGroupId]) => {
+                const components: CopyComponentRequest[] = request.components;
+                const snippet = this.snippetService.marshalSnippet(components, 
processGroupId);
+                return of(
+                    FlowActions.copySuccess({
+                        copiedSnippet: {
+                            snippet,
+                            dimensions: request.dimensions,
+                            origin: request.origin
+                        }
+                    })
+                );
+            })
+        )
+    );
+
+    paste$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(FlowActions.paste),
+            map((action) => action.request),
+            concatLatestFrom(() => [
+                
this.store.select(selectCopiedSnippet).pipe(isDefinedAndNotNull()),
+                this.store.select(selectCurrentProcessGroupId),
+                this.store.select(selectTransform)
+            ]),
+            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
+                                };
+                            }
+                        }
+
+                        return from(
+                            
this.snippetService.copySnippet(response.snippet.id, pasteLocation, 
processGroupId)
+                        ).pipe(map((response) => FlowActions.pasteSuccess({ 
response })));
+                    }),
+                    catchError((error) => of(FlowActions.flowSnackbarError({ 
error: error.error })))
+                )
+            )
+        )
+    );
+
+    pasteSuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(FlowActions.pasteSuccess),
+            map((action) => action.response),
+            switchMap((response) => {
+                this.canvasView.updateCanvasVisibility();
+                this.birdseyeView.refresh();
+
+                const components: SelectedComponent[] = [];
+                components.push(
+                    ...response.flow.labels.map((label) => {
+                        return {
+                            id: label.id,
+                            componentType: ComponentType.Label
+                        };
+                    })
+                );
+                components.push(
+                    ...response.flow.funnels.map((funnel) => {
+                        return {
+                            id: funnel.id,
+                            componentType: ComponentType.Funnel
+                        };
+                    })
+                );
+                components.push(
+                    
...response.flow.remoteProcessGroups.map((remoteProcessGroups) => {
+                        return {
+                            id: remoteProcessGroups.id,
+                            componentType: ComponentType.RemoteProcessGroup
+                        };
+                    })
+                );
+                components.push(
+                    ...response.flow.inputPorts.map((inputPorts) => {
+                        return {
+                            id: inputPorts.id,
+                            componentType: ComponentType.InputPort
+                        };
+                    })
+                );
+                components.push(
+                    ...response.flow.outputPorts.map((outputPorts) => {
+                        return {
+                            id: outputPorts.id,
+                            componentType: ComponentType.OutputPort
+                        };
+                    })
+                );
+                components.push(
+                    ...response.flow.processGroups.map((processGroup) => {
+                        return {
+                            id: processGroup.id,
+                            componentType: ComponentType.ProcessGroup
+                        };
+                    })
+                );
+                components.push(
+                    ...response.flow.processors.map((processor) => {
+                        return {
+                            id: processor.id,
+                            componentType: ComponentType.Processor
+                        };
+                    })
+                );
+                components.push(
+                    ...response.flow.connections.map((connection) => {
+                        return {
+                            id: connection.id,
+                            componentType: ComponentType.Connection
+                        };
+                    })
+                );
+
+                return of(
+                    FlowActions.selectComponents({
+                        request: {
+                            components
+                        }
+                    })
+                );
+            })
+        )
+    );
+
     deleteComponent$ = createEffect(() =>
         this.actions$.pipe(
             ofType(FlowActions.deleteComponents),
@@ -1962,8 +2078,8 @@ export class FlowEffects {
                         } as Snippet
                     );
 
-                    return from(this.flowService.createSnippet(snippet)).pipe(
-                        switchMap((response) => 
this.flowService.deleteSnippet(response.snippet.id)),
+                    return 
from(this.snippetService.createSnippet(snippet)).pipe(
+                        switchMap((response) => 
this.snippetService.deleteSnippet(response.snippet.id)),
                         map(() => {
                             const deleteResponses: DeleteComponentResponse[] = 
[];
 
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts
index 67a8aa0bc8..7eb089b8ba 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts
@@ -20,6 +20,7 @@ import {
     changeVersionComplete,
     changeVersionSuccess,
     clearFlowApiError,
+    copySuccess,
     createComponentComplete,
     createComponentSuccess,
     createConnection,
@@ -41,6 +42,7 @@ import {
     loadProcessorSuccess,
     loadRemoteProcessGroupSuccess,
     navigateWithoutTransform,
+    pasteSuccess,
     pollChangeVersionSuccess,
     pollRevertChangesSuccess,
     requestRefreshRemoteProcessGroup,
@@ -143,6 +145,7 @@ export const initialState: FlowState = {
         parameterProviderBulletins: [],
         reportingTaskBulletins: []
     },
+    copiedSnippet: null,
     dragging: false,
     saving: false,
     versionSaving: false,
@@ -324,6 +327,51 @@ 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);
+            if (labels) {
+                labels.push(...response.flow.labels);
+            }
+            const funnels: any[] | null = getComponentCollection(draftState, 
ComponentType.Funnel);
+            if (funnels) {
+                funnels.push(...response.flow.funnels);
+            }
+            const remoteProcessGroups: any[] | null = getComponentCollection(
+                draftState,
+                ComponentType.RemoteProcessGroup
+            );
+            if (remoteProcessGroups) {
+                remoteProcessGroups.push(...response.flow.remoteProcessGroups);
+            }
+            const inputPorts: any[] | null = 
getComponentCollection(draftState, ComponentType.InputPort);
+            if (inputPorts) {
+                inputPorts.push(...response.flow.inputPorts);
+            }
+            const outputPorts: any[] | null = 
getComponentCollection(draftState, ComponentType.OutputPort);
+            if (outputPorts) {
+                outputPorts.push(...response.flow.outputPorts);
+            }
+            const processGroups: any[] | null = 
getComponentCollection(draftState, ComponentType.ProcessGroup);
+            if (processGroups) {
+                processGroups.push(...response.flow.processGroups);
+            }
+            const processors: any[] | null = 
getComponentCollection(draftState, ComponentType.Processor);
+            if (processors) {
+                processors.push(...response.flow.processors);
+            }
+            const connections: any[] | null = 
getComponentCollection(draftState, ComponentType.Connection);
+            if (connections) {
+                connections.push(...response.flow.connections);
+            }
+
+            draftState.copiedSnippet = null;
+        });
+    }),
     on(setDragging, (state, { dragging }) => ({
         ...state,
         dragging
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
index 01a75fc101..536b5378f8 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
@@ -42,6 +42,8 @@ export const selectCurrentProcessGroupId = 
createSelector(selectFlowState, (stat
 
 export const selectRefreshRpgDetails = createSelector(selectFlowState, (state: 
FlowState) => state.refreshRpgDetails);
 
+export const selectCopiedSnippet = createSelector(selectFlowState, (state: 
FlowState) => state.copiedSnippet);
+
 export const selectCurrentParameterContext = createSelector(
     selectFlowState,
     (state: FlowState) => state.flow.processGroupFlow.parameterContext
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/index.ts
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/index.ts
index 092cab1e54..392c267b55 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/index.ts
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/index.ts
@@ -423,18 +423,36 @@ export interface UpdatePositionsRequest {
     connectionUpdates: UpdateComponentRequest[];
 }
 
-export interface MoveComponentRequest {
+export interface SnippetComponentRequest {
     id: string;
     uri: string;
     type: ComponentType;
     entity: any;
 }
 
+export interface MoveComponentRequest extends SnippetComponentRequest {}
+
 export interface MoveComponentsRequest {
     components: MoveComponentRequest[];
     groupId: string;
 }
 
+export interface CopyComponentRequest extends SnippetComponentRequest {}
+
+export interface CopyRequest {
+    components: CopyComponentRequest[];
+    origin: Position;
+    dimensions: any;
+}
+
+export interface PasteRequest {
+    pasteLocation?: Position;
+}
+
+export interface PasteResponse {
+    flow: Flow;
+}
+
 export interface DeleteComponentRequest {
     id: string;
     uri: string;
@@ -490,6 +508,12 @@ export interface Snippet {
     };
 }
 
+export interface CopiedSnippet {
+    snippet: Snippet;
+    origin: Position;
+    dimensions: any;
+}
+
 /*
     Tooltips
  */
@@ -613,6 +637,7 @@ export interface FlowState {
     error: string | null;
     versionSaving: boolean;
     changeVersionRequest: FlowUpdateRequestEntity | null;
+    copiedSnippet: CopiedSnippet | null;
     status: 'pending' | 'loading' | 'error' | 'success';
 }
 
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component.html
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component.html
index 7dc0e816a1..03f21089f9 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component.html
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component.html
@@ -116,8 +116,8 @@
                             color="primary"
                             class="mr-2"
                             type="button"
-                            [disabled]="!canPaste(selection)"
-                            (click)="paste(selection)">
+                            [disabled]="!canPaste()"
+                            (click)="paste()">
                             <i class="fa fa-paste"></i>
                         </button>
                         <button
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component.ts
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component.ts
index 853c0bd1d9..6eac3f66f6 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component.ts
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component.ts
@@ -17,11 +17,13 @@
 
 import { Component, Input } from '@angular/core';
 import {
+    copy,
     deleteComponents,
     getParameterContextsAndOpenGroupComponentsDialog,
     navigateToEditComponent,
     navigateToEditCurrentProcessGroup,
     navigateToManageComponentPolicies,
+    paste,
     setOperationCollapsed,
     startComponents,
     startCurrentProcessGroup,
@@ -34,6 +36,7 @@ import { CanvasUtils } from 
'../../../../service/canvas-utils.service';
 import { initialState } from '../../../../state/flow/flow.reducer';
 import { Storage } from '../../../../../../service/storage.service';
 import {
+    CopyComponentRequest,
     DeleteComponentRequest,
     MoveComponentRequest,
     StartComponentRequest,
@@ -43,6 +46,8 @@ import {
 import { BreadcrumbEntity } from '../../../../state/shared';
 import { ComponentType } from '../../../../../../state/shared';
 import { MatButtonModule } from '@angular/material/button';
+import * as d3 from 'd3';
+import { CanvasView } from '../../../../service/canvas-view.service';
 
 @Component({
     selector: 'operation-control',
@@ -63,6 +68,7 @@ export class OperationControl {
     constructor(
         private store: Store<CanvasState>,
         public canvasUtils: CanvasUtils,
+        private canvasView: CanvasView,
         private storage: Storage
     ) {
         try {
@@ -335,22 +341,45 @@ export class OperationControl {
         }
     }
 
-    canCopy(selection: any): boolean {
-        // TODO - isCopyable
-        return false;
+    canCopy(selection: d3.Selection<any, any, any, any>): boolean {
+        return this.canvasUtils.isCopyable(selection);
     }
 
-    copy(selection: any): void {
-        // TODO - copy
+    copy(selection: d3.Selection<any, any, any, any>): void {
+        const components: CopyComponentRequest[] = [];
+        selection.each((d) => {
+            components.push({
+                id: d.id,
+                type: d.type,
+                uri: d.uri,
+                entity: d
+            });
+        });
+
+        const origin = this.canvasUtils.getOrigin(selection);
+        const dimensions = 
this.canvasView.getSelectionBoundingClientRect(selection);
+
+        this.store.dispatch(
+            copy({
+                request: {
+                    components,
+                    origin,
+                    dimensions
+                }
+            })
+        );
     }
 
-    canPaste(selection: any): boolean {
-        // TODO - isPastable
-        return false;
+    canPaste(): boolean {
+        return this.canvasUtils.isPastable();
     }
 
-    paste(selection: any): void {
-        // TODO - paste
+    paste(): void {
+        this.store.dispatch(
+            paste({
+                request: {}
+            })
+        );
     }
 
     canGroup(selection: any): boolean {
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/header/new-canvas-item/new-canvas-item.component.ts
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/header/new-canvas-item/new-canvas-item.component.ts
index 45fa0d4594..6a42dc247a 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/header/new-canvas-item/new-canvas-item.component.ts
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/header/new-canvas-item/new-canvas-item.component.ts
@@ -19,14 +19,12 @@ import { Component, Input } from '@angular/core';
 import { CdkDrag, CdkDragEnd } from '@angular/cdk/drag-drop';
 import { Store } from '@ngrx/store';
 import { CanvasState } from '../../../../state';
-import { INITIAL_SCALE, INITIAL_TRANSLATE } from 
'../../../../state/transform/transform.reducer';
-import { selectTransform } from 
'../../../../state/transform/transform.selectors';
 import { createComponentRequest, setDragging } from 
'../../../../state/flow/flow.actions';
 import { Client } from '../../../../../../service/client.service';
 import { selectDragging } from '../../../../state/flow/flow.selectors';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
-import { Position } from '../../../../state/shared';
 import { ComponentType } from '../../../../../../state/shared';
+import { CanvasView } from '../../../../service/canvas-view.service';
 
 @Component({
     selector: 'new-canvas-item',
@@ -45,21 +43,11 @@ export class NewCanvasItem {
 
     private hovering = false;
 
-    private scale: number = INITIAL_SCALE;
-    private translate: Position = INITIAL_TRANSLATE;
-
     constructor(
         private client: Client,
+        private canvasView: CanvasView,
         private store: Store<CanvasState>
     ) {
-        this.store
-            .select(selectTransform)
-            .pipe(takeUntilDestroyed())
-            .subscribe((transform) => {
-                this.scale = transform.scale;
-                this.translate = transform.translate;
-            });
-
         this.store
             .select(selectDragging)
             .pipe(takeUntilDestroyed())
@@ -93,27 +81,10 @@ export class NewCanvasItem {
     }
 
     onDragEnded(event: CdkDragEnd): void {
-        const canvasContainer: any = 
document.getElementById('canvas-container');
-        const rect = canvasContainer.getBoundingClientRect();
         const dropPoint = event.dropPoint;
 
-        // translate the drop point onto the canvas
-        const canvasDropPoint = {
-            x: dropPoint.x - rect.left,
-            y: dropPoint.y - rect.top
-        };
-
-        // if the position is over the canvas fire an event to add the new item
-        if (
-            canvasDropPoint.x >= 0 &&
-            canvasDropPoint.x < rect.width &&
-            canvasDropPoint.y >= 0 &&
-            canvasDropPoint.y < rect.height
-        ) {
-            // adjust the x and y coordinates accordingly
-            const x = canvasDropPoint.x / this.scale - this.translate.x / 
this.scale;
-            const y = canvasDropPoint.y / this.scale - this.translate.y / 
this.scale;
-
+        const position = this.canvasView.getCanvasPosition(dropPoint);
+        if (position) {
             this.store.dispatch(
                 createComponentRequest({
                     request: {
@@ -122,7 +93,7 @@ export class NewCanvasItem {
                             version: 0
                         },
                         type: this.type,
-                        position: { x, y }
+                        position
                     }
                 })
             );
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/ui/common/tooltips/property-tip/property-tip.component.html
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/ui/common/tooltips/property-tip/property-tip.component.html
index f094092019..b2a14e92bb 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/ui/common/tooltips/property-tip/property-tip.component.html
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/ui/common/tooltips/property-tip/property-tip.component.html
@@ -44,7 +44,11 @@
                 <b>History</b>
                 <ul class="px-2">
                     @for (previousValue of propertyHistory.previousValues; 
track previousValue) {
-                        <li>{{ previousValue.previousValue }} - {{ 
previousValue.timestamp }} ({{ previousValue.userIdentity }})</li>
+                        <li>
+                            {{ previousValue.previousValue }} - {{ 
previousValue.timestamp }} ({{
+                                previousValue.userIdentity
+                            }})
+                        </li>
                     }
                 </ul>
             </div>

Reply via email to