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()) { +
+
+
Mode
+ + +
+ +
+
Zoom
+ + +
+
+ } +
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);