From 098c3db04d9cb68ef72daa6557a478f73dae8d81 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 20:13:40 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20rFlows=20UX=20overhaul=20=E2=80=94=20fu?= =?UTF-8?q?nnel=20shapes,=20inline=20editing,=20side=20overflow=20ports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace rectangular funnel nodes with tapered SVG containers featuring overflow lip notches on left/right sides and a narrow spending outflow at the bottom. Funnels scale logarithmically by monthly funding rate. Double-click enters inline edit mode with draggable threshold markers (min/max/sufficient), editable labels via foreignObject, and a compact toolbar (Done/Delete/... panel fallback). Single click now selects only. Overflow edges route from side ports with horizontal bezier curves that create a natural "spilling over" visual before curving to targets. Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/flows.css | 43 ++ modules/rflows/components/folk-flows-app.ts | 503 +++++++++++++++++--- modules/rflows/lib/types.ts | 9 +- 3 files changed, 499 insertions(+), 56 deletions(-) diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index 7a90e06..e025e41 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -436,6 +436,49 @@ } .sufficiency-glow { animation: sufficiencyPulse 2s ease-in-out infinite; } +/* ── Funnel shape & inline editing ─────────────────── */ + +/* Threshold markers in edit mode */ +.threshold-marker { pointer-events: none; } +.threshold-handle { cursor: ns-resize; transition: opacity 0.15s; } +.threshold-handle:hover { opacity: 0.8; } + +/* Inline edit inputs (foreignObject) */ +.inline-edit-input { + background: transparent; border: none; border-bottom: 1px solid #6366f1; + color: #e2e8f0; font-size: 13px; font-weight: 600; width: 100%; + outline: none; padding: 2px 4px; box-sizing: border-box; + font-family: system-ui, -apple-system, sans-serif; +} +.inline-edit-input:focus { border-bottom-color: #818cf8; } + +/* Edit mode toolbar */ +.inline-edit-toolbar { + display: flex; gap: 4px; justify-content: center; margin-top: 2px; +} +.inline-edit-toolbar button { + padding: 3px 8px; border-radius: 4px; border: none; + font-size: 10px; cursor: pointer; font-weight: 600; + font-family: system-ui, -apple-system, sans-serif; + transition: opacity 0.15s; +} +.inline-edit-toolbar button:hover { opacity: 0.85; } + +/* Inline edit overlay container */ +.inline-edit-overlay { pointer-events: all; } + +/* Funnel overflow lip glow when overflowing */ +.funnel-lip { transition: fill 0.3s, opacity 0.3s; } +.funnel-lip--active { fill: #f59e0b; opacity: 0.8; } + +/* Status badge in outcome inline edit */ +.inline-status-badge { cursor: pointer; transition: opacity 0.15s; } +.inline-status-badge:hover { opacity: 0.8; } + +/* Side port arrows */ +.port-group[data-port-side="left"] .port-arrow { /* horizontal arrow left handled inline */ } +.port-group[data-port-side="right"] .port-arrow { /* horizontal arrow right handled inline */ } + /* ── Mobile responsive ──────────────────────────────── */ @media (max-width: 768px) { .flows-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; } diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 0be2cc3..22c1822 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -93,10 +93,17 @@ class FolkFlowsApp extends HTMLElement { private simInterval: ReturnType | null = null; private canvasInitialized = false; + // Inline edit state + private inlineEditNodeId: string | null = null; + private inlineEditDragThreshold: string | null = null; + private inlineEditDragStartY = 0; + private inlineEditDragStartValue = 0; + // Wiring state private wiringActive = false; private wiringSourceNodeId: string | null = null; private wiringSourcePortKind: PortKind | null = null; + private wiringSourcePortSide: "left" | "right" | null = null; private wiringDragging = false; private wiringPointerX = 0; private wiringPointerY = 0; @@ -653,7 +660,13 @@ class FolkFlowsApp extends HTMLElement { private getNodeSize(n: FlowNode): { w: number; h: number } { if (n.type === "source") return { w: 200, h: 60 }; - if (n.type === "funnel") return { w: 220, h: 180 }; + if (n.type === "funnel") { + const d = n.data as FunnelNodeData; + const baseW = 200, baseH = 160; + // Scale: $1k/mo = 1x, $10k/mo = ~1.3x, $100k/mo = ~1.6x (logarithmic) + const scale = 1 + Math.log10(Math.max(1, d.inflowRate / 1000)) * 0.3; + return { w: Math.round(baseW * scale), h: Math.round(baseH * scale) }; + } return { w: 200, h: 100 }; // outcome } @@ -766,9 +779,10 @@ class FolkFlowsApp extends HTMLElement { nodeDragStarted = false; svg.classList.remove("dragging"); - // If it was a click (no drag), open the editor + // Single click = select only (inline edit on double-click) if (!wasDragged) { - this.openEditor(clickedNodeId); + this.selectedNodeId = clickedNodeId; + this.updateSelectionHighlight(); } } }; @@ -799,7 +813,8 @@ class FolkFlowsApp extends HTMLElement { // Start wiring from output port if (portDir === "out") { - this.enterWiring(portNodeId, portKind); + const portSide = portGroup.dataset.portSide as "left" | "right" | undefined; + this.enterWiring(portNodeId, portKind, portSide); this.wiringDragging = true; this.wiringPointerX = e.clientX; this.wiringPointerY = e.clientY; @@ -844,9 +859,7 @@ class FolkFlowsApp extends HTMLElement { if (!nodeId) return; const node = this.nodes.find((n) => n.id === nodeId); if (!node) return; - if (node.type === "outcome") this.openOutcomeModal(nodeId); - else if (node.type === "source") this.openSourceModal(nodeId); - else this.openEditor(nodeId); + this.enterInlineEdit(nodeId); }); // Hover: tooltip + edge highlighting @@ -974,6 +987,7 @@ class FolkFlowsApp extends HTMLElement { if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; if (e.key === "Escape") { + if (this.inlineEditNodeId) { this.exitInlineEdit(); return; } if (this.wiringActive) { this.cancelWiring(); return; } this.closeModal(); this.closeEditor(); @@ -1065,9 +1079,11 @@ class FolkFlowsApp extends HTMLElement { private renderFunnelNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string { const d = n.data as FunnelNodeData; - const x = n.position.x, y = n.position.y, w = 220, h = 180; + const s = this.getNodeSize(n); + const x = n.position.x, y = n.position.y, w = s.w, h = s.h; const sufficiency = computeSufficiencyState(d); const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant"; + const isAbundant = sufficiency === "abundant"; const threshold = d.sufficientThreshold ?? d.maxThreshold; const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); @@ -1079,46 +1095,89 @@ class FolkFlowsApp extends HTMLElement { : sufficiency === "sufficient" ? "Sufficient" : d.currentValue < d.minThreshold ? "Critical" : "Seeking"; - // Inflow satisfaction bar - const satBarY = 28; - const satBarW = w - 20; - const satRatio = sat ? Math.min(sat.ratio, 1) : 0; - const satOverflow = sat ? sat.ratio > 1 : false; - const satFillW = satBarW * satRatio; - const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : ""; - const satBarBorder = satOverflow ? `stroke="#fbbf24" stroke-width="1"` : ""; + // Funnel shape parameters + const r = 10; // corner radius + const lipW = 14; // overflow lip extension + const lipH = Math.round(h * 0.08); // lip notch top offset + const lipNotch = 14; // lip notch height + const taperStart = 0.65; // body tapers at 65% down + const taperInset = 0.2; // bottom is 60% of top width + const insetPx = Math.round(w * taperInset); + const taperY = Math.round(h * taperStart); + const clipId = `funnel-clip-${n.id}`; - // 3-zone background: drain (red), healthy (blue), overflow (amber) - const zoneY = 52; - const zoneH = h - 76; + // Funnel SVG path: wide top with lip notches, tapering to narrow bottom + const funnelPath = [ + `M ${r},0`, // top-left after corner + `L ${w - r},0`, // across top + `Q ${w},0 ${w},${r}`, // top-right corner + `L ${w},${lipH}`, // down to right lip + `L ${w + lipW},${lipH}`, // right lip extends + `L ${w + lipW},${lipH + lipNotch}`, // right lip bottom + `L ${w},${lipH + lipNotch}`, // back to body + `L ${w},${taperY}`, // down right side to taper + `Q ${w},${taperY + (h - taperY) * 0.3} ${w - insetPx},${h - r}`, // taper curve right + `Q ${w - insetPx},${h} ${w - insetPx - r},${h}`, // bottom-right corner + `L ${insetPx + r},${h}`, // across narrow bottom + `Q ${insetPx},${h} ${insetPx},${h - r}`, // bottom-left corner + `Q ${insetPx},${taperY + (h - taperY) * 0.3} 0,${taperY}`, // taper curve left + `L 0,${lipH + lipNotch}`, // up left side from taper + `L ${-lipW},${lipH + lipNotch}`, // left lip bottom + `L ${-lipW},${lipH}`, // left lip top + `L 0,${lipH}`, // back to body + `L 0,${r}`, // up to top-left + `Q 0,0 ${r},0`, // top-left corner + `Z`, + ].join(" "); + + // Interior regions (clipped to funnel shape) + const zoneTop = lipH + lipNotch + 4; + const zoneBot = h - 4; + const zoneH = zoneBot - zoneTop; const drainPct = d.minThreshold / (d.maxCapacity || 1); const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1); - const overflowPct = 1 - drainPct - healthyPct; + const overflowPct = Math.max(0, 1 - drainPct - healthyPct); const drainH = zoneH * drainPct; const healthyH = zoneH * healthyPct; const overflowH = zoneH * overflowPct; // Fill level const totalFillH = zoneH * fillPct; - const fillY = zoneY + zoneH - totalFillH; + const fillY = zoneTop + zoneH - totalFillH; + + // Inflow satisfaction bar + const satBarY = lipH + lipNotch + 22; + const satBarW = w - 40; + const satRatio = sat ? Math.min(sat.ratio, 1) : 0; + const satOverflow = sat ? sat.ratio > 1 : false; + const satFillW = satBarW * satRatio; + const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : ""; + const satBarBorder = satOverflow ? `stroke="#fbbf24" stroke-width="1"` : ""; const glowClass = isSufficient ? " node-glow" : ""; return ` - ${isSufficient ? `` : ""} - - ${this.esc(d.label)} - ${statusLabel} - - + + + + ${isSufficient ? `` : ""} + + + + + + + + + + ${this.esc(d.label)} + ${statusLabel} + + ${satLabel} - - - - - $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} - - + $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} + + ${this.renderPortsSvg(n)} `; } @@ -1186,6 +1245,7 @@ class FolkFlowsApp extends HTMLElement { fromNode: FlowNode; toNode: FlowNode; fromPort: PortKind; + fromSide?: "left" | "right"; color: string; flowAmount: number; pct: number; @@ -1213,14 +1273,16 @@ class FolkFlowsApp extends HTMLElement { } if (n.type === "funnel") { const d = n.data as FunnelNodeData; - // Overflow edges — actual excess flow + // Overflow edges — actual excess flow (routed through side ports) for (const alloc of d.overflowAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; const excess = Math.max(0, d.currentValue - d.maxThreshold); const flowAmount = excess * (alloc.percentage / 100); + const side = this.getOverflowSideForTarget(n, target); edges.push({ fromNode: n, toNode: target, fromPort: "overflow", + fromSide: side, color: alloc.color || "#f59e0b", flowAmount, pct: alloc.percentage, dashed: true, fromId: n.id, toId: alloc.targetId, edgeType: "overflow", @@ -1269,7 +1331,7 @@ class FolkFlowsApp extends HTMLElement { // Second pass: render edges with normalized widths let html = ""; for (const e of edges) { - const from = this.getPortPosition(e.fromNode, e.fromPort); + const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide); const to = this.getPortPosition(e.toNode, "inflow"); const isGhost = e.flowAmount === 0; const strokeW = isGhost ? 1 : Math.max(1.5, (e.flowAmount / maxFlowAmount) * 14); @@ -1278,6 +1340,7 @@ class FolkFlowsApp extends HTMLElement { from.x, from.y, to.x, to.y, e.color, strokeW, e.dashed, isGhost, label, e.fromId, e.toId, e.edgeType, + e.fromSide, ); } return html; @@ -1287,12 +1350,25 @@ class FolkFlowsApp extends HTMLElement { x1: number, y1: number, x2: number, y2: number, color: string, strokeW: number, dashed: boolean, ghost: boolean, label: string, fromId: string, toId: string, edgeType: string, + fromSide?: "left" | "right", ): string { - const cy1 = y1 + (y2 - y1) * 0.4; - const cy2 = y1 + (y2 - y1) * 0.6; - const midX = (x1 + x2) / 2; - const midY = (y1 + y2) / 2; - const d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`; + let d: string; + let midX: number; + let midY: number; + + if (fromSide) { + // Side port: curve outward horizontally first, then turn toward target + const outwardX = fromSide === "left" ? x1 - 60 : x1 + 60; + d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`; + midX = (x1 + outwardX + x2) / 3; + midY = (y1 + y2) / 2; + } else { + const cy1 = y1 + (y2 - y1) * 0.4; + const cy2 = y1 + (y2 - y1) * 0.6; + d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`; + midX = (x1 + x2) / 2; + midY = (y1 + y2) / 2; + } if (ghost) { return ` @@ -1348,7 +1424,7 @@ class FolkFlowsApp extends HTMLElement { const el = g as SVGGElement; const isSelected = el.dataset.nodeId === this.selectedNodeId; el.classList.toggle("selected", isSelected); - const bg = el.querySelector(".node-bg") as SVGRectElement | null; + const bg = el.querySelector(".node-bg") as SVGElement | null; if (bg) { if (isSelected) { bg.setAttribute("stroke", "#6366f1"); @@ -1386,23 +1462,51 @@ class FolkFlowsApp extends HTMLElement { return PORT_DEFS[nodeType] || []; } - private getPortPosition(node: FlowNode, portKind: PortKind): { x: number; y: number } { + private getPortPosition(node: FlowNode, portKind: PortKind, side?: "left" | "right"): { x: number; y: number } { const s = this.getNodeSize(node); - const def = this.getPortDefs(node.type).find((p) => p.kind === portKind); + let def: PortDefinition | undefined; + if (side) { + def = this.getPortDefs(node.type).find((p) => p.kind === portKind && p.side === side); + } + if (!def) { + def = this.getPortDefs(node.type).find((p) => p.kind === portKind && (!side || !p.side)); + } + if (!def) { + // Fallback: pick first matching kind + def = this.getPortDefs(node.type).find((p) => p.kind === portKind); + } if (!def) return { x: node.position.x + s.w / 2, y: node.position.y + s.h / 2 }; return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac }; } + /** Pick the overflow side port closest to a target node */ + private getOverflowSideForTarget(fromNode: FlowNode, toNode: FlowNode): "left" | "right" { + const toCenter = toNode.position.x + this.getNodeSize(toNode).w / 2; + const fromCenter = fromNode.position.x + this.getNodeSize(fromNode).w / 2; + return toCenter < fromCenter ? "left" : "right"; + } + private renderPortsSvg(n: FlowNode): string { const s = this.getNodeSize(n); const defs = this.getPortDefs(n.type); return defs.map((p) => { const cx = s.w * p.xFrac; const cy = s.h * p.yFrac; - const arrow = p.dir === "out" - ? `` - : ``; - return ` + let arrow: string; + const sideAttr = p.side ? ` data-port-side="${p.side}"` : ""; + if (p.side) { + // Side port: horizontal arrow + if (p.side === "left") { + arrow = ``; + } else { + arrow = ``; + } + } else if (p.dir === "out") { + arrow = ``; + } else { + arrow = ``; + } + return ` ${arrow} @@ -1410,10 +1514,11 @@ class FolkFlowsApp extends HTMLElement { }).join(""); } - private enterWiring(nodeId: string, portKind: PortKind) { + private enterWiring(nodeId: string, portKind: PortKind, portSide?: "left" | "right") { this.wiringActive = true; this.wiringSourceNodeId = nodeId; this.wiringSourcePortKind = portKind; + this.wiringSourcePortSide = portSide || null; this.wiringDragging = false; const svg = this.shadow.getElementById("flow-canvas"); if (svg) svg.classList.add("wiring"); @@ -1424,6 +1529,7 @@ class FolkFlowsApp extends HTMLElement { this.wiringActive = false; this.wiringSourceNodeId = null; this.wiringSourcePortKind = null; + this.wiringSourcePortSide = null; this.wiringDragging = false; const svg = this.shadow.getElementById("flow-canvas"); if (svg) svg.classList.remove("wiring"); @@ -1534,7 +1640,7 @@ class FolkFlowsApp extends HTMLElement { const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId); if (!sourceNode) return; - const { x: x1, y: y1 } = this.getPortPosition(sourceNode, this.wiringSourcePortKind); + const { x: x1, y: y1 } = this.getPortPosition(sourceNode, this.wiringSourcePortKind, this.wiringSourcePortSide || undefined); const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null; if (!svg) return; @@ -1542,9 +1648,17 @@ class FolkFlowsApp extends HTMLElement { const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom; const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom; - const cy1 = y1 + (y2 - y1) * 0.4; - const cy2 = y1 + (y2 - y1) * 0.6; - wireLayer.innerHTML = ``; + let tempPath: string; + if (this.wiringSourcePortSide) { + // Side port: curve outward horizontally first + const outwardX = this.wiringSourcePortSide === "left" ? x1 - 60 : x1 + 60; + tempPath = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`; + } else { + const cy1 = y1 + (y2 - y1) * 0.4; + const cy2 = y1 + (y2 - y1) * 0.6; + tempPath = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`; + } + wireLayer.innerHTML = ``; } // ─── Node position update (direct DOM, no re-render) ── @@ -1637,6 +1751,289 @@ class FolkFlowsApp extends HTMLElement { if (panel) { panel.classList.remove("open"); panel.innerHTML = ""; } } + // ─── Inline edit mode ───────────────────────────────── + + private enterInlineEdit(nodeId: string) { + // Exit any previous inline edit + if (this.inlineEditNodeId && this.inlineEditNodeId !== nodeId) { + this.exitInlineEdit(); + } + this.inlineEditNodeId = nodeId; + this.selectedNodeId = nodeId; + this.updateSelectionHighlight(); + + const node = this.nodes.find((n) => n.id === nodeId); + if (!node) return; + + // Overlay the inline edit SVG elements on the node + const nodeLayer = this.shadow.getElementById("node-layer"); + const g = nodeLayer?.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null; + if (!g) return; + + // Remove any existing inline edit overlay + g.querySelector(".inline-edit-overlay")?.remove(); + + const s = this.getNodeSize(node); + const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g"); + overlay.classList.add("inline-edit-overlay"); + + if (node.type === "funnel") { + this.renderFunnelInlineEdit(overlay, node, s); + } else if (node.type === "source") { + this.renderSourceInlineEdit(overlay, node, s); + } else { + this.renderOutcomeInlineEdit(overlay, node, s); + } + + // Toolbar: Done | Delete | ... + const toolbarY = s.h + 8; + overlay.innerHTML += ` + +
+ + + +
+
`; + + g.appendChild(overlay); + this.attachInlineEditListeners(g, node); + } + + private renderFunnelInlineEdit(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { + const d = node.data as FunnelNodeData; + const lipH = Math.round(s.h * 0.08); + const lipNotch = 14; + const zoneTop = lipH + lipNotch + 4; + const zoneBot = s.h - 4; + const zoneH = zoneBot - zoneTop; + + // Label edit + overlay.innerHTML = ` + + + `; + + // Threshold markers + const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [ + { key: "minThreshold", value: d.minThreshold, color: "#ef4444", label: "Min" }, + { key: "maxThreshold", value: d.maxThreshold, color: "#f59e0b", label: "Max" }, + ]; + if (d.sufficientThreshold !== undefined) { + thresholds.push({ key: "sufficientThreshold", value: d.sufficientThreshold, color: "#10b981", label: "Suf" }); + } + + for (const t of thresholds) { + const frac = t.value / (d.maxCapacity || 1); + const markerY = zoneTop + zoneH * (1 - frac); + + overlay.innerHTML += ` + + + ${t.label} ${this.formatDollar(t.value)}`; + } + } + + private renderSourceInlineEdit(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { + const d = node.data as SourceNodeData; + overlay.innerHTML = ` + + + + + + `; + } + + private renderOutcomeInlineEdit(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { + const d = node.data as OutcomeNodeData; + const statusColors: Record = { + "not-started": "#64748b", "in-progress": "#3b82f6", "completed": "#10b981", "blocked": "#ef4444" + }; + const statusList = ["not-started", "in-progress", "completed", "blocked"] as const; + const nextStatus = statusList[(statusList.indexOf(d.status) + 1) % statusList.length]; + + overlay.innerHTML = ` + + + + + + + + ${d.status}`; + } + + private attachInlineEditListeners(g: SVGGElement, node: FlowNode) { + const overlay = g.querySelector(".inline-edit-overlay"); + if (!overlay) return; + + // Input fields + overlay.querySelectorAll("input[data-inline-field]").forEach((el) => { + const input = el as HTMLInputElement; + const field = input.dataset.inlineField!; + input.addEventListener("input", () => { + const val = input.type === "number" ? parseFloat(input.value) || 0 : input.value; + (node.data as any)[field] = val; + // Re-render the node (but not the overlay) + this.redrawNodeOnly(node); + this.redrawEdges(); + }); + input.addEventListener("keydown", (e: Event) => { + const ke = e as KeyboardEvent; + if (ke.key === "Enter") this.exitInlineEdit(); + if (ke.key === "Escape") this.exitInlineEdit(); + ke.stopPropagation(); + }); + }); + + // Threshold drag handles + overlay.querySelectorAll(".threshold-handle").forEach((el) => { + el.addEventListener("pointerdown", (e: Event) => { + const pe = e as PointerEvent; + pe.stopPropagation(); + pe.preventDefault(); + const thresholdKey = (el as SVGElement).dataset.threshold!; + this.inlineEditDragThreshold = thresholdKey; + this.inlineEditDragStartY = pe.clientY; + this.inlineEditDragStartValue = (node.data as any)[thresholdKey] || 0; + (el as Element).setPointerCapture(pe.pointerId); + }); + el.addEventListener("pointermove", (e: Event) => { + if (!this.inlineEditDragThreshold) return; + const pe = e as PointerEvent; + const d = node.data as FunnelNodeData; + const s = this.getNodeSize(node); + const lipH = Math.round(s.h * 0.08); + const lipNotch = 14; + const zoneH = s.h - 4 - (lipH + lipNotch + 4); + // Pixels to dollar conversion + const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom; + const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1); + let newVal = this.inlineEditDragStartValue + deltaDollars; + // Constrain: 0 ≤ min ≤ sufficient ≤ max ≤ capacity + newVal = Math.max(0, Math.min(d.maxCapacity, newVal)); + const key = this.inlineEditDragThreshold; + if (key === "minThreshold") newVal = Math.min(newVal, d.maxThreshold); + if (key === "maxThreshold") newVal = Math.max(newVal, d.minThreshold); + if (key === "sufficientThreshold") newVal = Math.max(d.minThreshold, Math.min(d.maxThreshold, newVal)); + (node.data as any)[key] = Math.round(newVal); + // Update display + this.redrawNodeInlineEdit(node); + }); + el.addEventListener("pointerup", () => { + this.inlineEditDragThreshold = null; + }); + }); + + // Done button + overlay.querySelector(".iet-done")?.addEventListener("click", (e: Event) => { + e.stopPropagation(); + this.exitInlineEdit(); + }); + + // Delete button + overlay.querySelector(".iet-delete")?.addEventListener("click", (e: Event) => { + e.stopPropagation(); + this.deleteNode(node.id); + this.exitInlineEdit(); + }); + + // "..." panel fallback button + overlay.querySelector(".iet-panel")?.addEventListener("click", (e: Event) => { + e.stopPropagation(); + this.exitInlineEdit(); + this.openEditor(node.id); + }); + + // Status badge cycling (outcome) + overlay.querySelector("[data-inline-action='cycle-status']")?.addEventListener("click", (e: Event) => { + e.stopPropagation(); + const d = node.data as OutcomeNodeData; + const statusList = ["not-started", "in-progress", "completed", "blocked"] as const; + d.status = statusList[(statusList.indexOf(d.status) + 1) % statusList.length]; + this.redrawNodeInlineEdit(node); + }); + + // Click-outside handler to exit inline edit + const clickOutsideHandler = (e: PointerEvent) => { + const target = e.target as Element; + if (!target.closest(`[data-node-id="${node.id}"]`)) { + this.exitInlineEdit(); + document.removeEventListener("pointerdown", clickOutsideHandler as EventListener, true); + } + }; + setTimeout(() => { + document.addEventListener("pointerdown", clickOutsideHandler as EventListener, true); + }, 100); + } + + private redrawNodeOnly(node: FlowNode) { + const nodeLayer = this.shadow.getElementById("node-layer"); + if (!nodeLayer) return; + const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; + if (!g) return; + const satisfaction = this.computeInflowSatisfaction(); + const newSvg = this.renderNodeSvg(node, satisfaction); + // Parse and replace, preserving inline edit overlay + const overlay = g.querySelector(".inline-edit-overlay"); + const temp = document.createElementNS("http://www.w3.org/2000/svg", "g"); + temp.innerHTML = newSvg; + const newG = temp.firstElementChild as SVGGElement; + if (newG && overlay) { + newG.appendChild(overlay); + } + if (newG) { + g.replaceWith(newG); + } + } + + private redrawNodeInlineEdit(node: FlowNode) { + // Re-render the whole node + re-enter inline edit + this.drawCanvasContent(); + const s = this.getNodeSize(node); + const nodeLayer = this.shadow.getElementById("node-layer"); + const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; + if (!g) return; + g.querySelector(".inline-edit-overlay")?.remove(); + + const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g"); + overlay.classList.add("inline-edit-overlay"); + + if (node.type === "funnel") { + this.renderFunnelInlineEdit(overlay, node, s); + } else if (node.type === "source") { + this.renderSourceInlineEdit(overlay, node, s); + } else { + this.renderOutcomeInlineEdit(overlay, node, s); + } + + // Toolbar + const toolbarY = s.h + 8; + overlay.innerHTML += ` + +
+ + + +
+
`; + + g.appendChild(overlay); + this.attachInlineEditListeners(g, node); + } + + private exitInlineEdit() { + if (!this.inlineEditNodeId) return; + const nodeLayer = this.shadow.getElementById("node-layer"); + const g = nodeLayer?.querySelector(`[data-node-id="${this.inlineEditNodeId}"]`) as SVGGElement | null; + if (g) g.querySelector(".inline-edit-overlay")?.remove(); + this.inlineEditNodeId = null; + this.inlineEditDragThreshold = null; + // Re-render to apply any changes + this.drawCanvasContent(); + } + private refreshEditorIfOpen(nodeId: string) { if (this.editingNodeId === nodeId) this.openEditor(nodeId); } diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index b0f75f3..cd61de7 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -109,6 +109,8 @@ export interface PortDefinition { color: string; /** Which port kinds this output can wire to */ connectsTo?: PortKind[]; + /** For side-mounted ports (overflow lips) */ + side?: "left" | "right"; } /** Single source of truth for port positions, colors, and connectivity rules. */ @@ -117,9 +119,10 @@ export const PORT_DEFS: Record = { { kind: "outflow", dir: "out", xFrac: 0.5, yFrac: 1, color: "#10b981", connectsTo: ["inflow"] }, ], funnel: [ - { kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" }, - { kind: "spending", dir: "out", xFrac: 0.3, yFrac: 1, color: "#8b5cf6", connectsTo: ["inflow"] }, - { kind: "overflow", dir: "out", xFrac: 0.7, yFrac: 1, color: "#f59e0b", connectsTo: ["inflow"] }, + { kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" }, + { kind: "overflow", dir: "out", xFrac: 0.0, yFrac: 0.15, color: "#f59e0b", connectsTo: ["inflow"], side: "left" }, + { kind: "overflow", dir: "out", xFrac: 1.0, yFrac: 0.15, color: "#f59e0b", connectsTo: ["inflow"], side: "right" }, + { kind: "spending", dir: "out", xFrac: 0.5, yFrac: 1, color: "#8b5cf6", connectsTo: ["inflow"] }, ], outcome: [ { kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" },