- Drag {{ selectedBlockCount }} selected {{ selectedBlockCount === 1 ? 'node' : 'nodes' }}
-
- }
-
@if (selectionBox(); as box) {
{
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();
+ });
});
diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts
index 64e77f2..d853021 100644
--- a/src/app/shared/rete-editor/rete-editor.ts
+++ b/src/app/shared/rete-editor/rete-editor.ts
@@ -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);
diff --git a/src/app/stores/flow-editor.spec.ts b/src/app/stores/flow-editor.spec.ts
index e28482a..773f0e7 100644
--- a/src/app/stores/flow-editor.spec.ts
+++ b/src/app/stores/flow-editor.spec.ts
@@ -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: {
diff --git a/src/app/stores/flow-editor.ts b/src/app/stores/flow-editor.ts
index 086b6dc..16666aa 100644
--- a/src/app/stores/flow-editor.ts
+++ b/src/app/stores/flow-editor.ts
@@ -27,6 +27,7 @@ export class EditorStateHolder {
readonly highlightedValidationNodeIds = signal
([]);
readonly validationRequiresSave = signal(false);
readonly activeSubflow = signal(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);
diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts
index 4cb43da..07bcd2c 100644
--- a/src/app/utilities/rete-editor.ts
+++ b/src/app/utilities/rete-editor.ts
@@ -37,6 +37,11 @@ const editorSockets = new WeakMap, Map, ReteRuntimeContext>();
const areaProgrammaticTranslations = new WeakMap, Set>();
+export const RETE_ZOOM_RANGE = {
+ min: 0.35,
+ max: 2.4
+} as const;
+
export type ReteEditorInstance = {
editor: NodeEditor;
area: AreaPlugin;
@@ -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);