feat(editor): add focus mode for container and generic nodes

This commit is contained in:
Lucio Lelii 2026-05-13 12:18:13 +02:00
parent 175059de79
commit 4bcb9f5d3e
9 changed files with 304 additions and 14 deletions

View File

@ -45,6 +45,8 @@
min-height: 48px;
border-radius: 0;
font-weight: 600;
user-select: none;
-webkit-user-select: none;
}
.app-shell {

View File

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

View File

@ -1,4 +1,13 @@
<div class="container-node" [class.container-node-assigning]="isAssigning" [class.container-node-delete-pending]="deleteConfirmOpen">
@if (focusOpen) {
<div class="container-node__focus-backdrop" (pointerdown)="$event.stopPropagation()" (click)="closeFocus($event)"></div>
}
<div
class="container-node"
[class.container-node-assigning]="isAssigning"
[class.container-node-delete-pending]="deleteConfirmOpen"
[class.container-node--focus]="focusOpen"
(pointerdown)="focusOpen && $event.stopPropagation()"
(click)="focusOpen && $event.stopPropagation()">
@if (isSchemaLoading && !isAssigning) {
<div class="llm-loading-overlay">
<div class="llm-loading-chip">
@ -51,6 +60,10 @@
</div>
}
@if (!isReadonly) {
<button type="button" class="container-node__focus" [matTooltip]="focusOpen ? 'Close expanded node' : 'Expand node'" (pointerdown)="$event.stopPropagation()" (click)="toggleFocus($event)">
<i class="bi" [class.bi-fullscreen]="!focusOpen" [class.bi-fullscreen-exit]="focusOpen"></i>
</button>
@if (!focusOpen) {
<button type="button" class="container-node__clone" matTooltip="Clone node" (pointerdown)="$event.stopPropagation()" (click)="cloneCurrentNode($event)">
<i class="bi bi-copy"></i>
</button>
@ -58,6 +71,7 @@
<i class="bi bi-trash"></i>
</button>
}
}
</div>
</div>

View File

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

View File

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

View File

@ -1,7 +1,13 @@
@if (focusOpen) {
<div class="llm-node__focus-backdrop" (pointerdown)="$event.stopPropagation()" (click)="closeFocus($event)"></div>
}
<div class="llm-node"
[class.llm-node-error]="hasUpdateBlockError()"
[class.llm-node-delete-pending]="deleteConfirmOpen"
[class.llm-node--human]="isHumanNode()">
[class.llm-node--human]="isHumanNode()"
[class.llm-node--focus]="focusOpen"
(pointerdown)="focusOpen && $event.stopPropagation()"
(click)="focusOpen && $event.stopPropagation()">
@if (isCreatingOnServer()) {
<div class="llm-loading-overlay">
<div class="llm-loading-chip">
@ -74,6 +80,15 @@
</div>
}
@if (!isReadonly) {
<button
type="button"
class="llm-clone-btn"
[matTooltip]="focusOpen ? 'Close expanded node' : 'Expand node'"
(pointerdown)="$event.stopPropagation()"
(click)="toggleFocus($event)">
<i class="bi" [class.bi-fullscreen]="!focusOpen" [class.bi-fullscreen-exit]="focusOpen"></i>
</button>
@if (!focusOpen) {
<button type="button" class="llm-clone-btn" matTooltip="Clone node" (pointerdown)="$event.stopPropagation()" (click)="cloneCurrentNode($event)">
<i class="bi bi-copy"></i>
</button>
@ -81,6 +96,7 @@
<i class="bi bi-trash"></i>
</button>
}
}
</div>
</div>

View File

@ -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<HTMLElement>>(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<string, boolean>();
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();

View File

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

View File

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