Merge pull request #334 from pathsim/feature/connection-labels · pathsim/pathview@a8a2109 · GitHub
Skip to content

Commit a8a2109

Browse files
authored
Merge pull request #334 from pathsim/feature/connection-labels
Connection labels
2 parents 7091fc1 + 96913d2 commit a8a2109

11 files changed

Lines changed: 195 additions & 4 deletions

File tree

‎docs/pvm-spec.md‎

Lines changed: 1 addition & 0 deletions

‎src/lib/components/canvas/flowConverters.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export function toFlowEdge(conn: Connection): Edge {
5656
target: conn.targetNodeId,
5757
targetHandle: HANDLE_ID.input(conn.targetNodeId, conn.targetPortIndex),
5858
type: 'orthogonal',
59-
data: { waypoints: conn.waypoints },
59+
data: { waypoints: conn.waypoints, label: conn.label },
6060
selectable: true,
6161
deletable: true,
6262
animated: false

‎src/lib/components/contextMenuBuilders.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { ContextMenuTarget } from '$lib/stores/contextMenu';
99
import { graphStore, ANNOTATION_FONT_SIZE } from '$lib/stores/graph';
1010
import { historyStore } from '$lib/stores/history';
1111
import { routingStore } from '$lib/stores/routing';
12+
import { editEdgeLabel } from '$lib/stores/edgeLabelEdit.svelte';
1213
import { eventStore } from '$lib/stores/events';
1314
import { clipboardStore } from '$lib/stores/clipboard';
1415
import { codePreviewStore } from '$lib/stores/codePreview';
@@ -400,6 +401,11 @@ function buildEventMenu(eventId: string): MenuItemType[] {
400401
*/
401402
function buildEdgeMenu(edgeId: string): MenuItemType[] {
402403
return [
404+
{
405+
label: 'Edit Label',
406+
icon: 'tag',
407+
action: () => editEdgeLabel(edgeId)
408+
},
403409
{
404410
label: 'Reset Route',
405411
icon: 'rotate',

‎src/lib/components/edges/OrthogonalEdge.svelte‎

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,15 @@
2626
</script>
2727

2828
<script lang="ts">
29-
import { BaseEdge, getSmoothStepPath, type EdgeProps, Position } from '@xyflow/svelte';
29+
import { BaseEdge, EdgeLabel, getSmoothStepPath, type EdgeProps, Position } from '@xyflow/svelte';
3030
import { routingStore } from '$lib/stores/routing';
31+
import { graphStore } from '$lib/stores/graph';
3132
import { edgeHighlights } from '$lib/stores/edgeHighlight';
33+
import { edgeLabelEdit, editEdgeLabel } from '$lib/stores/edgeLabelEdit.svelte';
3234
import { historyStore } from '$lib/stores/history';
3335
import { screenToFlow } from '$lib/utils/viewUtils';
3436
import { GRID_SIZE, EDGE_SOURCE_OFFSET, EDGE_TARGET_OFFSET, EDGE_CORNER_RADIUS } from '$lib/routing/constants';
37+
import { EDGE_LABEL } from '$lib/constants/dimensions';
3538
import type { Direction, RouteResult } from '$lib/routing';
3639
import type { Waypoint } from '$lib/types/nodes';
3740
@@ -295,6 +298,78 @@
295298
return midpoints;
296299
});
297300
301+
// Connection label, shown on the middle of the longest route segment
302+
const label = $derived((data as { label?: string } | undefined)?.label ?? '');
303+
const isEditingLabel = $derived(edgeLabelEdit.connectionId === id);
304+
305+
// Editor width follows the typed text
306+
let draftLength = $state(0);
307+
308+
const labelAnchor = $derived.by(() => {
309+
if (!label && !isEditingLabel) return null;
310+
const points = displayedRoute
311+
? [adjustedSource, ...displayedRoute.path, adjustedTarget]
312+
: [adjustedSource, adjustedTarget];
313+
let anchor = points[0];
314+
let longest = -1;
315+
for (let i = 0; i < points.length - 1; i++) {
316+
const length = Math.abs(points[i + 1].x - points[i].x) + Math.abs(points[i + 1].y - points[i].y);
317+
if (length > longest) {
318+
longest = length;
319+
anchor = { x: (points[i].x + points[i + 1].x) / 2, y: (points[i].y + points[i + 1].y) / 2 };
320+
}
321+
}
322+
return anchor;
323+
});
324+
325+
function handleEdgeDoubleClick(event: MouseEvent) {
326+
event.stopPropagation();
327+
editEdgeLabel(id);
328+
}
329+
330+
function commitLabel(text: string) {
331+
if (edgeLabelEdit.connectionId !== id) return;
332+
editEdgeLabel(null);
333+
if (text.trim() === label) return;
334+
historyStore.mutate(() => graphStore.updateConnectionLabel(id, text));
335+
}
336+
337+
/**
338+
* Label editor input. Enter commits, Escape cancels, a pointer press outside
339+
* the input commits. Blur alone never commits, because removing the editor
340+
* also blurs the input. Keys are stopped at the input so edge keyboard
341+
* handling and app shortcuts never see them. Focus waits until the input
342+
* sits in the label layer.
343+
*/
344+
function labelEditor(input: HTMLInputElement) {
345+
const onKeydown = (event: KeyboardEvent) => {
346+
event.stopPropagation();
347+
if (event.key === 'Enter') commitLabel(input.value);
348+
else if (event.key === 'Escape') editEdgeLabel(null);
349+
};
350+
const onPointerDown = (event: PointerEvent) => {
351+
if (event.target !== input) commitLabel(input.value);
352+
};
353+
const onInput = () => {
354+
draftLength = input.value.length;
355+
};
356+
draftLength = input.value.length;
357+
input.addEventListener('keydown', onKeydown);
358+
input.addEventListener('input', onInput);
359+
document.addEventListener('pointerdown', onPointerDown, true);
360+
requestAnimationFrame(() => {
361+
input.focus();
362+
input.select();
363+
});
364+
return {
365+
destroy: () => {
366+
input.removeEventListener('keydown', onKeydown);
367+
input.removeEventListener('input', onInput);
368+
document.removeEventListener('pointerdown', onPointerDown, true);
369+
}
370+
};
371+
}
372+
298373
// Segment drag creates a waypoint, then drags it
299374
function handleSegmentPointerDown(event: PointerEvent, segmentIndex: number) {
300375
event.stopPropagation();
@@ -341,9 +416,11 @@
341416
}
342417
</script>
343418

419+
<!-- svelte-ignore a11y_no_static_element_interactions -->
344420
<g
345421
class:highlighted={highlightColor !== undefined}
346422
style={highlightColor !== undefined ? `--highlight-color: ${highlightColor}` : undefined}
423+
ondblclick={handleEdgeDoubleClick}
347424
>
348425
<BaseEdge {id} {path} {style} />
349426

@@ -386,8 +463,30 @@
386463
class:highlighted={highlightColor !== undefined}
387464
/>
388465
</g>
466+
467+
{#if label && !isEditingLabel && labelAnchor}
468+
<text
469+
x={labelAnchor.x}
470+
y={labelAnchor.y}
471+
class="edge-label"
472+
class:selected
473+
class:highlighted={highlightColor !== undefined}>{label}</text
474+
>
475+
{/if}
389476
</g>
390477

478+
{#if isEditingLabel && labelAnchor}
479+
<EdgeLabel x={labelAnchor.x} y={labelAnchor.y} transparent>
480+
<input
481+
class="edge-label-input"
482+
style="--label-height: {EDGE_LABEL.height}px; --label-padding-x: {EDGE_LABEL.paddingX}px; --label-chars: {Math.max(draftLength, EDGE_LABEL.minChars)};"
483+
value={label}
484+
placeholder="Label"
485+
use:labelEditor
486+
/>
487+
</EdgeLabel>
488+
{/if}
489+
391490
<style>
392491
.edge-arrow {
393492
fill: var(--edge);
@@ -415,6 +514,51 @@
415514
stroke: var(--highlight-color, var(--accent)) !important;
416515
}
417516
517+
/* Connection label with a halo in the canvas color, readable on top of wires.
518+
* Pointer events pass through to the wire, so hovering the label hovers the edge. */
519+
.edge-label {
520+
font-family: var(--font-ui);
521+
font-size: var(--font-xs);
522+
fill: var(--text-muted);
523+
stroke: var(--surface);
524+
stroke-width: 3px;
525+
stroke-linejoin: round;
526+
paint-order: stroke;
527+
text-anchor: middle;
528+
dominant-baseline: central;
529+
pointer-events: none;
530+
transition: fill 0.15s ease;
531+
}
532+
533+
:global(.svelte-flow__edge:hover) .edge-label,
534+
.edge-label.selected {
535+
fill: var(--accent);
536+
}
537+
538+
.edge-label.highlighted {
539+
fill: var(--highlight-color, var(--accent));
540+
}
541+
542+
/* Inline label editor: same capsule, active like a dragged waypoint */
543+
.edge-label-input {
544+
box-sizing: border-box;
545+
height: var(--label-height);
546+
width: calc(var(--label-chars) * 1ch + 2 * var(--label-padding-x));
547+
padding: 0 var(--label-padding-x);
548+
border: 1.5px solid var(--accent);
549+
border-radius: calc(var(--label-height) / 2);
550+
background: var(--surface);
551+
color: var(--text);
552+
font-family: var(--font-ui);
553+
font-size: var(--font-xs);
554+
text-align: center;
555+
outline: none;
556+
}
557+
558+
.edge-label-input::placeholder {
559+
color: var(--text-muted);
560+
}
561+
418562
/* Waypoint group - visibility controlled by inline styles */
419563
.waypoint-group {
420564
transition: opacity 0.1s ease;

‎src/lib/constants/dimensions.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ export const HANDLE = {
3636
hollowInset: 1.5
3737
} as const;
3838

39+
/** Connection label capsule, shared by the drawn label and its inline editor */
40+
export const EDGE_LABEL = {
41+
/** Capsule height in pixels */
42+
height: 14,
43+
/** Space between text and capsule ends in pixels */
44+
paddingX: 6,
45+
/** Minimum editor width in characters */
46+
minChars: 5
47+
} as const;
48+
3949
/** Event node dimensions (grid-aligned) */
4050
export const EVENT = {
4151
/** Total bounding box size: 8 grid units = 80px */

‎src/lib/stores/clipboard.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,8 @@ async function paste(targetPosition: Position): Promise<{ nodeIds: string[]; eve
340340
sourceNodeId: newSourceId,
341341
sourcePortIndex: conn.sourcePortIndex,
342342
targetNodeId: newTargetId,
343-
targetPortIndex: conn.targetPortIndex
343+
targetPortIndex: conn.targetPortIndex,
344+
...(conn.label ? { label: conn.label } : {})
344345
});
345346
}
346347
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Edge label editing - which connection currently shows the inline label editor
3+
*/
4+
5+
export const edgeLabelEdit = $state<{ connectionId: string | null }>({ connectionId: null });
6+
7+
/** Open the label editor for a connection, or close it with null */
8+
export function editEdgeLabel(connectionId: string | null): void {
9+
edgeLabelEdit.connectionId = connectionId;
10+
}

‎src/lib/stores/graph/connections.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,19 @@ export function updateConnectionWaypoints(id: string, waypoints: Waypoint[]): vo
107107
connections.map((c) => (c.id === id ? { ...c, waypoints } : c))
108108
);
109109
}
110+
111+
/**
112+
* Set the label of a connection; empty text removes it
113+
*/
114+
export function updateConnectionLabel(id: string, label: string): void {
115+
const text = label.trim();
116+
updateCurrentConnections((connections) =>
117+
connections.map((c) => {
118+
if (c.id !== id) return c;
119+
const next = { ...c };
120+
if (text) next.label = text;
121+
else delete next.label;
122+
return next;
123+
})
124+
);
125+
}

‎src/lib/stores/graph/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export const graphStore = {
7878
removeConnection: connections.removeConnection,
7979
getAllConnections: connections.getAllConnections,
8080
updateConnectionWaypoints: connections.updateConnectionWaypoints,
81+
updateConnectionLabel: connections.updateConnectionLabel,
8182

8283
// ==================== PORT OPERATIONS ====================
8384
addInputPort: ports.addInputPort,

‎src/lib/stores/graph/nodes.ts‎

Lines changed: 2 additions & 1 deletion

0 commit comments

Comments
 (0)