Add flow editor toolbar and lock readonly graph drag

This commit is contained in:
Lucio Lelii 2026-03-15 06:29:01 +01:00
parent 533a1e7f43
commit d01344c19e
4 changed files with 171 additions and 2 deletions

View File

@ -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;

View File

@ -5,6 +5,48 @@
(pointermove)="onShellPointerMove($event)"
(pointerup)="onShellPointerUp($event)"
(pointercancel)="onShellPointerUp($event)">
@if (!readonly()) {
<div class="rete-editor-toolbar">
<section class="rete-editor-toolbar-group" aria-label="Editor mode controls">
<div class="rete-editor-toolbar-legend">Mode</div>
<button
type="button"
class="rete-editor-toolbar-btn"
[class.rete-editor-toolbar-btn-active]="!selectionModeActive"
(click)="setEditorMode('standard', $event)"
aria-label="Standard mode">
<i class="bi bi-cursor" aria-hidden="true"></i>
</button>
<button
type="button"
class="rete-editor-toolbar-btn"
[class.rete-editor-toolbar-btn-active]="selectionModeActive"
(click)="setEditorMode('select', $event)"
aria-label="Selection mode">
<i class="bi bi-bounding-box" aria-hidden="true"></i>
</button>
</section>
<section class="rete-editor-toolbar-group" aria-label="Zoom controls">
<div class="rete-editor-toolbar-legend">Zoom</div>
<button
type="button"
class="rete-editor-toolbar-btn"
(click)="zoomIn($event)"
aria-label="Zoom in">
<i class="bi bi-zoom-in" aria-hidden="true"></i>
</button>
<button
type="button"
class="rete-editor-toolbar-btn"
(click)="zoomOut($event)"
aria-label="Zoom out">
<i class="bi bi-zoom-out" aria-hidden="true"></i>
</button>
</section>
</div>
}
<div class="h-full w-full" #editor (dragover)="onDragOver($event)" (drop)="onDrop($event)">
</div>

View File

@ -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);
}
}

View File

@ -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<ReteEditorInstance> {
const editor = new NodeEditor<HFSchemes>();
@ -55,6 +55,7 @@ export async function createEditor(
const connection = new ConnectionPlugin<HFSchemes, AreaExtra>();
const render = new AngularPlugin<HFSchemes, AreaExtra>({ 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);