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
3 changes: 3 additions & 0 deletions docs/api/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ interface FlowEdge<T = Record<string, any>> {
/** Center label text. */
label?: string;

/** Render label, labelStart and labelEnd as HTML rather than text. Default: false */
labelHtml?: boolean;

/** Center label position along path (0 = source, 1 = target). Default: 0.5 */
labelPosition?: number;

Expand Down
1 change: 1 addition & 0 deletions docs/configuration/edges.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ These are covered in the [v0.2.1-alpha migration guide](../migration/v0.2.1-alph
label: 'connects to', // Optional. Center label text.
labelStart: 'from', // Optional. Label near source.
labelEnd: 'to', // Optional. Label near target.
labelHtml: false, // Optional. Render the labels as HTML rather than text.
color: '#ff0000', // Optional. Stroke color string or gradient object.
strokeWidth: 2, // Optional. Stroke width.
animated: true, // Optional. true/'dash', 'pulse', or 'dot'.
Expand Down
35 changes: 35 additions & 0 deletions docs/edges/labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,41 @@ Hover each edge to see the label appear — the top edge is always visible, the
```
::enddemo

## HTML labels

Label text is written with `textContent`, so markup in a label shows as the tags themselves. Set `labelHtml: true` on the edge to render its labels as HTML instead — for a label that needs a line break, an icon, or a piece of emphasis:

::demo
```html
<div x-data="flowCanvas({
nodes: [
{ id: 'a', position: { x: 0, y: 0 }, data: { label: 'Request' } },
{ id: 'b', position: { x: 330, y: 0 }, data: { label: 'Manager' } },
],
edges: [
{ id: 'e1', source: 'a', target: 'b', label: 'over the limit<br><em>and no manager on shift</em>', labelHtml: true },
],
background: 'dots',
fitViewOnInit: true,
controls: false,
pannable: false,
zoomable: false,
})" class="flow-container" style="height: 220px;">
<div x-flow-viewport>
<template x-for="node in nodes" :key="node.id">
<div x-flow-node="node">
<div x-flow-handle:target></div>
<span x-text="node.data.label"></span>
<div x-flow-handle:source></div>
</div>
</template>
</div>
</div>
```
::enddemo

The flag covers all three positions — `label`, `labelStart` and `labelEnd` — and the value goes to `innerHTML` unchanged. Like any HTML you hand a framework, it is trusted: pass anything a user typed through your own escaping or sanitiser first.

## Example

```js
Expand Down
12 changes: 12 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,18 @@ export interface FlowEdge<T = Record<string, any>> {
/** Optional label displayed on the edge */
label?: string;

/**
* Render the labels as HTML instead of text.
*
* Labels are written with `textContent` by default, so markup in them shows as the tags
* themselves. Set this to render `label`, `labelStart` and `labelEnd` as HTML — for a label that
* needs a line break, an icon or a piece of emphasis.
*
* The value goes to `innerHTML` unchanged. Like any HTML you hand a framework, it is trusted:
* pass user input through your own escaping or sanitiser first.
*/
labelHtml?: boolean;

/** Position of the center label along the path (0 = source, 1 = target). Default: 0.5 */
labelPosition?: number;

Expand Down
53 changes: 53 additions & 0 deletions src/plugin/directives/flow-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -655,3 +655,56 @@ describe('x-flow-edge label path-length caching (Task B1)', () => {
expect(getTotalLengthSpy).toHaveBeenCalled(); // re-measured on new d
});
});

