diff --git a/src/app/layouts/app-layout/app-layout.css b/src/app/layouts/app-layout/app-layout.css
index 63ce26f..0a9c1c1 100644
--- a/src/app/layouts/app-layout/app-layout.css
+++ b/src/app/layouts/app-layout/app-layout.css
@@ -45,6 +45,8 @@
min-height: 48px;
border-radius: 0;
font-weight: 600;
+ user-select: none;
+ -webkit-user-select: none;
}
.app-shell {
diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css
index 49f29aa..a3ad869 100644
--- a/src/app/shared/nodes/container-node/container-node.css
+++ b/src/app/shared/nodes/container-node/container-node.css
@@ -11,6 +11,28 @@
overflow: hidden;
}
+.container-node__focus-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 9990;
+ background: rgba(15, 23, 42, 0.36);
+ backdrop-filter: blur(10px) saturate(0.8);
+}
+
+.container-node.container-node--focus {
+ position: fixed;
+ inset: 0;
+ margin: auto;
+ z-index: 9991;
+ width: 340px;
+ max-width: calc(100vw - 32px);
+ height: fit-content;
+ max-height: calc(100vh - 32px);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ box-shadow: 0 30px 70px rgba(15, 23, 42, 0.36);
+}
+
.container-node__delete-overlay {
position: absolute;
inset: 0;
@@ -173,7 +195,8 @@
flex: 0 0 auto;
}
-.container-node__delete {
+.container-node__delete,
+.container-node__focus {
border: 0;
width: 30px;
height: 30px;
diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html
index 90a9829..e03d71f 100644
--- a/src/app/shared/nodes/container-node/container-node.html
+++ b/src/app/shared/nodes/container-node/container-node.html
@@ -1,4 +1,13 @@
-
+@if (focusOpen) {
+
+}
+
@if (isSchemaLoading && !isAssigning) {
@@ -51,6 +60,10 @@
}
@if (!isReadonly) {
+
+ @if (!focusOpen) {
@@ -58,6 +71,7 @@
}
+ }
diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts
index b9f45cb..a36333e 100644
--- a/src/app/shared/nodes/container-node/container-node.ts
+++ b/src/app/shared/nodes/container-node/container-node.ts
@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, HostListener, Input, inject } from '@angular/core';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, HostListener, Input, OnDestroy, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
@@ -74,9 +74,12 @@ type StructuredRetrieverConfig = {
},
changeDetection: ChangeDetectionStrategy.OnPush
})
-export class ContainerNodeComponent {
+export class ContainerNodeComponent implements OnDestroy {
+ private static readonly BODY_LOCK_CLASS = 'node-focus-modal-open';
+ private static readonly BODY_LOCK_COUNT_ATTR = 'data-node-focus-lock-count';
private editorState = inject(EditorStateHolder);
private cdr = inject(ChangeDetectorRef);
+ private hostElement = inject
>(ElementRef);
private subflowPreview = inject(SubflowPreviewDialogService);
private fieldRetriever = inject(FieldRetriever);
private containersService = inject(ContainersService);
@@ -94,7 +97,12 @@ export class ContainerNodeComponent {
parameterDisplayItems: ContainerDisplayItem[] = [];
parameterDisplaySections: ContainerDisplaySection[] = [];
schemaReady = false;
+ focusOpen = false;
private schemaLoading = false;
+ private focusPlaceholder: Comment | null = null;
+ private focusOriginalParent: Node | null = null;
+ private focusOriginalNextSibling: Node | null = null;
+ private pageScrollLocked = false;
nameEditorOpen = false;
draftName = '';
@@ -132,6 +140,18 @@ export class ContainerNodeComponent {
void this.loadSchemaContext();
}
+ @HostListener('document:keydown.escape')
+ onEscape() {
+ if (this.focusOpen) {
+ this.setFocusOpen(false);
+ }
+ }
+
+ ngOnDestroy() {
+ this.releasePageScrollLock();
+ this.restoreHostFromModalLayer();
+ }
+
ngAfterViewInit() {
this.rendered();
}
@@ -140,6 +160,87 @@ export class ContainerNodeComponent {
return this.data?.data?.['__readonly'] === true;
}
+ toggleFocus(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ this.setFocusOpen(!this.focusOpen);
+ }
+
+ closeFocus(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ this.setFocusOpen(false);
+ }
+
+ private setFocusOpen(value: boolean) {
+ if (this.focusOpen === value) return;
+ this.focusOpen = value;
+ if (value) {
+ this.attachHostToModalLayer();
+ this.applyPageScrollLock();
+ } else {
+ this.releasePageScrollLock();
+ this.restoreHostFromModalLayer();
+ }
+ this.cdr.markForCheck();
+ }
+
+ private applyPageScrollLock() {
+ if (this.pageScrollLocked) return;
+ const body = document.body;
+ const currentCount = Number(body.getAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR) ?? '0');
+ const nextCount = Number.isFinite(currentCount) ? currentCount + 1 : 1;
+ body.setAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR, String(nextCount));
+ body.classList.add(ContainerNodeComponent.BODY_LOCK_CLASS);
+ this.pageScrollLocked = true;
+ }
+
+ private releasePageScrollLock() {
+ if (!this.pageScrollLocked) return;
+ const body = document.body;
+ const currentCount = Number(body.getAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR) ?? '0');
+ const nextCount = Number.isFinite(currentCount) ? Math.max(0, currentCount - 1) : 0;
+
+ if (nextCount === 0) {
+ body.removeAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR);
+ body.classList.remove(ContainerNodeComponent.BODY_LOCK_CLASS);
+ } else {
+ body.setAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR, String(nextCount));
+ }
+
+ this.pageScrollLocked = false;
+ }
+
+ private attachHostToModalLayer() {
+ const host = this.hostElement.nativeElement;
+ const parent = host.parentNode;
+ if (!parent || host.parentNode === document.body) return;
+
+ this.focusOriginalParent = parent;
+ this.focusOriginalNextSibling = host.nextSibling;
+ this.focusPlaceholder = document.createComment('container-node-focus-placeholder');
+ parent.insertBefore(this.focusPlaceholder, host);
+ document.body.appendChild(host);
+ }
+
+ private restoreHostFromModalLayer() {
+ const host = this.hostElement.nativeElement;
+ if (!this.focusOriginalParent) return;
+
+ if (this.focusPlaceholder?.parentNode === this.focusOriginalParent) {
+ this.focusOriginalParent.insertBefore(host, this.focusPlaceholder);
+ this.focusOriginalParent.removeChild(this.focusPlaceholder);
+ } else if (this.focusOriginalNextSibling?.parentNode === this.focusOriginalParent) {
+ this.focusOriginalParent.insertBefore(host, this.focusOriginalNextSibling);
+ } else {
+ this.focusOriginalParent.appendChild(host);
+ }
+
+ this.focusPlaceholder = null;
+ this.focusOriginalParent = null;
+ this.focusOriginalNextSibling = null;
+ }
+
get name() {
return String(this.configuration?.['name'] ?? this.data?.data?.name ?? 'Container');
}
diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css
index bd77f5b..7c8808e 100644
--- a/src/app/shared/nodes/generic-node/generic-node.css
+++ b/src/app/shared/nodes/generic-node/generic-node.css
@@ -11,6 +11,28 @@
color: #0f172a;
}
+.llm-node__focus-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 9990;
+ background: rgba(15, 23, 42, 0.36);
+ backdrop-filter: blur(10px) saturate(0.8);
+}
+
+.llm-node.llm-node--focus {
+ position: fixed;
+ inset: 0;
+ margin: auto;
+ z-index: 9991;
+ width: 280px;
+ max-width: calc(100vw - 32px);
+ height: fit-content;
+ max-height: calc(100vh - 32px);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ box-shadow: 0 30px 70px rgba(15, 23, 42, 0.34);
+}
+
:host.llm-node-readonly,
:host.llm-node-readonly * {
cursor: default !important;
@@ -367,8 +389,6 @@
display: inline-flex;
align-items: center;
justify-content: center;
- cursor: pointer;
- transition: background-color 0.15s ease;
}
.llm-clone-btn {
@@ -384,11 +404,6 @@
align-items: center;
justify-content: center;
cursor: pointer;
- transition: background-color 0.15s ease;
-}
-
-.llm-clone-btn:hover {
- background: rgba(30, 64, 175, 0.34);
}
.llm-delete-btn:hover {
diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html
index 099cafc..5e8c92f 100644
--- a/src/app/shared/nodes/generic-node/generic-node.html
+++ b/src/app/shared/nodes/generic-node/generic-node.html
@@ -1,7 +1,13 @@
+@if (focusOpen) {
+
+}
+ [class.llm-node--human]="isHumanNode()"
+ [class.llm-node--focus]="focusOpen"
+ (pointerdown)="focusOpen && $event.stopPropagation()"
+ (click)="focusOpen && $event.stopPropagation()">
@if (isCreatingOnServer()) {
@@ -74,6 +80,15 @@
}
@if (!isReadonly) {
+
+ @if (!focusOpen) {
@@ -81,6 +96,7 @@
}
+ }
diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts
index e41cf4f..3e367c4 100644
--- a/src/app/shared/nodes/generic-node/generic-node.ts
+++ b/src/app/shared/nodes/generic-node/generic-node.ts
@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, HostListener, inject, Input } from '@angular/core';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, HostListener, inject, Input, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, normalizeFlowPortValueKinds } from '@models/flow';
@@ -129,13 +129,16 @@ type RenderedSocketPort = {
},
changeDetection: ChangeDetectionStrategy.OnPush
})
-export class GenericNodeComponent {
+export class GenericNodeComponent implements OnDestroy {
+ private static readonly BODY_LOCK_CLASS = 'node-focus-modal-open';
+ private static readonly BODY_LOCK_COUNT_ATTR = 'data-node-focus-lock-count';
private settingsDialog = inject(NodeSettingsDialogService);
private editorState = inject(EditorStateHolder);
private fieldRetriever = inject(FieldRetriever);
private blocksService = inject(BlocksService);
private cdr = inject(ChangeDetectorRef);
+ private hostElement = inject>(ElementRef);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@@ -184,6 +187,7 @@ export class GenericNodeComponent {
localEditorUseInput = false;
localEditorBindableInputName: string | null = null;
deleteConfirmOpen = false;
+ focusOpen = false;
schemaReady = false;
private schemaLoading = false;
@@ -199,6 +203,10 @@ export class GenericNodeComponent {
private schemaRequirements: SchemaRequirements = { required: [], requiredObjects: [], conditional: [] };
private conditionalRequiredByPath = new Map();
private refreshingConditionalRequirements = false;
+ private focusPlaceholder: Comment | null = null;
+ private focusOriginalParent: Node | null = null;
+ private focusOriginalNextSibling: Node | null = null;
+ private pageScrollLocked = false;
ngOnInit() {
this.outputs = [];
@@ -243,6 +251,18 @@ export class GenericNodeComponent {
void this.loadSchemaContext();
}
+ @HostListener('document:keydown.escape')
+ onEscape() {
+ if (this.focusOpen) {
+ this.setFocusOpen(false);
+ }
+ }
+
+ ngOnDestroy() {
+ this.releasePageScrollLock();
+ this.restoreHostFromModalLayer();
+ }
+
ngAfterViewInit() {
this.rendered();
}
@@ -255,6 +275,87 @@ export class GenericNodeComponent {
return this.blockId ?? 'unknown-id';
}
+ toggleFocus(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ this.setFocusOpen(!this.focusOpen);
+ }
+
+ closeFocus(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ this.setFocusOpen(false);
+ }
+
+ private setFocusOpen(value: boolean) {
+ if (this.focusOpen === value) return;
+ this.focusOpen = value;
+ if (value) {
+ this.attachHostToModalLayer();
+ this.applyPageScrollLock();
+ } else {
+ this.releasePageScrollLock();
+ this.restoreHostFromModalLayer();
+ }
+ this.cdr.markForCheck();
+ }
+
+ private applyPageScrollLock() {
+ if (this.pageScrollLocked) return;
+ const body = document.body;
+ const currentCount = Number(body.getAttribute(GenericNodeComponent.BODY_LOCK_COUNT_ATTR) ?? '0');
+ const nextCount = Number.isFinite(currentCount) ? currentCount + 1 : 1;
+ body.setAttribute(GenericNodeComponent.BODY_LOCK_COUNT_ATTR, String(nextCount));
+ body.classList.add(GenericNodeComponent.BODY_LOCK_CLASS);
+ this.pageScrollLocked = true;
+ }
+
+ private releasePageScrollLock() {
+ if (!this.pageScrollLocked) return;
+ const body = document.body;
+ const currentCount = Number(body.getAttribute(GenericNodeComponent.BODY_LOCK_COUNT_ATTR) ?? '0');
+ const nextCount = Number.isFinite(currentCount) ? Math.max(0, currentCount - 1) : 0;
+
+ if (nextCount === 0) {
+ body.removeAttribute(GenericNodeComponent.BODY_LOCK_COUNT_ATTR);
+ body.classList.remove(GenericNodeComponent.BODY_LOCK_CLASS);
+ } else {
+ body.setAttribute(GenericNodeComponent.BODY_LOCK_COUNT_ATTR, String(nextCount));
+ }
+
+ this.pageScrollLocked = false;
+ }
+
+ private attachHostToModalLayer() {
+ const host = this.hostElement.nativeElement;
+ const parent = host.parentNode;
+ if (!parent || host.parentNode === document.body) return;
+
+ this.focusOriginalParent = parent;
+ this.focusOriginalNextSibling = host.nextSibling;
+ this.focusPlaceholder = document.createComment('generic-node-focus-placeholder');
+ parent.insertBefore(this.focusPlaceholder, host);
+ document.body.appendChild(host);
+ }
+
+ private restoreHostFromModalLayer() {
+ const host = this.hostElement.nativeElement;
+ if (!this.focusOriginalParent) return;
+
+ if (this.focusPlaceholder?.parentNode === this.focusOriginalParent) {
+ this.focusOriginalParent.insertBefore(host, this.focusPlaceholder);
+ this.focusOriginalParent.removeChild(this.focusPlaceholder);
+ } else if (this.focusOriginalNextSibling?.parentNode === this.focusOriginalParent) {
+ this.focusOriginalParent.insertBefore(host, this.focusOriginalNextSibling);
+ } else {
+ this.focusOriginalParent.appendChild(host);
+ }
+
+ this.focusPlaceholder = null;
+ this.focusOriginalParent = null;
+ this.focusOriginalNextSibling = null;
+ }
+
async openNameEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
diff --git a/src/app/shared/title-toolbar/title-toolbar.css b/src/app/shared/title-toolbar/title-toolbar.css
index 57d7567..bb378a1 100644
--- a/src/app/shared/title-toolbar/title-toolbar.css
+++ b/src/app/shared/title-toolbar/title-toolbar.css
@@ -1,3 +1,17 @@
+:host {
+ display: block;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+:host input,
+:host textarea,
+:host select,
+:host code {
+ user-select: text;
+ -webkit-user-select: text;
+}
+
.title-toolbar-shell {
display: flex;
align-items: center;
diff --git a/src/styles.css b/src/styles.css
index a9faffc..04b8c56 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -65,6 +65,10 @@ body {
font-family: var(--font-body);
}
+body.node-focus-modal-open {
+ overflow: hidden;
+}
+
/* ── Global Material overrides ────────────────────────────────── */
/* Default form fields to full width — uses :where() for zero specificity