feat: refine subflow editing navigation and zoom
This commit is contained in:
parent
760202090d
commit
31e2700c5a
|
|
@ -64,6 +64,7 @@ export class FlowEditor {
|
|||
activeEditorKey = this.editorState.activeEditorKey;
|
||||
subflows = this.editorState.availableSubflows;
|
||||
activeSubflowKey = computed(() => this.editorState.activeSubflow()?.key ?? null);
|
||||
structureNavigationRequest = this.editorState.structureNavigationRequest;
|
||||
readonly = this.editorState.isCurrentFlowReadOnly;
|
||||
validationErrors = this.editorState.flowValidationErrors;
|
||||
validationErrorCount = computed(() => this.validationErrors().length);
|
||||
|
|
@ -205,6 +206,16 @@ export class FlowEditor {
|
|||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const request = this.structureNavigationRequest();
|
||||
if (!request || !this.subflows().length) return;
|
||||
this.activeRightPanel.set('structure');
|
||||
this.assistantOpen.set(true);
|
||||
if (this.tourActive()) {
|
||||
setTimeout(() => this.syncTourLayout());
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.aiCreationRequested() && this.flow()) {
|
||||
this.aiCreationRequested.set(false);
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export const CONTAINER_SUBFLOW_DRAG_MIME = 'application/x-hf-selected-subflow';
|
||||
|
|
@ -668,12 +668,6 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.container-node__dropzone--active {
|
||||
border-color: #0f766e;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(204, 251, 241, 0.95) 0%, rgba(224, 242, 254, 0.98) 100%);
|
||||
}
|
||||
|
||||
.container-node__dropzone--filled {
|
||||
border-style: solid;
|
||||
}
|
||||
|
|
@ -709,64 +703,11 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.container-node__replace-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container-node__replace-confirm-text {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.container-node__replace-confirm-note {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #475569;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.container-node__replace-confirm-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.container-node__replace-confirm-cancel,
|
||||
.container-node__replace-confirm-action {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.container-node__replace-confirm-cancel {
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
box-shadow: inset 0 0 0 1px #cbd5e1;
|
||||
}
|
||||
|
||||
.container-node__replace-confirm-action {
|
||||
background: #0f766e;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:host.container-node--readonly,
|
||||
:host.container-node--readonly * {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.container-node__dropzone-note {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.container-node__dropzone-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -378,21 +378,8 @@
|
|||
@for (flowField of flowFields; track flowField.path) {
|
||||
<div
|
||||
class="container-node__dropzone"
|
||||
[class.container-node__dropzone--active]="!isReadonly && selectedCount > 0"
|
||||
[class.container-node__dropzone--filled]="flowField.blockCount > 0"
|
||||
(dragover)="onDropZoneDragOver($event, flowField)"
|
||||
(dragleave)="onDropZoneDragLeave($event)"
|
||||
(drop)="onDropZoneDrop($event, flowField)">
|
||||
@if (flowField.replaceConfirmOpen) {
|
||||
<div class="container-node__replace-confirm" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
|
||||
<div class="container-node__replace-confirm-text">Replace current {{ flowField.label }}?</div>
|
||||
<div class="container-node__replace-confirm-note">The existing embedded flow will be removed and replaced by the dropped selection.</div>
|
||||
<div class="container-node__replace-confirm-actions">
|
||||
<button type="button" class="container-node__replace-confirm-cancel" (click)="cancelReplaceSubflow($event)">Cancel</button>
|
||||
<button type="button" class="container-node__replace-confirm-action" (click)="confirmReplaceSubflow(flowField, $event)">Replace</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (isAssigning) {
|
||||
[class.container-node__dropzone--filled]="flowField.blockCount > 0">
|
||||
@if (isAssigning) {
|
||||
<div class="container-node__dropzone-main">
|
||||
<span class="container-node__spinner" aria-hidden="true"></span>
|
||||
<span>Updating container...</span>
|
||||
|
|
@ -405,20 +392,17 @@
|
|||
</div>
|
||||
}
|
||||
<div class="container-node__dropzone-main">
|
||||
<span class="container-node__dropzone-empty">Drag a flow here or import from flow</span>
|
||||
</div>
|
||||
<div class="container-node__dropzone-note">
|
||||
Use the selection box in the editor, then drag the floating selection badge into this area.
|
||||
<span class="container-node__dropzone-empty">Import from an existing flow</span>
|
||||
</div>
|
||||
}
|
||||
@if (flowField.blockCount > 0 && !flowField.replaceConfirmOpen && !isAssigning) {
|
||||
@if (flowField.blockCount > 0 && !isAssigning) {
|
||||
<div class="container-node__dropzone-actions">
|
||||
<button type="button" class="container-node__subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview(flowField, $event)">
|
||||
View Flow
|
||||
<button type="button" class="container-node__subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflow(flowField, $event)">
|
||||
Open flow
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (!isReadonly && !flowField.replaceConfirmOpen && !isAssigning) {
|
||||
@if (!isReadonly && !isAssigning) {
|
||||
<div class="container-node__dropzone-actions">
|
||||
<button type="button" class="container-node__import" (pointerdown)="$event.stopPropagation()" (click)="importSubflow(flowField, $event)">
|
||||
@if (flowField.importLoading) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ describe('ContainerNodeComponent', () => {
|
|||
let fixture: ComponentFixture<ContainerNodeComponent>;
|
||||
let fieldRetriever: FieldRetriever;
|
||||
let settingsDialog: NodeSettingsDialogService;
|
||||
let editorState: EditorStateHolder;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
|
|
@ -33,6 +34,7 @@ describe('ContainerNodeComponent', () => {
|
|||
component = fixture.componentInstance;
|
||||
fieldRetriever = TestBed.inject(FieldRetriever);
|
||||
settingsDialog = TestBed.inject(NodeSettingsDialogService);
|
||||
editorState = TestBed.inject(EditorStateHolder);
|
||||
});
|
||||
|
||||
it('keeps empty textarea fields visible when schema conditions enable them', () => {
|
||||
|
|
@ -143,6 +145,82 @@ describe('ContainerNodeComponent', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('shows external flow import without subflow drag-and-drop copy', async () => {
|
||||
component.data = {
|
||||
data: {
|
||||
id: 'container-1',
|
||||
name: 'Container',
|
||||
typeName: 'GenericContainer',
|
||||
specificConfiguration: {
|
||||
subFlow: {
|
||||
blocks: [],
|
||||
containers: [],
|
||||
connections: [],
|
||||
dependencies: []
|
||||
}
|
||||
},
|
||||
inputs: [],
|
||||
outputs: []
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(component as any, 'loadSchemaContext').mockResolvedValue(undefined);
|
||||
(component as any).containerFlowFieldDefinitions = [{
|
||||
path: 'subFlow',
|
||||
label: 'Subflow',
|
||||
retrieverBlockType: 'Flows',
|
||||
retrieverKey: 'subFlow',
|
||||
retrieverUrl: null,
|
||||
retrieverStructuredData: true,
|
||||
retrieverDependsOn: [],
|
||||
validationUrl: null,
|
||||
validationType: null,
|
||||
requiresAuth: false,
|
||||
ui: {}
|
||||
}];
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.textContent).toContain('Import flow');
|
||||
expect(host.textContent).toContain('Import from an existing flow');
|
||||
expect(host.textContent).not.toContain('Drag a flow here');
|
||||
});
|
||||
|
||||
it('opens the container subflow in the editor structure panel instead of a modal', () => {
|
||||
component.data = {
|
||||
data: {
|
||||
id: 'container-1',
|
||||
name: 'Container',
|
||||
typeName: 'GenericContainer',
|
||||
specificConfiguration: {},
|
||||
inputs: [],
|
||||
outputs: []
|
||||
}
|
||||
};
|
||||
const openSpy = vi.spyOn(editorState, 'openSubflowFromActiveContext').mockReturnValue(true);
|
||||
|
||||
component.openSubflow({
|
||||
path: 'subFlow',
|
||||
label: 'Subflow',
|
||||
flow: { blocks: [], containers: [], connections: [], dependencies: [] },
|
||||
blockCount: 1,
|
||||
importLoading: false,
|
||||
retrieverBlockType: 'Flows',
|
||||
retrieverKey: 'subFlow',
|
||||
retrieverUrl: null,
|
||||
retrieverStructuredData: true,
|
||||
retrieverDependsOn: [],
|
||||
validationUrl: null,
|
||||
validationType: null,
|
||||
requiresAuth: false,
|
||||
ui: {}
|
||||
});
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith('container-1', 'subFlow');
|
||||
});
|
||||
|
||||
it('clears dependent retriever fields when the parent field changes', async () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
|
|
|
|||
|
|
@ -21,9 +21,7 @@ import { BlocksService } from '@services/blocks/blocks';
|
|||
import { FieldRetriever } from '@services/retriever/field-retriever';
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { ReteModule } from 'rete-angular-plugin/21';
|
||||
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
|
||||
import { NodeFocusModalController } from '../node-focus-modal-controller';
|
||||
import { BiasAnnotationsComponent } from '@shared/bias-annotations/bias-annotations';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
|
@ -82,7 +80,6 @@ type ContainerDisplaySection = SchemaDisplaySection<ContainerDisplayItem>;
|
|||
type ContainerFlowFieldView = SchemaFlowDataFieldDefinition & {
|
||||
flow: FlowData | null;
|
||||
blockCount: number;
|
||||
replaceConfirmOpen: boolean;
|
||||
importLoading: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -101,7 +98,6 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
private cdr = inject(ChangeDetectorRef);
|
||||
private hostElement = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly focusModal = new NodeFocusModalController(this.hostElement, 'container-node-focus-placeholder');
|
||||
private subflowPreview = inject(SubflowPreviewDialogService);
|
||||
private fieldRetriever = inject(FieldRetriever);
|
||||
private containersService = inject(ContainersService);
|
||||
private blocksService = inject(BlocksService);
|
||||
|
|
@ -112,7 +108,6 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
private containerFieldDefinitions: ContainerFieldDefinition[] = [];
|
||||
private containerFlowFieldDefinitions: SchemaFlowDataFieldDefinition[] = [];
|
||||
deleteConfirmOpen = false;
|
||||
replaceConfirmSelection: { path: string; selection: string[] } | null = null;
|
||||
importLoadingPath: string | null = null;
|
||||
private importErrorMessage: string | null = null;
|
||||
parameterFields: ContainerFieldView[] = [];
|
||||
|
|
@ -290,10 +285,6 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
return !!this.dependencyInput || !!this.dependantOutput;
|
||||
}
|
||||
|
||||
get selectedCount() {
|
||||
return this.editorState.selectedBlockIds().filter((id) => id !== this.blockId).length;
|
||||
}
|
||||
|
||||
get flowFields(): ContainerFlowFieldView[] {
|
||||
return this.resolveFlowFieldDefinitions().map((definition) => {
|
||||
const flow = this.flowAtPath(definition.path);
|
||||
|
|
@ -301,20 +292,11 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
...definition,
|
||||
flow,
|
||||
blockCount: flowDataNodeCount(flow),
|
||||
replaceConfirmOpen: this.replaceConfirmSelection?.path === definition.path,
|
||||
importLoading: this.importLoadingPath === definition.path
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get replaceConfirmOpen() {
|
||||
return !!this.replaceConfirmSelection?.selection.length;
|
||||
}
|
||||
|
||||
get hasFlowDropzones() {
|
||||
return this.flowFields.length > 0;
|
||||
}
|
||||
|
||||
get assignmentErrorMessage() {
|
||||
const value = this.data?.data?.['__containerAssignmentError'];
|
||||
if (typeof value === 'string' && value.length > 0) return value;
|
||||
|
|
@ -512,7 +494,7 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
async importSubflow(flowField: ContainerFlowFieldView, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (this.isReadonly || this.importLoadingPath || this.replaceConfirmOpen) return;
|
||||
if (this.isReadonly || this.importLoadingPath) return;
|
||||
|
||||
this.importLoadingPath = flowField.path;
|
||||
this.importErrorMessage = null;
|
||||
|
|
@ -673,58 +655,6 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
await this.applyFieldValue(definition, currentValue !== true);
|
||||
}
|
||||
|
||||
onDropZoneDragOver(event: DragEvent, flowField: ContainerFlowFieldView) {
|
||||
if (this.isReadonly) return;
|
||||
if (!this.canAcceptSelectionDrop(flowField.path)) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}
|
||||
|
||||
onDropZoneDrop(event: DragEvent, flowField: ContainerFlowFieldView) {
|
||||
if (this.isReadonly) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const raw = event.dataTransfer?.getData(CONTAINER_SUBFLOW_DRAG_MIME);
|
||||
const payload = this.parseDraggedSelection(raw);
|
||||
if (!payload.length) return;
|
||||
|
||||
if (flowField.blockCount > 0) {
|
||||
this.replaceConfirmSelection = { path: flowField.path, selection: payload };
|
||||
this.editorState.stopDraggingSelectedBlocks();
|
||||
return;
|
||||
}
|
||||
|
||||
this.assignSelectionToContainer(flowField, payload);
|
||||
}
|
||||
|
||||
onDropZoneDragLeave(_: DragEvent) {
|
||||
if (this.isReadonly) return;
|
||||
this.editorState.stopDraggingSelectedBlocks();
|
||||
}
|
||||
|
||||
confirmReplaceSubflow(flowField: ContainerFlowFieldView, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (this.isReadonly) return;
|
||||
|
||||
const payload = this.replaceConfirmSelection?.path === flowField.path
|
||||
? this.replaceConfirmSelection.selection
|
||||
: null;
|
||||
this.replaceConfirmSelection = null;
|
||||
if (!payload?.length) return;
|
||||
|
||||
this.assignSelectionToContainer(flowField, payload);
|
||||
}
|
||||
|
||||
cancelReplaceSubflow(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
this.replaceConfirmSelection = null;
|
||||
}
|
||||
|
||||
deleteNode(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
|
@ -757,11 +687,12 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
openSubflowPreview(flowField: ContainerFlowFieldView, event?: Event) {
|
||||
openSubflow(flowField: ContainerFlowFieldView, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (!flowField.flow) return;
|
||||
this.subflowPreview.open(flowField.flow, `${this.name} ${flowField.label}`, this.name);
|
||||
const containerId = this.blockId;
|
||||
if (!containerId) return;
|
||||
this.editorState.openSubflowFromActiveContext(containerId, flowField.path);
|
||||
}
|
||||
|
||||
async openFieldPreview(field: ContainerFieldView, event?: Event) {
|
||||
|
|
@ -831,12 +762,6 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
return normalizeFlowDataValue(getValueByPath(this.configuration ?? {}, path));
|
||||
}
|
||||
|
||||
private canAcceptSelectionDrop(path: string) {
|
||||
return !this.isAssigning
|
||||
&& (!this.replaceConfirmOpen || this.replaceConfirmSelection?.path === path)
|
||||
&& this.selectedCount > 0;
|
||||
}
|
||||
|
||||
private async openReadonlyTextDialog(label: string, value: string) {
|
||||
await this.settingsDialog.open({
|
||||
title: label,
|
||||
|
|
@ -856,28 +781,6 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
private assignSelectionToContainer(flowField: ContainerFlowFieldView, payload: string[]) {
|
||||
this.importErrorMessage = null;
|
||||
const assign = this.data?.data?.assignSelectedBlocksToContainer;
|
||||
if (typeof assign !== 'function' || !payload.length) return;
|
||||
|
||||
void assign(payload, flowField.path, flowField.validationUrl);
|
||||
this.editorState.stopDraggingSelectedBlocks();
|
||||
}
|
||||
|
||||
private parseDraggedSelection(raw: string | undefined) {
|
||||
if (!raw) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePortName(kind: 'input' | 'output', key: string) {
|
||||
const port = this.resolvePortDefinition(kind, key);
|
||||
return typeof port?.name === 'string' && port.name.length > 0 ? port.name : key;
|
||||
|
|
|
|||
|
|
@ -187,25 +187,6 @@
|
|||
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.18);
|
||||
}
|
||||
|
||||
.rete-editor-selection-badge {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 35;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #99f6e4;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 118, 110, 0.96);
|
||||
color: #f0fdfa;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.18);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.rete-editor-selection-box {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
|
|
|
|||
|
|
@ -43,26 +43,6 @@
|
|||
}
|
||||
@if (!readonly()) {
|
||||
<div class="rete-editor-toolbar" [class.rete-editor-toolbar--with-lanes]="swimlanesEnabled && sortedLanes().length">
|
||||
<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
|
||||
|
|
@ -85,17 +65,6 @@
|
|||
|
||||
<div class="h-full w-full rete-editor-canvas" #editor (dragover)="onDragOver($event)" (drop)="onDrop($event)">
|
||||
</div>
|
||||
|
||||
@if (hasSelectedBlocks) {
|
||||
<div
|
||||
class="rete-editor-selection-badge"
|
||||
draggable="true"
|
||||
(dragstart)="onSelectionDragStart($event)"
|
||||
(dragend)="onSelectionDragEnd()">
|
||||
Drag {{ selectedBlockCount }} selected {{ selectedBlockCount === 1 ? 'node' : 'nodes' }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectionBox(); as box) {
|
||||
<div
|
||||
class="rete-editor-selection-box"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { BlocksService } from '@services/blocks/blocks';
|
|||
import { ContainersService } from '@services/containers/containers';
|
||||
import { GraphSelectionService } from '@services/graph-selection/graph-selection';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { RETE_ZOOM_RANGE } from '@utilities/rete-editor';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { ReteEditor } from './rete-editor';
|
||||
|
|
@ -82,4 +83,26 @@ describe('ReteEditor', () => {
|
|||
expect((fixture.nativeElement as HTMLElement).querySelector('.rete-lanes-layer')).toBeNull();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.rete-lane-headers')).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps toolbar zoom using the shared zoom range', async () => {
|
||||
const zoom = vi.fn().mockResolvedValue(undefined);
|
||||
(component as any).rete = {
|
||||
area: {
|
||||
area: {
|
||||
transform: { x: 0, y: 0, k: RETE_ZOOM_RANGE.max },
|
||||
zoom
|
||||
}
|
||||
}
|
||||
};
|
||||
(component as any).container = () => ({
|
||||
nativeElement: document.createElement('div')
|
||||
});
|
||||
|
||||
await (component as any).applyZoom(1.12);
|
||||
expect(zoom).not.toHaveBeenCalled();
|
||||
|
||||
(component as any).rete.area.area.transform.k = RETE_ZOOM_RANGE.min;
|
||||
await (component as any).applyZoom(1 / 1.12);
|
||||
expect(zoom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@ 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';
|
||||
import { CONTAINER_SUBFLOW_DRAG_MIME } from '@shared/nodes/container-node/container-node-drag';
|
||||
import { GraphSelectionService } from '@services/graph-selection/graph-selection';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { addBlockToEditor, createEditor, exportGraph, isProgrammaticNodeTranslation, ReteEditorInstance, setEditorGlobalInputs, setEditorLanes } from '@utilities/rete-editor';
|
||||
import { addBlockToEditor, createEditor, exportGraph, isProgrammaticNodeTranslation, RETE_ZOOM_RANGE, ReteEditorInstance, setEditorGlobalInputs, setEditorLanes } from '@utilities/rete-editor';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { SWIMLANES_ENABLED } from '@shared/feature-flags';
|
||||
|
||||
|
|
@ -118,26 +117,13 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
this.flowState.stopDraggingSelectedBlocks();
|
||||
}
|
||||
|
||||
get selectedBlockCount() {
|
||||
return this.flowState.selectedBlockIds().length;
|
||||
}
|
||||
|
||||
get hasSelectedBlocks() {
|
||||
return !this.readonly() && this.selectedBlockCount > 0;
|
||||
}
|
||||
|
||||
get selectionModeActive() {
|
||||
return this.editorMode() === 'select';
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
const dragTypes = Array.from(event.dataTransfer.types ?? []);
|
||||
event.dataTransfer.dropEffect = dragTypes.includes(CONTAINER_SUBFLOW_DRAG_MIME)
|
||||
? 'move'
|
||||
: 'copy';
|
||||
}
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
async onDrop(event: DragEvent) {
|
||||
|
|
@ -246,24 +232,6 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
this.selectionBox.set(null);
|
||||
}
|
||||
|
||||
onSelectionDragStart(event: DragEvent) {
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const selectedBlockIds = this.flowState.selectedBlockIds();
|
||||
if (!selectedBlockIds.length) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
this.flowState.startDraggingSelectedBlocks(selectedBlockIds);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData(CONTAINER_SUBFLOW_DRAG_MIME, JSON.stringify(selectedBlockIds));
|
||||
}
|
||||
|
||||
onSelectionDragEnd() {
|
||||
this.flowState.stopDraggingSelectedBlocks();
|
||||
}
|
||||
|
||||
onShellClick(event: MouseEvent) {
|
||||
const target = event.target as Element | null;
|
||||
if (!target) {
|
||||
|
|
@ -275,8 +243,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
target.closest('[data-testid="connection"]') ||
|
||||
target.closest('.connection-delete') ||
|
||||
target.closest('[data-testid="node"]') ||
|
||||
target.closest('.rete-editor-toolbar') ||
|
||||
target.closest('.rete-editor-selection-badge')
|
||||
target.closest('.rete-editor-toolbar')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -717,7 +684,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
if (!area || !host) return;
|
||||
|
||||
const currentZoom = area.transform.k || 1;
|
||||
const nextZoom = Math.min(2.4, Math.max(0.35, currentZoom * multiplier));
|
||||
const nextZoom = Math.min(RETE_ZOOM_RANGE.max, Math.max(RETE_ZOOM_RANGE.min, currentZoom * multiplier));
|
||||
if (Math.abs(nextZoom - currentZoom) < 0.001) return;
|
||||
|
||||
await area.zoom(nextZoom, 0, 0);
|
||||
|
|
|
|||
|
|
@ -191,6 +191,48 @@ describe('EditorStateHolder', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('opens a nested subflow from the active editor context and requests the structure panel', async () => {
|
||||
const nestedSubflow: FlowData = { blocks: [], containers: [], connections: [], dependencies: [] };
|
||||
const parentSubflow: FlowData = {
|
||||
blocks: [],
|
||||
containers: [{
|
||||
id: 'nested-container',
|
||||
name: 'Nested container',
|
||||
typeName: 'LoopContainer',
|
||||
nodeFamily: 'container',
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
specificConfiguration: { subFlow: nestedSubflow }
|
||||
}],
|
||||
connections: [],
|
||||
dependencies: []
|
||||
};
|
||||
const flow = makeFlow({
|
||||
data: {
|
||||
blocks: [],
|
||||
containers: [{
|
||||
id: 'container-1',
|
||||
name: 'Container',
|
||||
typeName: 'LoopContainer',
|
||||
nodeFamily: 'container',
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
specificConfiguration: { subFlow: parentSubflow }
|
||||
}],
|
||||
connections: [],
|
||||
dependencies: []
|
||||
}
|
||||
});
|
||||
|
||||
await service.openDocument(flow);
|
||||
expect(service.openSubflow([{ containerId: 'container-1', configurationPath: 'subFlow' }])).toBe(true);
|
||||
|
||||
const previousRequest = service.structureNavigationRequest();
|
||||
expect(service.openSubflowFromActiveContext('nested-container', 'subFlow')).toBe(true);
|
||||
expect(service.activeSubflow()?.key).toBe('container-1:subFlow/nested-container:subFlow');
|
||||
expect(service.structureNavigationRequest()).toBe(previousRequest + 1);
|
||||
});
|
||||
|
||||
it('saves annotations in the full flow payload and adopts server-generated ids', async () => {
|
||||
const flow = makeFlow({
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export class EditorStateHolder {
|
|||
readonly highlightedValidationNodeIds = signal<string[]>([]);
|
||||
readonly validationRequiresSave = signal(false);
|
||||
readonly activeSubflow = signal<FlowSubflowEntry | null>(null);
|
||||
readonly structureNavigationRequest = signal(0);
|
||||
|
||||
/** Derived state */
|
||||
readonly hasFlow = computed(() => !!this.currentFlow());
|
||||
|
|
@ -170,6 +171,19 @@ export class EditorStateHolder {
|
|||
return true;
|
||||
}
|
||||
|
||||
openSubflowFromActiveContext(containerId: string, configurationPath: string): boolean {
|
||||
if (!containerId || !configurationPath) return false;
|
||||
const locator = [
|
||||
...(this.activeSubflow()?.locator ?? []),
|
||||
{ containerId, configurationPath }
|
||||
];
|
||||
const opened = this.openSubflow(locator);
|
||||
if (opened) {
|
||||
this.structureNavigationRequest.update((value) => value + 1);
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
|
||||
setSelectedBlocks(blockIds: string[]) {
|
||||
const unique = Array.from(new Set(blockIds.filter((id) => typeof id === 'string' && id.length > 0)));
|
||||
this.selectedBlockIds.set(unique);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ const editorSockets = new WeakMap<NodeEditor<HFSchemes>, Map<string, ClassicPres
|
|||
const editorRuntime = new WeakMap<NodeEditor<HFSchemes>, ReteRuntimeContext>();
|
||||
const areaProgrammaticTranslations = new WeakMap<AreaPlugin<HFSchemes, AreaExtra>, Set<string>>();
|
||||
|
||||
export const RETE_ZOOM_RANGE = {
|
||||
min: 0.35,
|
||||
max: 2.4
|
||||
} as const;
|
||||
|
||||
export type ReteEditorInstance = {
|
||||
editor: NodeEditor<HFSchemes>;
|
||||
area: AreaPlugin<HFSchemes, AreaExtra>;
|
||||
|
|
@ -150,6 +155,9 @@ export async function createEditor(
|
|||
area.use(render);
|
||||
|
||||
AreaExtensions.simpleNodesOrder(area);
|
||||
AreaExtensions.restrictor(area, {
|
||||
scaling: RETE_ZOOM_RANGE
|
||||
});
|
||||
|
||||
if (flowData)
|
||||
await loadFlowData(editor, area, flowData, runtime);
|
||||
|
|
|
|||
Loading…
Reference in New Issue