feat(editor): highlight selected connection endpoints

This commit is contained in:
Lucio Lelii 2026-09-30 07:52:12 +02:00
parent 421ef23518
commit c8085dad5a
3 changed files with 70 additions and 8 deletions

View File

@ -4,17 +4,30 @@
import { Injectable, signal } from '@angular/core';
export type SelectedConnection = {
id: string;
sourceId: string;
sourceOutput: string;
targetId: string;
targetInput: string;
};
@Injectable({ providedIn: 'root' })
export class GraphSelectionService {
readonly selectedConnectionId = signal<string | null>(null);
/** The two endpoints let their socket components light up with the selected wire. */
readonly selectedConnection = signal<SelectedConnection | null>(null);
readonly deleteConnectionRequestTick = signal(0);
selectConnection(connectionId: string | null | undefined) {
this.selectedConnectionId.set(connectionId ? String(connectionId) : null);
selectConnection(connectionId: string | null | undefined, endpoints?: Omit<SelectedConnection, 'id'>) {
const id = connectionId ? String(connectionId) : null;
this.selectedConnectionId.set(id);
this.selectedConnection.set(id && endpoints ? { id, ...endpoints } : null);
}
clearConnectionSelection() {
this.selectedConnectionId.set(null);
this.selectedConnection.set(null);
}
requestDeleteSelectedConnection() {

View File

@ -20,7 +20,9 @@ import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-v
selector: 'app-custom-connection',
standalone: true,
template: `
<svg data-testid="connection" [attr.data-connection-id]="connectionId" [attr.data-loop-back]="isLoopBack || null">
<svg data-testid="connection" [attr.data-connection-id]="connectionId" [attr.data-loop-back]="isLoopBack || null"
[attr.data-connection-kind]="connectionKind" [attr.data-selected]="isSelected || null"
[style.z-index]="isSelected ? 100 : 1">
<path
[class.connection-readonly]="isReadonly"
[attr.d]="drawnPath"
@ -214,6 +216,10 @@ export class CustomConnectionComponent {
return String(this.data?.id ?? '');
}
get connectionKind(): 'data' | 'dependency' | 'resource' {
return this.isResourceLink ? 'resource' : this.isDependencyConnection ? 'dependency' : 'data';
}
private get loopData(): LoopAwareConnection | undefined {
return this.data as LoopAwareConnection | undefined;
}
@ -294,7 +300,9 @@ export class CustomConnectionComponent {
}
get strokeColor(): string {
if (this.isSelected) return '#f97316';
if (this.isSelected) {
return this.isResourceLink ? '#0f9f8d' : this.isDependencyConnection ? '#4f46e5' : '#f97316';
}
if (this.isBiasRoutingChange) return '#b45309';
if (this.isLoopBack) return '#7c3aed';
if (this.isResourceLink) return '#0f766e';
@ -302,14 +310,16 @@ export class CustomConnectionComponent {
}
get strokeWidth(): number {
if (this.isSelected) return this.isDependencyConnection || this.isResourceLink ? 3.25 : 6;
if (this.isSelected) return this.isDependencyConnection || this.isResourceLink ? 4 : 6;
if (this.isBiasRoutingChange) return 6;
return this.isDependencyConnection || this.isResourceLink ? 2.25 : 5;
}
get strokeDasharray(): string | null {
if (this.isLoopBack) return '12 7';
if (this.isResourceLink) return '2 6';
// A resource is a capability handle, not an ordering dependency: its teal segmented wire is
// intentionally unlike the thin grey dashed dependency wire.
if (this.isResourceLink) return '14 5 2 5';
return this.isDependencyConnection ? '6 6' : null;
}
@ -329,7 +339,12 @@ export class CustomConnectionComponent {
if (this.isReadonly) return;
event.preventDefault();
event.stopPropagation();
this.graphSelection.selectConnection(this.connectionId);
this.graphSelection.selectConnection(this.connectionId, {
sourceId: String(this.data?.source ?? ''),
sourceOutput: String(this.data?.sourceOutput ?? ''),
targetId: String(this.data?.target ?? ''),
targetInput: String(this.data?.targetInput ?? '')
});
}
deleteConnection(event: Event) {

View File

@ -12,6 +12,7 @@ import {
Input,
OnDestroy
} from "@angular/core";
import { GraphSelectionService } from '@services/graph-selection/graph-selection';
@Component({
template: ``,
@ -22,6 +23,7 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@Input() emit!: any;
@Input() rendered!: any;
private readonly element = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly graphSelection = inject(GraphSelectionService);
private resizeObserver: ResizeObserver | null = null;
private renderFrame: number | null = null;
private side: "input" | "output" = "input";
@ -33,6 +35,7 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@HostBinding("style.width")
get width() {
if (this.isSelectedEndpoint) return this.kind === "resource" ? "11px" : "12px";
return this.kind === "resource" ? "7px" : "8px";
}
@ -45,7 +48,13 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@HostBinding("style.borderRadius") get borderRadius() {
return this.kind === "resource" ? "4px" : "999px";
}
@HostBinding("style.border") border = "1px solid rgba(255,255,255,0.9)";
@HostBinding("style.border") get border() {
return this.isSelectedEndpoint ? "2px solid #fff" : "1px solid rgba(255,255,255,0.9)";
}
@HostBinding('attr.data-connection-endpoint-selected') get selectedEndpointAttribute() {
return this.isSelectedEndpoint || null;
}
@HostBinding("style.cursor") get cursor() {
return this.data?.__readonly === true ? "default" : "crosshair";
}
@ -56,6 +65,13 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@HostBinding("style.background")
get bg() {
if (this.isSelectedEndpoint) {
return this.kind === 'resource'
? 'linear-gradient(145deg, #5eead4 0%, #0f9f8d 100%)'
: this.kind === 'dependency'
? 'linear-gradient(145deg, #a5b4fc 0%, #4f46e5 100%)'
: 'linear-gradient(145deg, #fdba74 0%, #ea580c 100%)';
}
if (this.kind === "resource") {
return "linear-gradient(180deg, #5eead4 0%, #0f766e 100%)";
}
@ -71,6 +87,12 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@HostBinding("style.boxShadow")
get sh() {
if (this.isSelectedEndpoint) {
const color = this.kind === 'resource' ? 'rgba(15,159,141,0.78)'
: this.kind === 'dependency' ? 'rgba(79,70,229,0.72)'
: 'rgba(234,88,12,0.72)';
return `0 0 0 4px ${color}, 0 5px 14px ${color}`;
}
if (this.kind === "resource") {
return "0 1px 4px rgba(15,118,110,0.32), 0 0 0 1px rgba(13,148,136,0.42)";
}
@ -92,6 +114,18 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
return kind === "dependency" || kind === "resource" ? kind : "data";
}
/** True for either end of the selected data, dependency or resource connection. */
get isSelectedEndpoint(): boolean {
const selected = this.graphSelection.selectedConnection();
if (!selected) return false;
const nodeId = this.data?.nodeId ?? this.data?.payload?.nodeId;
const key = this.data?.key ?? this.data?.payload?.key;
if (nodeId == null || key == null) return false;
return this.side === 'output'
? String(nodeId) === selected.sourceId && String(key) === selected.sourceOutput
: String(nodeId) === selected.targetId && String(key) === selected.targetInput;
}
ngOnChanges(): void {
this.side = this.resolveSocketSide();
this.kind = this.resolveSocketKind();