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