diff --git a/src/app/shared/rete-editor/rete-editor.css b/src/app/shared/rete-editor/rete-editor.css
index 9ee8af7..9498d67 100644
--- a/src/app/shared/rete-editor/rete-editor.css
+++ b/src/app/shared/rete-editor/rete-editor.css
@@ -12,6 +12,74 @@
overflow: hidden;
}
+.rete-editor-toolbar {
+ position: sticky;
+ top: 12px;
+ left: 0;
+ z-index: 36;
+ display: inline-flex;
+ gap: 10px;
+ align-items: flex-start;
+ padding-left: 12px;
+ pointer-events: none;
+}
+
+.rete-editor-toolbar-group {
+ position: relative;
+ display: inline-flex;
+ gap: 6px;
+ align-items: center;
+ padding: 12px 10px 10px;
+ border: 1px solid rgba(148, 163, 184, 0.72);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.94);
+ box-shadow: 0 14px 28px rgba(15, 23, 42, 0.14);
+ backdrop-filter: blur(12px);
+ pointer-events: auto;
+}
+
+.rete-editor-toolbar-legend {
+ position: absolute;
+ top: -10px;
+ left: 12px;
+ padding: 2px 8px;
+ border: 1px solid rgba(148, 163, 184, 0.7);
+ border-radius: 999px;
+ background: #f8fafc;
+ color: #475569;
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.rete-editor-toolbar-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 38px;
+ height: 38px;
+ border: 1px solid rgba(203, 213, 225, 0.95);
+ border-radius: 12px;
+ background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
+ color: #0f172a;
+ font-size: 16px;
+ transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
+}
+
+.rete-editor-toolbar-btn:hover {
+ border-color: #94a3b8;
+ box-shadow: 0 10px 18px rgba(15, 23, 42, 0.12);
+ transform: translateY(-1px);
+}
+
+.rete-editor-toolbar-btn-active {
+ border-color: #0f766e;
+ background: linear-gradient(180deg, #ccfbf1 0%, #99f6e4 100%);
+ color: #134e4a;
+ box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.18);
+}
+
.rete-editor-selection-badge {
position: absolute;
top: 16px;
diff --git a/src/app/shared/rete-editor/rete-editor.html b/src/app/shared/rete-editor/rete-editor.html
index 1b99706..ea1f296 100644
--- a/src/app/shared/rete-editor/rete-editor.html
+++ b/src/app/shared/rete-editor/rete-editor.html
@@ -5,6 +5,48 @@
(pointermove)="onShellPointerMove($event)"
(pointerup)="onShellPointerUp($event)"
(pointercancel)="onShellPointerUp($event)">
+ @if (!readonly()) {
+
+ }
+
diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts
index e1063b8..5499b02 100644
--- a/src/app/shared/rete-editor/rete-editor.ts
+++ b/src/app/shared/rete-editor/rete-editor.ts
@@ -1,5 +1,6 @@
import { Component, ElementRef, Injector, input, OnChanges, OnDestroy, output, signal, SimpleChanges, ViewChild } from '@angular/core';
import { BlockType, FlowData, FlowNode } from '@models/flow';
+import { Drag } from 'rete-area-plugin';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
@@ -36,6 +37,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
private suppressDirtyEvents = false;
creatingEmptyBlock = false;
creatingEmptyBlockType = '';
+ editorMode = signal<'standard' | 'select'>('standard');
selectionBox = signal<{ left: number; top: number; width: number; height: number } | null>(null);
private selectionPointerId: number | null = null;
private selectionStart: { x: number; y: number } | null = null;
@@ -75,6 +77,10 @@ export class ReteEditor implements OnChanges, OnDestroy {
return !this.readonly() && this.selectedBlockCount > 0;
}
+ get selectionModeActive() {
+ return this.editorMode() === 'select';
+ }
+
onDragOver(event: DragEvent) {
event.preventDefault();
if (event.dataTransfer) {
@@ -130,6 +136,31 @@ export class ReteEditor implements OnChanges, OnDestroy {
this.shell.nativeElement.setPointerCapture(event.pointerId);
}
+ setEditorMode(mode: 'standard' | 'select', event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ if (this.readonly()) return;
+ this.editorMode.set(mode);
+ this.syncAreaDragMode();
+ if (mode === 'standard') {
+ this.selectionPointerId = null;
+ this.selectionStart = null;
+ this.selectionBox.set(null);
+ }
+ }
+
+ zoomIn(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ void this.applyZoom(1.12);
+ }
+
+ zoomOut(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ void this.applyZoom(1 / 1.12);
+ }
+
onShellPointerMove(event: PointerEvent) {
if (this.selectionPointerId !== event.pointerId || !this.selectionStart) return;
@@ -193,7 +224,8 @@ export class ReteEditor implements OnChanges, OnDestroy {
host.innerHTML = '';
const rete = await createEditor(host, this.injector, this.flowData(), {
- nodeView: this.nodeView()
+ nodeView: this.nodeView(),
+ readonly: this.readonly()
});
if (currentVersion !== this.loadVersion) {
rete.area.destroy();
@@ -201,6 +233,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
}
this.rete = rete;
+ this.syncAreaDragMode();
const loadedFlowId = this.flowId();
const normalizedData = exportGraph(rete.editor);
if (this.flowState.currentFlow()?.id === loadedFlowId) {
@@ -270,6 +303,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
}
private canStartSelection(target: EventTarget | null) {
+ if (!this.selectionModeActive) return false;
const element = target instanceof HTMLElement ? target : null;
if (!element) return false;
if (element.closest('[data-testid="node"]')) return false;
@@ -277,6 +311,13 @@ export class ReteEditor implements OnChanges, OnDestroy {
return true;
}
+ private syncAreaDragMode() {
+ const area = this.rete?.area?.area;
+ if (!area) return;
+
+ area.setDragHandler(this.selectionModeActive ? null : new Drag());
+ }
+
private resolveBlocksInsideSelection() {
const selection = this.selectionBox();
if (!selection) return [];
@@ -308,4 +349,16 @@ export class ReteEditor implements OnChanges, OnDestroy {
|| targetRect.top > selectionRect.bottom
);
}
+
+ private async applyZoom(multiplier: number) {
+ const area = this.rete?.area?.area;
+ const host = this.container?.nativeElement as HTMLElement | undefined;
+ if (!area || !host) return;
+
+ const currentZoom = area.transform.k || 1;
+ const nextZoom = Math.min(2.4, Math.max(0.35, currentZoom * multiplier));
+ if (Math.abs(nextZoom - currentZoom) < 0.001) return;
+
+ await area.zoom(nextZoom, 0, 0);
+ }
}
diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts
index 04e7f40..5d516fe 100644
--- a/src/app/utilities/rete-editor.ts
+++ b/src/app/utilities/rete-editor.ts
@@ -47,7 +47,7 @@ export async function createEditor(
container: HTMLElement,
injector: Injector,
flowData: FlowData,
- options?: { nodeView?: "editor" | "execution" }
+ options?: { nodeView?: "editor" | "execution"; readonly?: boolean }
): Promise {
const editor = new NodeEditor();
@@ -55,6 +55,7 @@ export async function createEditor(
const connection = new ConnectionPlugin();
const render = new AngularPlugin({ injector });
const nodeView = options?.nodeView ?? "editor";
+ const readonly = options?.readonly === true;
const runtime: ReteRuntimeContext = {
blocksService: injector.get(BlocksService),
containersService: injector.get(ContainersService),
@@ -107,6 +108,11 @@ export async function createEditor(
AreaExtensions.simpleNodesOrder(area);
+ area.addPipe((context: any) => {
+ if (readonly && context?.type === 'nodetranslate') return;
+ return context;
+ });
+
editor.use(area);
area.use(connection);
area.use(render);