describe('x-flow-edge label markup (labelHtml)', () => {
// The harness builds host > .flow-container > svg > g and no viewport, so labels are created
// but never appended. Marking the container as the viewport gives `ensureLabel` somewhere to
// put them, and the label block re-runs on the next mutation.
const withViewport = (host: HTMLElement): void => {
host.querySelector('.flow-container')!.classList.add('flow-viewport');
};

it('writes a label as text by default, so markup shows as the tags it is', async () => {
const { host, data } = mountEdges(flatNodes(), [{ id: 'e1', source: 'a', target: 'b' }]);
await flush();
withViewport(host);

data.getEdge('e1')!.label = '<b>bold</b>';
await flush();

const label = host.querySelector('.flow-edge-label') as HTMLElement;
expect(label.textContent).toBe('<b>bold</b>');
expect(label.querySelector('b')).toBeNull();
});

it('renders a label as HTML when the edge asks for it', async () => {
const { host, data } = mountEdges(flatNodes(), [
{ id: 'e1', source: 'a', target: 'b', labelHtml: true },
]);
await flush();
withViewport(host);

data.getEdge('e1')!.label = 'over the limit<br>and no manager';
await flush();

const label = host.querySelector('.flow-edge-label') as HTMLElement;
expect(label.querySelector('br')).not.toBeNull();
expect(label.textContent).toBe('over the limitand no manager');
});

it('applies to the start and end labels too', async () => {
const { host, data } = mountEdges(flatNodes(), [
{ id: 'e1', source: 'a', target: 'b', labelHtml: true },
]);
await flush();
withViewport(host);

const edge = data.getEdge('e1')!;
edge.labelStart = '<em>from</em>';
edge.labelEnd = '<em>to</em>';
await flush();

expect(host.querySelector('.flow-edge-label-start')!.querySelector('em')).not.toBeNull();
expect(host.querySelector('.flow-edge-label-end')!.querySelector('em')).not.toBeNull();
});
});
16 changes: 12 additions & 4 deletions src/plugin/directives/flow-edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1754,6 +1754,7 @@ export function registerFlowEdgeDirective(Alpine: Alpine) {
cssClass: string,
viewport: Element | null,
edgeId: string,
asHtml: boolean,
): HTMLDivElement | null => {
if (text) {
// If closure lost reference (e.g., directive re-init), reclaim from DOM
Expand All @@ -1772,7 +1773,13 @@ export function registerFlowEdgeDirective(Alpine: Alpine) {
existing.dataset.flowEdgeId = edgeId;
if (viewport) viewport.appendChild(existing);
}
existing.textContent = text;
// `innerHTML` only when the edge asked for it. The value is trusted the way any
// framework trusts HTML it is handed — see `labelHtml` in the Edge type.
if (asHtml) {
if (existing.innerHTML !== text) existing.innerHTML = text;
} else if (existing.textContent !== text) {
existing.textContent = text;
}
return existing;
}
if (existing) { existing.remove(); }
Expand All @@ -1781,6 +1788,7 @@ export function registerFlowEdgeDirective(Alpine: Alpine) {

const viewport = el.closest('.flow-viewport');
const labelVis = edge.labelVisibility ?? 'always';
const labelHtml = edge.labelHtml === true;

// Lazily measure + cache the path length, keyed by the `d` attribute set
// above. Only called when a label actually needs it, so edges without
Expand All @@ -1795,7 +1803,7 @@ export function registerFlowEdgeDirective(Alpine: Alpine) {
};

// Center label (uses labelPosition percentage or default midpoint)
labelEl = ensureLabel(labelEl, edge.label, 'flow-edge-label', viewport, edge.id);
labelEl = ensureLabel(labelEl, edge.label, 'flow-edge-label', viewport, edge.id, labelHtml);
if (labelEl) {
const totalLength = getCachedTotalLength();
if (totalLength > 0) {
Expand All @@ -1810,7 +1818,7 @@ export function registerFlowEdgeDirective(Alpine: Alpine) {
}

// Start label (fixed pixel offset from source end)
labelStartEl = ensureLabel(labelStartEl, edge.labelStart, 'flow-edge-label flow-edge-label-start', viewport, edge.id);
labelStartEl = ensureLabel(labelStartEl, edge.labelStart, 'flow-edge-label flow-edge-label-start', viewport, edge.id, labelHtml);
if (labelStartEl) {
const totalLength = getCachedTotalLength();
if (totalLength > 0) {
Expand All @@ -1822,7 +1830,7 @@ export function registerFlowEdgeDirective(Alpine: Alpine) {
}

// End label (fixed pixel offset from target end)
labelEndEl = ensureLabel(labelEndEl, edge.labelEnd, 'flow-edge-label flow-edge-label-end', viewport, edge.id);
labelEndEl = ensureLabel(labelEndEl, edge.labelEnd, 'flow-edge-label flow-edge-label-end', viewport, edge.id, labelHtml);
if (labelEndEl) {
const totalLength = getCachedTotalLength();
if (totalLength > 0) {
Expand Down