/
import {CommonModule} from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
type OnInit,
signal,
ViewEncapsulation,
} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {FormControl, ReactiveFormsModule} from '@angular/forms';
import {
DF_PAN_ZOOM_OPTIONS,
DfArrowhead,
DfConnectionPoint,
DfConnectionType,
dfCycleDetectionValidator,
type DfDataConnection,
type DfDataModel,
type DfDataNode,
type DfEvent,
dfIsolatedNodesValidator,
type DfPanZoomOptions,
dfPanZoomOptionsProvider,
NgDrawFlowComponent,
NgDrawFlowStoreService,
provideNgDrawFlowConfigs,
} from '@ng-draw-flow/core';
import {TuiAddonDoc, type TuiRawLoaderContent} from '@taiga-ui/addon-doc';
import {TuiButton, TuiLabel, TuiTextfield} from '@taiga-ui/core';
import {TuiInputNumber} from '@taiga-ui/kit';
import {MarkdownModule} from 'ngx-markdown';
import {distinctUntilChanged, filter} from 'rxjs';
import {SimpleNodeComponent} from '../../../app/modules/nodes';
@Component({
standalone: true,
selector: 'editor',
imports: [
CommonModule,
MarkdownModule,
NgDrawFlowComponent,
ReactiveFormsModule,
TuiAddonDoc,
TuiButton,
TuiInputNumber,
TuiLabel,
TuiTextfield,
],
templateUrl: './editor.component.html',
styleUrl: './editor.component.less',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
dfPanZoomOptionsProvider({leftPosition: 50}),
provideNgDrawFlowConfigs({
connection: {
type: DfConnectionType.SmoothStep,
arrowhead: {type: DfArrowhead.Arrow},
curvature: 10,
},
nodes: {simpleNode: SimpleNodeComponent},
options: {
nodesDraggable: true,
nodesDeletable: true,
connectionsDeletable: true,
connectionsCreatable: true,
},
}),
],
})
export default class EditorComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
public readonly panZoomOptions = inject<DfPanZoomOptions>(DF_PAN_ZOOM_OPTIONS);
public readonly drawFlowStore = inject(NgDrawFlowStoreService);
public readonly customNodeExample: Record<string, TuiRawLoaderContent> = {
Typescript: import('./editor.component.ts?raw'),
Styles: import('./editor.component.less?raw'),
Template: import('./editor.component.html?raw'),
};
public data: DfDataModel = {
nodes: [
{
id: 'node-1',
data: {type: 'simpleNode', text: 'This base node'},
position: {x: 0, y: 0},
startNode: true,
},
{
id: 'node-2',
data: {type: 'simpleNode', text: 'This child node 1'},
position: {x: 250, y: 50},
},
{
id: 'node-3',
data: {type: 'simpleNode', text: 'This child node 2'},
position: {x: 250, y: -50},
},
{
id: 'node-4',
data: {type: 'simpleNode', text: 'This child node 3'},
position: {x: 500, y: 0},
endNode: true,
},
],
connections: [
{
source: {
nodeId: 'node-1',
connectorType: DfConnectionPoint.Output,
connectorId: 'node-1-output-1',
},
target: {
nodeId: 'node-2',
connectorType: DfConnectionPoint.Input,
connectorId: 'node-2-input-1',
},
},
{
source: {
nodeId: 'node-1',
connectorType: DfConnectionPoint.Output,
connectorId: 'node-1-output-1',
},
target: {
nodeId: 'node-3',
connectorType: DfConnectionPoint.Input,
connectorId: 'node-3-input-1',
},
},
{
source: {
nodeId: 'node-2',
connectorType: DfConnectionPoint.Output,
connectorId: 'node-2-output-1',
},
target: {
nodeId: 'node-4',
connectorType: DfConnectionPoint.Input,
connectorId: 'node-4-input-1',
},
},
{
source: {
nodeId: 'node-3',
connectorType: DfConnectionPoint.Output,
connectorId: 'node-3-output-1',
},
target: {
nodeId: 'node-4',
connectorType: DfConnectionPoint.Input,
connectorId: 'node-4-input-1',
},
},
],
};
public readonly scaleControl = new FormControl<number>(1, {nonNullable: true});
public readonly fullscreen = signal(false);
public counter = 0;
public form = new FormControl<DfDataModel>(this.data, [
dfCycleDetectionValidator(),
dfIsolatedNodesValidator(),
]);
public ngOnInit(): void {
this.form.statusChanges.subscribe((v) => {
console.warn(v, 'onStatusChange');
});
this.form.valueChanges.pipe(filter(Boolean)).subscribe((value: DfDataModel) => {
this.data = value;
});
this.scaleControl.valueChanges
.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
.subscribe((value: number) => {
if (!Number.isFinite(value)) {
return;
}
this.drawFlowStore.setScale(value);
});
this.drawFlowStore.connectionCreated$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data: DfEvent<DfDataConnection>) => {
console.warn(data, 'drawFlowStore connectionCreated');
});
this.drawFlowStore.connectionDeleted$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data: DfEvent<DfDataConnection>) => {
console.warn(data, 'drawFlowStore connectionDeleted');
});
this.drawFlowStore.connectionSelected$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data: DfDataConnection) => {
console.warn(data, 'drawFlowStore connectionSelected');
});
this.drawFlowStore.nodeDeleted$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data: DfEvent<DfDataNode>) => {
console.warn(data, 'drawFlowStore nodeDeleted');
});
this.drawFlowStore.nodeMoved$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data: DfEvent<DfDataNode>) => {
console.warn(data, 'drawFlowStore nodeMoved');
});
this.drawFlowStore.nodeSelected$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data: DfDataNode) => {
console.warn(data, 'drawFlowStore nodeSelected');
});
}
public onScaleChange(zoomLevel: number): void {
this.scaleControl.setValue(zoomLevel / 100, {emitEvent: false});
}
public onConnectionCreated(event: DfEvent<DfDataConnection>): void {
console.warn(event, 'onConnectionCreated');
}
public onConnectionDeleted(event: DfEvent<DfDataConnection>): void {
console.warn(event, 'onConnectionDeleted');
}
public onConnectionSelected(event: DfDataConnection): void {
console.warn(event, 'onConnectionSelected');
}
public onNodeMoved(event: DfEvent<DfDataNode>): void {
console.warn(event, 'onNodeMoved');
}
public onNodeSelected(event: DfDataNode): void {
console.warn(event, 'onNodeSelected');
}
public onNodeDeleted(event: DfEvent<DfDataNode>): void {
console.warn(event, 'onNodeDeleted');
}
public toggleFullscreen(): void {
this.fullscreen.update((value) => !value);
this.drawFlowStore.resetPosition();
}
public addNodeToDrawFlow(): void {
const id = `new-node-${this.counter}`;
this.data.nodes.push({
id,
data: {
type: 'simpleNode',
text: `created node ${this.counter + 1}`,
},
});
this.form.setValue(this.data);
this.counter++;
}
}
.editor {
position: relative;
block-size: 31.25rem;
inline-size: 100%;
margin: 0 auto;
background: #fff;
&.fullscreen {
position: fixed;
z-index: 1;
inset-block-start: 0;
inset-inline-start: 0;
inline-size: 100%;
block-size: 100%;
background-color: #fff;
.add {
inset-block-start: 5rem;
}
.fullscreen-btn {
inset-block-start: 7.8rem;
}
.remove-node-btn {
inset-block-start: 10.6rem;
}
.selection-info {
inset-block-start: 5rem;
}
}
}
.add {
position: absolute;
inset-inline-end: 1.25rem;
inset-block-start: 1.25rem;
}
.fullscreen-btn {
position: absolute;
inset-inline-end: 1.25rem;
inset-block-start: 3.75rem;
}
.remove-node-btn {
position: absolute;
inset-inline-end: 1.25rem;
inset-block-start: 6.25rem;
}
.scale-controls {
position: absolute;
display: flex;
inset-block-end: 1.25rem;
inset-inline-end: 1.25rem;
align-items: center;
gap: 0.75rem;
}
.scale-value {
inline-size: 3.125rem;
text-align: center;
color: #333;
}
.scale-field {
background: #fff;
}
.selection-info {
position: absolute;
display: flex;
inset-block-start: 1.25rem;
inset-inline-start: 1.25rem;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 1rem;
min-inline-size: 11rem;
background-color: rgba(255, 255, 255, 0.95);
border: 1px solid #dcdcdc;
border-radius: 0.5rem;
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.08);
}
.selection-title {
font-weight: 600;
color: #1f1f1f;
}
.selection-value {
font-size: 0.875rem;
color: #555;
}
<tui-doc-page header="Editor">
Our graph editor is built to serve as an API-first platform, enabling developers to design their own UI elements and
interact with the graph through exposed methods. This approach allows for the addition of new nodes and control over
the zoom level, complete with the ability to display the current zoom, all while providing a customizable experience
tailored to your application's needs.
<section class="tui-space_top-4">
<tui-doc-example
style="padding: 0"
[content]="customNodeExample"
>
<div
class="editor"
[ngClass]="{fullscreen: fullscreen()}"
>
<ng-draw-flow
[formControl]="form"
(connectionCreated)="onConnectionCreated($event)"
(connectionDeleted)="onConnectionDeleted($event)"
(connectionSelected)="onConnectionSelected($event)"
(nodeDeleted)="onNodeDeleted($event)"
(nodeMoved)="onNodeMoved($event)"
(nodeSelected)="onNodeSelected($event)"
(scale)="onScaleChange($event)"
/>
<button
size="s"
tuiButton
type="button"
class="add"
(click)="addNodeToDrawFlow()"
>
Add node
</button>
<button
size="s"
tuiButton
type="button"
class="fullscreen-btn"
(click)="toggleFullscreen()"
>
{{ fullscreen() ? 'Exit from fullscreen' : 'Fullscreen' }}
</button>
<button
size="s"
tuiButton
type="button"
class="remove-node-btn"
(click)="drawFlowStore.removeNode('node-3')"
>
Remove node-3
</button>
<div class="scale-controls">
<button
size="s"
tuiButton
type="button"
(click)="drawFlowStore.zoomOut()"
>
zoomOut
</button>
<tui-textfield
tuiTextfieldSize="m"
tuiTheme="light"
class="scale-field"
[tuiTextfieldCleaner]="false"
>
<label tuiLabel>Set scale</label>
<input
aria-label="Set scale"
tuiInputNumber
[formControl]="scaleControl"
[max]="panZoomOptions.maxZoom"
[min]="panZoomOptions.minZoom"
[step]="panZoomOptions.zoomStep"
/>
</tui-textfield>
<div class="scale-value">{{ drawFlowStore.scale() }}%</div>
<button
size="s"
tuiButton
type="button"
(click)="drawFlowStore.zoomIn()"
>
zoomIn
</button>
</div>
<div class="selection-info">
@if (drawFlowStore.selectedNode(); as node) {
<div class="selection-title">Selected node</div>
<div class="selection-value">{{ node.id }}</div>
} @else {
@if (drawFlowStore.selectedConnection(); as connection) {
<div class="selection-title">Selected connection</div>
<div class="selection-value">
{{ connection.source.nodeId }} → {{ connection.target.nodeId }}
</div>
} @else {
<div class="selection-title">Nothing selected</div>
}
}
</div>
</div>
</tui-doc-example>
</section>
</tui-doc-page>