Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/pvm-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ A connection is a directed wire from a source output port to a target input port
| `targetNodeId` | string | yes | ID of the target node. |
| `targetPortIndex` | number | yes | Index into the target node's `inputs` array. |
| `waypoints` | array | no | Route control points. UI-only, no simulation semantics. |
| `label` | string | no | Text shown on the wire, e.g. a signal name. UI-only, no simulation semantics. |

**Fan-out:** A single output port can connect to multiple input ports (multiple connections share the same `sourceNodeId`/`sourcePortIndex`).

Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/canvas/flowConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function toFlowEdge(conn: Connection): Edge {
target: conn.targetNodeId,
targetHandle: HANDLE_ID.input(conn.targetNodeId, conn.targetPortIndex),
type: 'orthogonal',
data: { waypoints: conn.waypoints },
data: { waypoints: conn.waypoints, label: conn.label },
selectable: true,
deletable: true,
animated: false
Expand Down
6 changes: 6 additions & 0 deletions src/lib/components/contextMenuBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { ContextMenuTarget } from '$lib/stores/contextMenu';
import { graphStore, ANNOTATION_FONT_SIZE } from '$lib/stores/graph';
import { historyStore } from '$lib/stores/history';
import { routingStore } from '$lib/stores/routing';
import { editEdgeLabel } from '$lib/stores/edgeLabelEdit.svelte';
import { eventStore } from '$lib/stores/events';
import { clipboardStore } from '$lib/stores/clipboard';
import { codePreviewStore } from '$lib/stores/codePreview';
Expand Down Expand Up @@ -400,6 +401,11 @@ function buildEventMenu(eventId: string): MenuItemType[] {
*/
function buildEdgeMenu(edgeId: string): MenuItemType[] {
return [
{
label: 'Edit Label',
icon: 'tag',
action: () => editEdgeLabel(edgeId)
},
{
label: 'Reset Route',
icon: 'rotate',
Expand Down
146 changes: 145 additions & 1 deletion src/lib/components/edges/OrthogonalEdge.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@
</script>

<script lang="ts">
import { BaseEdge, getSmoothStepPath, type EdgeProps, Position } from '@xyflow/svelte';
import { BaseEdge, EdgeLabel, getSmoothStepPath, type EdgeProps, Position } from '@xyflow/svelte';
import { routingStore } from '$lib/stores/routing';
import { graphStore } from '$lib/stores/graph';
import { edgeHighlights } from '$lib/stores/edgeHighlight';
import { edgeLabelEdit, editEdgeLabel } from '$lib/stores/edgeLabelEdit.svelte';
import { historyStore } from '$lib/stores/history';
import { screenToFlow } from '$lib/utils/viewUtils';
import { GRID_SIZE, EDGE_SOURCE_OFFSET, EDGE_TARGET_OFFSET, EDGE_CORNER_RADIUS } from '$lib/routing/constants';
import { EDGE_LABEL } from '$lib/constants/dimensions';
import type { Direction, RouteResult } from '$lib/routing';
import type { Waypoint } from '$lib/types/nodes';

Expand Down Expand Up @@ -295,6 +298,78 @@
return midpoints;
});

// Connection label, shown on the middle of the longest route segment
const label = $derived((data as { label?: string } | undefined)?.label ?? '');
const isEditingLabel = $derived(edgeLabelEdit.connectionId === id);

// Editor width follows the typed text
let draftLength = $state(0);

const labelAnchor = $derived.by(() => {
if (!label && !isEditingLabel) return null;
const points = displayedRoute
? [adjustedSource, ...displayedRoute.path, adjustedTarget]
: [adjustedSource, adjustedTarget];
let anchor = points[0];
let longest = -1;
for (let i = 0; i < points.length - 1; i++) {
const length = Math.abs(points[i + 1].x - points[i].x) + Math.abs(points[i + 1].y - points[i].y);
if (length > longest) {
longest = length;
anchor = { x: (points[i].x + points[i + 1].x) / 2, y: (points[i].y + points[i + 1].y) / 2 };
}
}
return anchor;
});

function handleEdgeDoubleClick(event: MouseEvent) {
event.stopPropagation();
editEdgeLabel(id);
}

function commitLabel(text: string) {
if (edgeLabelEdit.connectionId !== id) return;
editEdgeLabel(null);
if (text.trim() === label) return;
historyStore.mutate(() => graphStore.updateConnectionLabel(id, text));
}

/**
* Label editor input. Enter commits, Escape cancels, a pointer press outside
* the input commits. Blur alone never commits, because removing the editor
* also blurs the input. Keys are stopped at the input so edge keyboard
* handling and app shortcuts never see them. Focus waits until the input
* sits in the label layer.
*/
function labelEditor(input: HTMLInputElement) {
const onKeydown = (event: KeyboardEvent) => {
event.stopPropagation();
if (event.key === 'Enter') commitLabel(input.value);
else if (event.key === 'Escape') editEdgeLabel(null);
};
const onPointerDown = (event: PointerEvent) => {
if (event.target !== input) commitLabel(input.value);
};
const onInput = () => {
draftLength = input.value.length;
};
draftLength = input.value.length;
input.addEventListener('keydown', onKeydown);
input.addEventListener('input', onInput);
document.addEventListener('pointerdown', onPointerDown, true);
requestAnimationFrame(() => {
input.focus();
input.select();
});
return {
destroy: () => {
input.removeEventListener('keydown', onKeydown);
input.removeEventListener('input', onInput);
document.removeEventListener('pointerdown', onPointerDown, true);
}
};
}

// Segment drag creates a waypoint, then drags it
function handleSegmentPointerDown(event: PointerEvent, segmentIndex: number) {
event.stopPropagation();
Expand Down Expand Up @@ -341,9 +416,11 @@
}
</script>

<!-- svelte-ignore a11y_no_static_element_interactions -->
<g
class:highlighted={highlightColor !== undefined}
style={highlightColor !== undefined ? `--highlight-color: ${highlightColor}` : undefined}
ondblclick={handleEdgeDoubleClick}
>
<BaseEdge {id} {path} {style} />

Expand Down Expand Up @@ -386,8 +463,30 @@
class:highlighted={highlightColor !== undefined}
/>
</g>

{#if label && !isEditingLabel && labelAnchor}
<text
x={labelAnchor.x}
y={labelAnchor.y}
class="edge-label"
class:selected
class:highlighted={highlightColor !== undefined}>{label}</text
>
{/if}
</g>

{#if isEditingLabel && labelAnchor}
<EdgeLabel x={labelAnchor.x} y={labelAnchor.y} transparent>
<input
class="edge-label-input"
style="--label-height: {EDGE_LABEL.height}px; --label-padding-x: {EDGE_LABEL.paddingX}px; --label-chars: {Math.max(draftLength, EDGE_LABEL.minChars)};"
value={label}
placeholder="Label"
use:labelEditor
/>
</EdgeLabel>
{/if}

<style>
.edge-arrow {
fill: var(--edge);
Expand Down Expand Up @@ -415,6 +514,51 @@
stroke: var(--highlight-color, var(--accent)) !important;
}

/* Connection label with a halo in the canvas color, readable on top of wires.
* Pointer events pass through to the wire, so hovering the label hovers the edge. */
.edge-label {
font-family: var(--font-ui);
font-size: var(--font-xs);
fill: var(--text-muted);
stroke: var(--surface);
stroke-width: 3px;
stroke-linejoin: round;
paint-order: stroke;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
transition: fill 0.15s ease;
}

:global(.svelte-flow__edge:hover) .edge-label,
.edge-label.selected {
fill: var(--accent);
}

.edge-label.highlighted {
fill: var(--highlight-color, var(--accent));
}

/* Inline label editor: same capsule, active like a dragged waypoint */
.edge-label-input {
box-sizing: border-box;
height: var(--label-height);
width: calc(var(--label-chars) * 1ch + 2 * var(--label-padding-x));
padding: 0 var(--label-padding-x);
border: 1.5px solid var(--accent);
border-radius: calc(var(--label-height) / 2);
background: var(--surface);
color: var(--text);
font-family: var(--font-ui);
font-size: var(--font-xs);
text-align: center;
outline: none;
}

.edge-label-input::placeholder {
color: var(--text-muted);
}

/* Waypoint group - visibility controlled by inline styles */
.waypoint-group {
transition: opacity 0.1s ease;
Expand Down
10 changes: 10 additions & 0 deletions src/lib/constants/dimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ export const HANDLE = {
hollowInset: 1.5
} as const;

/** Connection label capsule, shared by the drawn label and its inline editor */
export const EDGE_LABEL = {
/** Capsule height in pixels */
height: 14,
/** Space between text and capsule ends in pixels */
paddingX: 6,
/** Minimum editor width in characters */
minChars: 5
} as const;

/** Event node dimensions (grid-aligned) */
export const EVENT = {
/** Total bounding box size: 8 grid units = 80px */
Expand Down
3 changes: 2 additions & 1 deletion src/lib/stores/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,8 @@ async function paste(targetPosition: Position): Promise<{ nodeIds: string[]; eve
sourceNodeId: newSourceId,
sourcePortIndex: conn.sourcePortIndex,
targetNodeId: newTargetId,
targetPortIndex: conn.targetPortIndex
targetPortIndex: conn.targetPortIndex,
...(conn.label ? { label: conn.label } : {})
});
}
}
Expand Down
10 changes: 10 additions & 0 deletions src/lib/stores/edgeLabelEdit.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Edge label editing - which connection currently shows the inline label editor
*/

export const edgeLabelEdit = $state<{ connectionId: string | null }>({ connectionId: null });

/** Open the label editor for a connection, or close it with null */
export function editEdgeLabel(connectionId: string | null): void {
edgeLabelEdit.connectionId = connectionId;
}
16 changes: 16 additions & 0 deletions src/lib/stores/graph/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,19 @@ export function updateConnectionWaypoints(id: string, waypoints: Waypoint[]): vo
connections.map((c) => (c.id === id ? { ...c, waypoints } : c))
);
}

/**
* Set the label of a connection; empty text removes it
*/
export function updateConnectionLabel(id: string, label: string): void {
const text = label.trim();
updateCurrentConnections((connections) =>
connections.map((c) => {
if (c.id !== id) return c;
const next = { ...c };
if (text) next.label = text;
else delete next.label;
return next;
})
);
}
1 change: 1 addition & 0 deletions src/lib/stores/graph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const graphStore = {
removeConnection: connections.removeConnection,
getAllConnections: connections.getAllConnections,
updateConnectionWaypoints: connections.updateConnectionWaypoints,
updateConnectionLabel: connections.updateConnectionLabel,

// ==================== PORT OPERATIONS ====================
addInputPort: ports.addInputPort,
Expand Down
3 changes: 2 additions & 1 deletion src/lib/stores/graph/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,8 @@ export function duplicateSelected(): string[] {
sourceNodeId: newSourceId,
sourcePortIndex: conn.sourcePortIndex,
targetNodeId: newTargetId,
targetPortIndex: conn.targetPortIndex
targetPortIndex: conn.targetPortIndex,
...(conn.label ? { label: conn.label } : {})
});
}
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/types/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export interface Connection {
targetNodeId: string;
targetPortIndex: number;
waypoints?: Waypoint[]; // Optional - empty/undefined means auto-route entire path
label?: string; // Optional text shown on the wire (UI-only)
}

/** Canvas annotation (markdown/LaTeX text) */
Expand Down