From 3ccb4594269e7280bbc7c688e1d27f61e025f586 Mon Sep 17 00:00:00 2001 From: webard Date: Mon, 10 Aug 2026 12:50:06 +0200 Subject: [PATCH] feat(edges): render edge labels as HTML with `labelHtml` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edge labels are written with `textContent`, so anything but plain text shows as the tags themselves — a label that wants a line break, an icon or a piece of emphasis has no way to ask for one, and `white-space: pre-wrap` on `.flow-edge-label` only reaches newlines that were typed. `labelHtml: true` on an edge switches its labels to `innerHTML`. It covers all three positions (`label`, `labelStart`, `labelEnd`), because they are one decision about one edge, and defaults to off, so nothing renders differently until an edge asks. The value is trusted the way any framework trusts HTML it is handed; the type's docstring and the labels page both say to sanitise user input first. Both branches skip a write when the DOM already holds the string, so the label block still costs nothing on the effect re-runs that don't change it. --- docs/api/types.md | 3 ++ docs/configuration/edges.md | 1 + docs/edges/labels.md | 35 ++++++++++++++++ src/core/types.ts | 12 ++++++ src/plugin/directives/flow-edge.test.ts | 53 +++++++++++++++++++++++++ src/plugin/directives/flow-edge.ts | 16 ++++++-- 6 files changed, 116 insertions(+), 4 deletions(-) diff --git a/docs/api/types.md b/docs/api/types.md index e3e7f41..6ca4ba6 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -229,6 +229,9 @@ interface FlowEdge> { /** 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; diff --git a/docs/configuration/edges.md b/docs/configuration/edges.md index 2d2ae39..b9df438 100644 --- a/docs/configuration/edges.md +++ b/docs/configuration/edges.md @@ -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'. diff --git a/docs/edges/labels.md b/docs/edges/labels.md index 648b871..0938fe8 100644 --- a/docs/edges/labels.md +++ b/docs/edges/labels.md @@ -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 +
+
+ +
+
+``` +::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 diff --git a/src/core/types.ts b/src/core/types.ts index 081187c..3bfdb3e 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -360,6 +360,18 @@ export interface FlowEdge> { /** 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; diff --git a/src/plugin/directives/flow-edge.test.ts b/src/plugin/directives/flow-edge.test.ts index 543a090..a2f297d 100644 --- a/src/plugin/directives/flow-edge.test.ts +++ b/src/plugin/directives/flow-edge.test.ts @@ -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 = 'bold'; + await flush(); + + const label = host.querySelector('.flow-edge-label') as HTMLElement; + expect(label.textContent).toBe('bold'); + 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
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 = 'from'; + edge.labelEnd = 'to'; + await flush(); + + expect(host.querySelector('.flow-edge-label-start')!.querySelector('em')).not.toBeNull(); + expect(host.querySelector('.flow-edge-label-end')!.querySelector('em')).not.toBeNull(); + }); +}); diff --git a/src/plugin/directives/flow-edge.ts b/src/plugin/directives/flow-edge.ts index 2d98ff7..052cc2a 100644 --- a/src/plugin/directives/flow-edge.ts +++ b/src/plugin/directives/flow-edge.ts @@ -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 @@ -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(); } @@ -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 @@ -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) { @@ -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) { @@ -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) {