Context Menu
This example demonstrates how to implement a context menu that adapts its options based on the context—showing node-specific actions when a node is right-clicked, and diagram-wide actions otherwise.
Context Menu Overview
Section titled “Context Menu Overview”The context menu appears at the cursor position when the user right-clicks on a node or the diagram. The example provides relevant actions such as copy, paste, delete, and select all.
Key Features
Section titled “Key Features”- Context Awareness: Menu options change depending on whether a node or the diagram background is right-clicked.
- Positioning: The menu appears at the exact cursor location using viewport coordinates.
- Integration: Uses Angular signals and services for reactive state management.
Implementation Details
Section titled “Implementation Details”Context Menu Service
Section titled “Context Menu Service”The ContextMenuService
manages the menu’s visibility, position, and context (node or diagram):
@Injectable()export class ContextMenuService { readonly visibility = signal(false); readonly menuPosition = signal({ x: 0, y: 0 }); readonly nodeContext = signal(false);
showMenu({ x, y }: Point): void { this.nodeContext.set(true); this.menuPosition.set({ x, y }); this.visibility.set(true); }
showDiagramMenu({ x, y }: Point): void { this.nodeContext.set(false); this.menuPosition.set({ x, y }); this.visibility.set(true); }
hideMenu(): void { this.visibility.set(false); }}
Menu Component
Section titled “Menu Component”The menu component displays options based on the context and stores information about menu positioning:
<div #menu class="menu" [style.top.px]="menuPosition().y" [style.left.px]="menuPosition().x" [class.show]="showMenu()"> <ul> @if (nodeContext()) { <li (click)="onCopy()">Copy</li> <li (click)="onDelete()">Delete</li> } @else { <li (click)="onSelectAll()">Select All</li> <li (click)="onPaste($event)">Paste</li> } </ul></div>
Node Right-Click Handling
Section titled “Node Right-Click Handling”Nodes handle right-click events to show the context menu and select the node. The menu position is calculated using viewport coordinates:
onRightClick(event: MouseEvent) { event.preventDefault(); event.stopPropagation();
if (this.node()) { // Additionally selects the node on right click this.selectionService.select([this.node().id]);
const cursorPosition = this.viewportService.clientToFlowViewportPosition({ x: event.clientX, y: event.clientY }); this.contextMenuService.showMenu(cursorPosition); }}
Diagram Right-Click Handling
Section titled “Diagram Right-Click Handling”Right-clicking the diagram background shows the diagram-wide menu, also using viewport coordinates:
onDiagramRightClick(event: MouseEvent) { event.preventDefault(); event.stopPropagation();
const cursorPosition = this.viewportService.clientToFlowViewportPosition({ x: event.clientX, y: event.clientY }); this.contextMenuService.showDiagramMenu(cursorPosition);}
Actions
Section titled “Actions”- Copy: Copies the selected node(s) to the clipboard.
- Paste: Pastes copied nodes at the cursor position.
- Delete: Removes the selected node(s).
- Select All: Selects all nodes in the diagram.
import '@angular/compiler';import { Component, inject } from '@angular/core';import { initializeModel, NgDiagramComponent, NgDiagramNodeTemplateMap, NgDiagramViewportService, type NgDiagramConfig,} from 'ng-diagram';import { MenuComponent } from './menu/menu.component';import { ContextMenuService } from './menu/menu.service';import { NodeComponent } from './node/node.component';
@Component({ selector: 'context-menu-example', imports: [NgDiagramComponent, MenuComponent], template: ` <div (contextmenu)="onDiagramRightClick($event)"> <div class="not-content diagram"> <ng-diagram [model]="model" [config]="config" [nodeTemplateMap]="nodeTemplateMap" /> </div> <menu></menu> </div> `, styleUrl: './context-menu-example.component.scss', providers: [ContextMenuService, NgDiagramViewportService],})export class ContextMenuExampleComponent { private contextMenuService = inject(ContextMenuService); private readonly viewportService = inject(NgDiagramViewportService);
nodeTemplateMap = new NgDiagramNodeTemplateMap([ ['customNodeType', NodeComponent], ]);
config = { zoom: { max: 3, }, } satisfies NgDiagramConfig;
model = initializeModel({ nodes: [ { id: '1', position: { x: 100, y: 100 }, type: 'customNodeType', data: { name: 'Custom Node', }, }, ], edges: [], metadata: { viewport: { x: 0, y: 0, scale: 1 } }, });
onDiagramRightClick(event: MouseEvent) { event.preventDefault(); event.stopPropagation();
const cursorPosition = this.viewportService.clientToFlowViewportPosition({ x: event.clientX, y: event.clientY, }); this.contextMenuService.showDiagramMenu(cursorPosition); }}
import '@angular/compiler';import { Component } from '@angular/core';import { NgDiagramModelService, provideNgDiagram } from 'ng-diagram';import { ContextMenuExampleComponent } from './context-menu-example.component';import { ContextMenuService } from './menu/menu.service';
@Component({ selector: 'context-menu-wrapper', imports: [ContextMenuExampleComponent], template: `<context-menu-example></context-menu-example> `, styles: [ ` :host { flex: 1; display: flex; position: relative; } `, ], providers: [ContextMenuService, NgDiagramModelService, provideNgDiagram()],})export class ContextMenuWrapperComponent {}
import { Component, HostListener, inject } from '@angular/core';import { NgDiagramClipboardService, NgDiagramModelService, NgDiagramSelectionService, NgDiagramViewportService,} from 'ng-diagram';import { ContextMenuService } from './menu.service';
@Component({ selector: 'menu', templateUrl: './menu.component.html', styleUrls: ['./menu.component.scss'],})export class MenuComponent { private contextMenuService = inject(ContextMenuService); private readonly modelService = inject(NgDiagramModelService); private readonly clipboardService = inject(NgDiagramClipboardService); private readonly selectionService = inject(NgDiagramSelectionService); private readonly viewportService = inject(NgDiagramViewportService);
showMenu = this.contextMenuService.visibility; menuPosition = this.contextMenuService.menuPosition; nodeContext = this.contextMenuService.nodeContext;
@HostListener('document:click') closeMenu() { this.contextMenuService.hideMenu(); }
onCopy() { this.clipboardService.copy(); }
onPaste(event: MouseEvent) { const position = this.viewportService.clientToFlowPosition({ x: event.clientX, y: event.clientY, }); this.clipboardService.paste(position); }
onDelete() { this.selectionService.deleteSelection(); }
onSelectAll() { const allNodesIds = this.modelService .getModel() .getNodes() .map((node) => node.id); this.selectionService.select(allNodesIds); }}
import { Injectable, signal } from '@angular/core';import type { Point } from 'ng-diagram';
@Injectable()export class ContextMenuService { readonly visibility = signal(false); readonly menuPosition = signal({ x: 0, y: 0 }); readonly nodeContext = signal(false);
showMenu({ x, y }: Point): void { this.nodeContext.set(true); this.menuPosition.set({ x, y }); this.visibility.set(true); }
showDiagramMenu({ x, y }: Point): void { this.nodeContext.set(false); this.menuPosition.set({ x, y }); this.visibility.set(true); }
hideMenu(): void { this.visibility.set(false); }}
import { Component, inject, input, model } from '@angular/core';import { FormsModule } from '@angular/forms';import { MatChipsModule } from '@angular/material/chips';import { MatFormFieldModule } from '@angular/material/form-field';import { MatInputModule } from '@angular/material/input';import { MatSelectModule } from '@angular/material/select';import { NgDiagramNodeSelectedDirective, NgDiagramPortComponent, NgDiagramSelectionService, NgDiagramViewportService, type NgDiagramNodeTemplate, type Node,} from 'ng-diagram';import { ContextMenuService } from '../menu/menu.service';
@Component({ selector: 'node', imports: [ NgDiagramPortComponent, MatSelectModule, MatFormFieldModule, FormsModule, MatInputModule, MatChipsModule, ], templateUrl: './node.component.html', styleUrls: ['./node.component.scss'], hostDirectives: [ { directive: NgDiagramNodeSelectedDirective, inputs: ['node'] }, ],})export class NodeComponent implements NgDiagramNodeTemplate { private readonly contextMenuService = inject(ContextMenuService); private readonly viewportService = inject(NgDiagramViewportService); private readonly selectionService = inject(NgDiagramSelectionService);
text = model<string>(''); node = input.required<Node>();
onRightClick(event: MouseEvent) { event.preventDefault(); event.stopPropagation();
if (this.node()) { // Additionally selects the node on right click this.selectionService.select([this.node().id]);
const cursorPosition = this.viewportService.clientToFlowViewportPosition({ x: event.clientX, y: event.clientY, }); this.contextMenuService.showMenu(cursorPosition); } }}
<div #menu class="menu" [style.top.px]="menuPosition().y" [style.left.px]="menuPosition().x" [class.show]="showMenu()"> <ul> @if (nodeContext()) { <li (click)="onCopy()">Copy</li> <li (click)="onDelete()">Delete</li> } @else { <li (click)="onSelectAll()">Select All</li> <li (click)="onPaste($event)">Paste</li> } </ul></div>
<div class="node" (contextmenu)="onRightClick($event)"> <div class="node-header"> <span>{{ node().data.name }}</span> </div> <div class="node-body"> Lorem ipsum dolor sit amet, consectetur adipiscing elit. </div></div>
:host { width: 100%; height: 100%;}
.diagram { flex: 1; display: flex; height: 20rem; font-family: 'Poppins', sans-serif;}
.menu { position: absolute; display: none; background-color: var(--ngd-node-bg-primary-default); border: 1px solid var(--ngd-node-stroke-primary-default); z-index: 1000; min-width: 120px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);}.menu.show { display: block;}.menu ul { list-style: none; margin: 0; padding: 0;}.menu li { padding: 8px 16px; cursor: pointer;}.menu li:hover { background-color: var(--ngd-node-stroke-primary-hover);}.menu li.disabled { color: #aaa; pointer-events: none;}.content { padding: 40px; border: 1px solid var(--ngd-node-stroke-secondary-default); margin: 20px; user-select: text;}
:host { display: flex; width: 100%; height: 100%; background-color: white; border-radius: 4px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.node { display: flex; flex-direction: column; padding: 4px 8px; border: 1px solid var(--ngd-node-stroke-primary-default); border-radius: 4px; background-color: var(--ngd-node-bg-primary-default);
&-body { font-size: 12px; } }}