feat(editor): add focus mode for container and generic nodes
This commit is contained in:
parent
175059de79
commit
4bcb9f5d3e
|
|
@ -45,6 +45,8 @@
|
|||
min-height: 48px;
|
||||
border-radius: 0;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue