feat: refine subflow editing navigation and zoom

This commit is contained in:
Lucio Lelii 2026-07-27 17:48:51 +02:00
parent 760202090d
commit 31e2700c5a
13 changed files with 192 additions and 272 deletions

View File

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

View File

@ -1 +0,0 @@
export const CONTAINER_SUBFLOW_DRAG_MIME = 'application/x-hf-selected-subflow';

View File

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

View File

@ -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) {

View File

@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: {

View File

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

View File

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