diff --git a/src/app/shared/custom-socket/custom-socket.ts b/src/app/shared/custom-socket/custom-socket.ts index 1ec96ea..2170162 100644 --- a/src/app/shared/custom-socket/custom-socket.ts +++ b/src/app/shared/custom-socket/custom-socket.ts @@ -2,16 +2,28 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. -import { ChangeDetectionStrategy, Component, HostBinding, Input } from "@angular/core"; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + HostBinding, + inject, + Input, + OnDestroy +} from "@angular/core"; @Component({ template: ``, changeDetection: ChangeDetectionStrategy.OnPush }) -export class CustomSocket { +export class CustomSocket implements AfterViewInit, OnDestroy { @Input() data!: any; @Input() emit!: any; @Input() rendered!: any; + private readonly element = inject>(ElementRef); + private resizeObserver: ResizeObserver | null = null; + private renderFrame: number | null = null; private side: "input" | "output" = "input"; private kind: "data" | "dependency" | "resource" = "data"; @@ -19,10 +31,18 @@ export class CustomSocket { return this.data.name; } - @HostBinding("style.width") w = "15px"; - @HostBinding("style.height") h = "15px"; + @HostBinding("style.width") + get width() { + return this.kind === "resource" ? "9px" : "8px"; + } + + @HostBinding("style.height") + get height() { + return this.kind === "resource" ? "100%" : "26px"; + } + @HostBinding("style.display") d = "block"; - @HostBinding("style.borderRadius") br = "4px"; + @HostBinding("style.borderRadius") br = "999px"; @HostBinding("style.border") border = "1px solid rgba(255,255,255,0.9)"; @HostBinding("style.cursor") get cursor() { return this.data?.__readonly === true ? "default" : "crosshair"; @@ -73,6 +93,29 @@ export class CustomSocket { ngOnChanges(): void { this.side = this.resolveSocketSide(); this.kind = this.resolveSocketKind(); - requestAnimationFrame(() => this.rendered()); + this.schedulePositionRefresh(); + } + + ngAfterViewInit(): void { + if (typeof ResizeObserver === "undefined") return; + this.resizeObserver = new ResizeObserver(() => this.schedulePositionRefresh()); + this.resizeObserver.observe(this.element.nativeElement); + } + + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + if (this.renderFrame !== null) cancelAnimationFrame(this.renderFrame); + } + + /** + * Rete stores the socket's DOM centre. Resource sockets change height when their card opens, so + * that position must be published again after every real size change. + */ + private schedulePositionRefresh(): void { + if (this.renderFrame !== null) cancelAnimationFrame(this.renderFrame); + this.renderFrame = requestAnimationFrame(() => { + this.renderFrame = null; + this.rendered?.(); + }); } } diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index bd6b120..542d443 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -425,7 +425,7 @@ .container-node__port-row { display: flex; align-items: center; - gap: 8px; + gap: 0; margin-bottom: 8px; } @@ -433,6 +433,19 @@ justify-content: flex-end; } +.container-node__socket { + position: relative; + flex: 0 0 0; + width: 0; + left: -18px; + z-index: 20; +} + +.container-node__socket--right { + left: auto; + right: -18px; +} + .container-node__port-label { display: inline-flex; flex-direction: column; diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index 0617f49..39b6c45 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -6,7 +6,7 @@ .llm-node { position: relative; - width: 280px; + width: 340px; user-select: none; -webkit-user-select: none; border-radius: 16px; @@ -30,7 +30,7 @@ inset: 0; margin: auto; z-index: 9991; - width: 280px; + width: 340px; max-width: calc(100vw - 32px); height: fit-content; max-height: calc(100vh - 32px); @@ -586,21 +586,24 @@ } .llm-row-input { - grid-template-columns: 16px 1fr; - gap: 6px; + grid-template-columns: 0 minmax(0, 1fr); + gap: 0; } .llm-row-output { - grid-template-columns: 1fr 16px; - gap: 6px; + grid-template-columns: minmax(0, 1fr) 0; + gap: 0; } .llm-socket { position: relative; + left: -16px; z-index: 20; } .llm-socket-right { + left: auto; + right: -16px; justify-self: end; } diff --git a/src/app/shared/nodes/resource-node/resource-node.css b/src/app/shared/nodes/resource-node/resource-node.css index b8b8c1d..8b2cee2 100644 --- a/src/app/shared/nodes/resource-node/resource-node.css +++ b/src/app/shared/nodes/resource-node/resource-node.css @@ -14,7 +14,8 @@ .resource-card { position: relative; - width: 250px; + width: 340px; + box-sizing: border-box; padding: 12px 16px 12px 12px; border: 1.5px solid #99f6e4; border-left: 5px solid #0d9488; @@ -26,7 +27,7 @@ } .resource-card--open { - width: 320px; + width: 340px; } /* diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css index 86134ba..df22ac1 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.css +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -6,7 +6,7 @@ .llm-node { position: relative; - width: 280px; + width: 340px; user-select: none; -webkit-user-select: none; border-radius: 16px; @@ -495,21 +495,24 @@ button.llm-node-bias-summary:focus-visible { } .llm-row-input { - grid-template-columns: 16px 1fr; - gap: 6px; + grid-template-columns: 0 minmax(0, 1fr); + gap: 0; } .llm-row-output { - grid-template-columns: 1fr 16px; - gap: 6px; + grid-template-columns: minmax(0, 1fr) 0; + gap: 0; } .llm-socket { position: relative; + left: -16px; z-index: 20; } .llm-socket-right { + left: auto; + right: -16px; justify-self: end; } diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index f15c62e..709bcfd 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -124,12 +124,10 @@ export async function createEditor( render.addPreset( Presets.classic.setup({ - // Rete pushes an output's anchor 12px past its socket, for a small handle on a node's side. - // A resource's handle is a bar along its right edge: its links start from the bar itself. + // Every socket is a bar centred on the node border, so the connection starts from the bar's + // actual DOM centre. The Rete default would push the anchor another 12px past it. socketPositionWatcher: getDOMSocketPosition({ - offset: (position, _nodeId, side, key) => key === FLOW_RESOURCE_PORT_KEY - ? position - : { x: position.x + 12 * (side === 'input' ? -1 : 1), y: position.y } + offset: (position) => position }), customize: { node(context: any) {