diff --git a/src/app/layouts/flow-editor/flow-editor.ts b/src/app/layouts/flow-editor/flow-editor.ts index 2ba3381..3c5b8ae 100644 --- a/src/app/layouts/flow-editor/flow-editor.ts +++ b/src/app/layouts/flow-editor/flow-editor.ts @@ -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); diff --git a/src/app/shared/nodes/container-node/container-node-drag.ts b/src/app/shared/nodes/container-node/container-node-drag.ts deleted file mode 100644 index cca2a5f..0000000 --- a/src/app/shared/nodes/container-node/container-node-drag.ts +++ /dev/null @@ -1 +0,0 @@ -export const CONTAINER_SUBFLOW_DRAG_MIME = 'application/x-hf-selected-subflow'; diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index 5e3452e..9d23099 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -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; diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index ad4306c..091d155 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -378,21 +378,8 @@ @for (flowField of flowFields; track flowField.path) {
- @if (flowField.replaceConfirmOpen) { -
-
Replace current {{ flowField.label }}?
-
The existing embedded flow will be removed and replaced by the dropped selection.
-
- - -
-
- } @else if (isAssigning) { + [class.container-node__dropzone--filled]="flowField.blockCount > 0"> + @if (isAssigning) {
Updating container... @@ -405,20 +392,17 @@
}
- Drag a flow here or import from flow -
-
- Use the selection box in the editor, then drag the floating selection badge into this area. + Import from an existing flow
} - @if (flowField.blockCount > 0 && !flowField.replaceConfirmOpen && !isAssigning) { + @if (flowField.blockCount > 0 && !isAssigning) {
-
} - @if (!isReadonly && !flowField.replaceConfirmOpen && !isAssigning) { + @if (!isReadonly && !isAssigning) {
- - -
Zoom
- - @if (hasSelectedBlocks) { -
- 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);