UNPKG

@bpgraph/core

Version:

**bpgraph** is a lightweight node-based visual programming library. It allows you to easily build interactive flow editors for low-code platforms, AI pipelines, or data processing systems.

1,408 lines (1,401 loc) 127 kB
import { Element, ElementView, Graph, Link, Link$1, Listener, Paper, Rect, V_default, config, curve, defaultsDeep, left, mask, right, standard_exports, svg } from "./vendor-CHy9MZSZ.js"; //#region src/adapters/joint/Util.ts function initCharWidth(fontSize, fontFamily = "system-ui, Avenir, Helvetica, Arial, sans-serif", charWidthCache) { const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); context.font = `${fontSize}px ${fontFamily}`; const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ "; for (const char of chars) charWidthCache[char] = context.measureText(char).width; } function getCharWidthCache(fontSize) { const fontSizeCache = {}; if (!fontSizeCache[fontSize]) { fontSizeCache[fontSize] = { default: fontSize * .5 }; initCharWidth(fontSize, "system-ui, Avenir, Helvetica, Arial, sans-serif", fontSizeCache[fontSize]); } return fontSizeCache[fontSize]; } function getTextWidth(text, fontSize) { let width = 0; const charWidthCache = getCharWidthCache(fontSize); for (const char of text) if (/[\u4e00-\u9fa5]/.test(char)) width += fontSize; else width += charWidthCache[char] ?? charWidthCache.default; return width; } function css(strings, ...values) { const result = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), ""); return result.replace(/\s+/g, " ").trim(); } //#endregion //#region src/adapters/joint/nodes/BlueprintNode.ts var BlueprintNode = class extends Element { constructor(attributes = {}, options = {}) { const style = attributes.style || {}; const nodeType = attributes.nodeType; const markup = [{ tagName: "rect", groupSelector: "base-node-body" }, { tagName: "g", groupSelector: "base-node-title" }]; if (nodeType === "switch") markup.push({ tagName: "g", groupSelector: "switch-plus", children: [{ tagName: "rect", groupSelector: "switch-plus-rect" }, { tagName: "path", groupSelector: "switch-plus-icon" }] }); const rowHeight = style.ports?.layout?.rowHeight ?? 20; const rowSpacing = style.ports?.layout?.gap ?? 8; const portStroke = style.ports?.output?.port?.stroke ?? "rgba(255, 255, 255, 0.8)"; super(defaultsDeep(attributes, { type: "custom.BlueprintNode", size: { width: 300, height: 100 }, inputs: [], outputs: [], attrs: { "base-node-body": { refWidth: "100%", refHeight: "100%", fill: style.background ?? "rgba(41, 44, 47, 0.9)", stroke: style.background ?? "rgba(41, 44, 47, 0.9)", strokeWidth: 1, rx: style.borderRadius ?? 8, ry: style.borderRadius ?? 8 }, "switch-plus": { cursor: "pointer", refX: "100%", refY: "100%", transform: "translate(-22, -" + (rowSpacing + rowHeight * .5) + ")" }, "switch-plus-rect": { width: 12, height: 12, fill: "transparent", stroke: "transparent", strokeWidth: 1 }, "switch-plus-icon": { d: "M2 6 L10 6 M6 2 L6 10", stroke: portStroke, strokeWidth: 1.5, pointerEvents: "none" } }, markup, ports: { groups: { in: { position: { name: "absolute", args: { x: 16 } }, attrs: { portBody: { fill: style.ports?.input?.port?.fill ?? "rgba(255, 255, 255, 0.5)", stroke: style.ports?.input?.port?.stroke ?? "rgba(255, 255, 255, 0.8)", strokeWidth: style.ports?.input?.port?.strokeWidth ?? 1, r: 6, magnet: "inout", cursor: "crosshair" }, portLabel: { x: 12, y: "0.3em", textAnchor: "start", fontSize: style.ports?.input?.label?.fontSize ?? 12, fill: style.ports?.input?.label?.color ?? "#fff" } } }, out: { position: { name: "absolute", args: { x: "calc(w - 16)" } }, attrs: { portBody: { fill: style.ports?.output?.port?.fill ?? "rgba(255, 255, 255, 0.5)", stroke: style.ports?.output?.port?.stroke ?? "rgba(255, 255, 255, 0.8)", strokeWidth: style.ports?.output?.port?.strokeWidth ?? 1, r: 6, magnet: "inout", cursor: "crosshair" }, portLabel: { x: -12, y: "0.3em", textAnchor: "end", fontSize: style.ports?.output?.label?.fontSize ?? 12, fill: style.ports?.output?.label?.color ?? "#fff" } } } } } }), options); } initialize(...args) { super.initialize(...args); if (this.getPorts().length === 0) this.buildPortItems(); this.on("change:inputs change:outputs", (_el, _changed, opt) => this.buildPortItems(opt)); } title(value) { this.set("title", value); const titleWidth = value.length > 0 ? getTextWidth(value, this.get("style")?.header?.fontSize || 12) : 0; const size = this.get("size"); const width = size?.width || 0; if (titleWidth + 35 > width) { this.resize(titleWidth + 35, size?.height || 100); return; } this.updateSizeByContent(); } setStyle(style) { this.set("style", style); } setValues(values) { this.set("values", values); } buildPortItems(opt = {}) { const items = []; const inputs = this.get("inputs") || []; const outputs = this.get("outputs") || []; const style = this.get("style") || {}; const HEADER_HEIGHT = style.header?.height ?? 24; const portHeight = style.ports?.layout?.rowHeight ?? 20; const portSpacing = style.ports?.layout?.gap ?? 8; const portTop = style.ports?.layout?.top ?? 0; for (const [index, input] of inputs.entries()) { if (input.type === "spacer") continue; items.push({ ...this.buildPortItem(input, "in"), id: input.id, group: "in", args: { y: HEADER_HEIGHT + index * portHeight + (index + 1) * portSpacing + portHeight / 2 + portTop } }); } for (const [index, output] of outputs.entries()) { if (output.type === "spacer") continue; items.push({ ...this.buildPortItem(output, "out"), id: output.id, group: "out", args: { y: HEADER_HEIGHT + index * portHeight + (index + 1) * portSpacing + portHeight / 2 + portTop } }); } this.startBatch("update-ports"); this.prop(["ports", "items"], items, { ...opt, rewrite: true }); this.stopBatch("update-ports"); this.updateSizeByContent(); } buildPortItem(port, group) { const style = this.get("style") || {}; const portHeight = style.ports?.layout?.rowHeight ?? 20; const left$1 = group === "in"; const portStyle = left$1 ? style.ports?.input : style.ports?.output; const fontSize = portStyle?.label?.fontSize ?? 12; const color = portStyle?.label?.color ?? "#fff"; const portLabelX = this.get("ports")?.groups?.in.attrs?.portLabel?.x || 12; const portLabelWidth = left$1 ? getTextWidth(port.label || "", fontSize) : 0; let showPort = port.showPort !== false; if (port.type === "select" && port.showPort !== true) showPort = false; const item = { height: portHeight, attrs: { portLabel: { text: port.label || "" }, portForm: { x: portLabelX + portLabelWidth + 4, y: -portHeight / 2, width: style.ports?.input?.editor?.box?.width || 80, height: portHeight }, portFormSvg: { transform: `translate(${portLabelX + portLabelWidth + 4}, ${-portHeight / 2})` }, portFormWrap: { style: { textAlign: left$1 ? "left" : "right", fontSize: fontSize + "px", color } }, field: { name: port.name } } }; const formWrap = left$1 ? this.renderFormWrap(port) : ""; item.markup = svg` ${showPort ? getPortIcon(port.type) : ""} <text @selector='portLabel' /> ${formWrap ? formWrap : ""} `; return item; } renderFormWrap(input) { const showInput = input.showInput !== false; if (!showInput) return ""; const style = this.get("style") || {}; const values = this.get("values") || {}; switch (input.type) { case "string": return renderString(input, style, values); case "number": return renderNumber(input, style, values); case "select": return renderSelect(input, style, values); case "boolean": return renderCheckbox(input, style, values); default: return ""; } } updateSizeByContent() { const ports = this.getPorts(); const style = this.get("style") || {}; const title = this.get("title") || ""; const nodeType = this.get("nodeType") || ""; const headerFontSize = style.header?.fontSize ?? 12; const inputPorts = ports.filter((port) => port.group === "in"); const outputPorts = ports.filter((port) => port.group === "out"); const portCount = inputPorts.length > outputPorts.length ? inputPorts.length : outputPorts.length; const titleHeight = style.header?.height ?? 24; const portHeight = style.ports?.layout?.rowHeight ?? 20; const portGap = style.ports?.layout?.gap ?? 8; const paddingTop = style.ports?.layout?.top ?? 0; const paddingBottom = style.ports?.layout?.bottom ?? 20; const editorBoxWidth = style.ports?.input?.editor?.box?.width || 0; let portsHeight = portCount > 0 ? portCount * portHeight + (portCount + 1) * portGap : 0; if (nodeType === "switch") portsHeight += portHeight * .3 + portGap; const titleWidth = title.length > 0 ? getTextWidth(title, headerFontSize) : 0; const inputsWidth = inputPorts.length > 0 ? Math.max(...inputPorts.map((port) => { const label = port.attrs?.portLabel?.text ?? ""; const markup = port.markup || []; const fontSize = style.ports?.input?.label?.fontSize || 12; const isPortForm = markup.some((m) => m.selector === "portForm"); const inputWidth = isPortForm ? editorBoxWidth + 8 : 0; const labelWidth = label.length > 0 ? getTextWidth(label, fontSize) : 0; return labelWidth + 25 + inputWidth; })) : 0; const outputsWidth = outputPorts.length > 0 ? Math.max(...outputPorts.map((port) => { const label = port.attrs?.portLabel?.text ?? ""; const fontSize = style.ports?.output?.label?.fontSize || 12; const labelWidth = label.length > 0 ? getTextWidth(label, fontSize) : 0; return labelWidth + 25; })) : 0; const minWidth = 120; const minHeight = 60; const height = Math.max(minHeight, titleHeight + portsHeight + paddingTop + paddingBottom); const width = Math.max(minWidth, titleWidth + 35, inputsWidth + outputsWidth + 14); this.resize(width, height); } }; function getPortIcon(type) { switch (type) { case "exec": return ` <path @selector='portBody' d="${squareArrowPath(12, 12)}" /> <title>${type}</title>`; default: return `<circle @selector="portBody" r="6" /> <title>${type}</title>`; } } function squareArrowPath(w, h) { const param = Math.max(w * .4, 0); const offsetX = w / 2; const offsetY = h / 2; return [ `M${0 - offsetX},${0 - offsetY}`, `L${w - param - offsetX},${0 - offsetY}`, `L${w - offsetX},${h / 2 - offsetY}`, `L${w - param - offsetX},${h - offsetY}`, `L${0 - offsetX},${h - offsetY} Z` ].join(" "); } function renderString(input, style, values) { const boxStyle = style.ports?.input?.editor?.box; const portHeight = style.ports?.layout?.rowHeight ?? 20; const inputStyle = css` outline: none; padding-left: 4px; padding-right: 4px; box-sizing: border-box; width: 100%; height: 22px; line-height: 22px; border: none; background: ${boxStyle?.background ?? "transparent"}; color: ${boxStyle?.color ?? "#fff"}; font-size: ${boxStyle?.fontSize ?? 12}px; border: 1px solid ${boxStyle?.borderColor ?? "rgba(255, 255, 255, 0.2)"}; border-radius: ${boxStyle?.borderRadius ?? 4}px; `; return ` <foreignObject @selector="portForm"> <div @selector="portFormWrap" xmlns="http://www.w3.org/1999/xhtml" style="width:100%; height:100%;line-height: ${portHeight - 2}px;"> <input @selector="field" xmlns="http://www.w3.org/1999/xhtml" type="text" style="${inputStyle}" value="${values[input.name] || ""}" /> </div> </foreignObject> `; } function renderNumber(input, style, values) { const boxStyle = style.ports?.input?.editor?.box; const portHeight = style.ports?.layout?.rowHeight ?? 20; const inputStyle = css` outline: none; padding-left: 4px; padding-right: 4px; box-sizing: border-box; width: 100%; height: 22px; line-height: 22px; border: none; background: ${boxStyle?.background ?? "transparent"}; color: ${boxStyle?.color ?? "#fff"}; font-size: ${boxStyle?.fontSize ?? 12}px; border: 1px solid ${boxStyle?.borderColor ?? "rgba(255, 255, 255, 0.2)"}; border-radius: ${boxStyle?.borderRadius ?? 4}px; `; const upArrowPath = "M2 8 L6 4 L10 8"; const downArrowPath = "M2 3 L6 7 L10 3"; return ` <foreignObject @selector="portForm"> <div @selector="portFormWrap" xmlns="http://www.w3.org/1999/xhtml" style="width:100%; height:100%;line-height: ${portHeight - 2}px;"> <input @selector="field" xmlns="http://www.w3.org/1999/xhtml" type="text" style="${inputStyle}" value="${values[input.name] || ""}" /> <svg width="12" height="20" style="position:absolute; right:2px; top:0;" viewBox="0 0 12 20"> <rect width="12" height="11" y="0" fill="transparent" cursor="pointer" @selector="upArrow" /> <rect width="12" height="11" y="11" fill="transparent" cursor="pointer" @selector="downArrow" /> <path d="${upArrowPath}" stroke="currentColor" stroke-width="1.2" fill="transparent" pointer-events="none" /> <path d="${downArrowPath}" stroke="currentColor" stroke-width="1.2" fill="transparent" pointer-events="none" transform="translate(0,11)" /> </svg> </div> </foreignObject> `; } function renderSelect(input, style, values) { const boxStyle = style.ports?.input?.editor?.box; const portHeight = style.ports?.layout?.rowHeight ?? 20; const inputStyle = css` outline: none; padding-left: 4px; padding-right: 16px; box-sizing: border-box; width: 100%; height: 22px; line-height: 22px; border: none; background: ${boxStyle?.background ?? "transparent"}; color: ${boxStyle?.color ?? "#fff"}; font-size: ${boxStyle?.fontSize ?? 12}px; border: 1px solid ${boxStyle?.borderColor ?? "rgba(255, 255, 255, 0.2)"}; border-radius: ${boxStyle?.borderRadius ?? 4}px; cursor: pointer; `; const value = values[input.name]; const options = input._options || []; const label = options.find((opt) => opt.value === value)?.label || ""; return ` <foreignObject @selector="portForm"> <div @selector="portFormWrap" xmlns="http://www.w3.org/1999/xhtml" style="width:100%; height:100%;line-height: ${portHeight - 2}px;"> <input @selector="field" xmlns="http://www.w3.org/1999/xhtml" type="text" title="${label || ""}" style="${inputStyle}" value="${label || ""}" /> <svg width="12" height="12" style="position:absolute; right:4px; top:6px; pointer-events:none;" viewBox="0 0 12 12"> <path d="M2 4 L6 8 L10 4" stroke="currentColor" stroke-width="1.5" fill="transparent" /> </svg> </div> </foreignObject> `; } function renderCheckbox(input, style, values) { return ` <g @selector="portFormSvg"> <rect @selector="field" xmlns="http://www.w3.org/2000/svg" x="0" y="4" width="16" height="16" rx="${style.ports?.input?.editor?.box?.borderRadius ?? 3}" ry="${style.ports?.input?.editor?.box?.borderRadius ?? 3}" fill="${values[input.name] ? style.ports?.input?.editor?.box?.background ?? "#ff6666" : "transparent"}" stroke="${style.ports?.input?.editor?.box?.borderColor ?? "rgba(255, 255, 255, 0.2)"}" stroke-width="1" cursor="pointer" /> <polyline points="4,11 7,14 12,8" fill="none" cursor="pointer" stroke="${style.ports?.input?.editor?.box?.color ?? "#fff"}" stroke-width="1" visibility="${values[input.name] ? "visible" : "hidden"}" /> </g> `; } //#endregion //#region src/adapters/joint/nodes/BlueprintNodeView.ts var BlueprintNodeView = class extends ElementView { highlightedPorts = /* @__PURE__ */ new Set(); dropdownElMap = /* @__PURE__ */ new Map(); currentlyOpenDropdown = null; initialize(...args) { this.paper?.el.addEventListener("click", this.hideDropdown); super.initialize(...args); this.listenTo(this.model, "change:title", this.refreshTitle); this.listenTo(this.model, "change:inputs change:outputs", this.applyPortHighlights); } onRender() { this.paper?.el.removeEventListener("click", this.otherClick); this.paper?.el.addEventListener("click", this.otherClick); const inputs = this.model.get("inputs") || []; inputs.forEach((input) => { const inputEl = this.findPortNode(input.id, "field"); if (!inputEl) return; if (input.type === "boolean") inputEl.addEventListener("click", this.updateFieldValue(input.type)); else if (input.type === "select") { this.createDropdown(input.id, input.name, input._options); inputEl.addEventListener("focus", this.showDropdown); inputEl.addEventListener("input", this.filterDropdown); } else if (input.type === "number") { inputEl.addEventListener("input", this.validateNumber(false)); inputEl.addEventListener("blur", this.validateNumber(true)); const upArrow = this.findPortNode(input.id, "upArrow"); const downArrow = this.findPortNode(input.id, "downArrow"); upArrow?.addEventListener("click", () => { const fieldEl = this.findPortNode(input.id, "field"); fieldEl.value = String(Number(fieldEl.value || 0) + 1); fieldEl.dispatchEvent(new Event("blur")); }); downArrow?.addEventListener("click", () => { const fieldEl = this.findPortNode(input.id, "field"); fieldEl.value = String(Number(fieldEl.value || 0) - 1); fieldEl.dispatchEvent(new Event("blur")); }); inputEl.addEventListener("input", this.updateFieldValue(input.type)); } else if (input.type === "string") inputEl.addEventListener("input", this.updateFieldValue(input.type)); }); const plus = this.findNode("switch-plus-rect"); if (plus) plus.addEventListener("click", (evt) => { evt.stopPropagation(); this.model.graph.trigger("node:switch:add-case", this.model); }); } otherClick = (evt) => { const target = evt.target; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.getAttribute("role") === "option") return; if (this.currentlyOpenDropdown) this.hideDropdown(); }; createDropdown(inputId, inputName, options) { const style = this.model.get("style") || {}; const dropdownStyle = style.ports?.input?.editor?.dropdown || {}; this.dropdownElMap.delete(inputName); if (!options || options.length === 0) return; const dropdownElement = document.createElement("div"); dropdownElement.addEventListener("wheel", (evt) => { evt.stopPropagation(); evt.preventDefault(); }); dropdownElement.classList.add("bpgraph-node-dropdown"); dropdownElement.style.cssText = ` background: ${dropdownStyle.background || "#222"}; border: 1px solid ${dropdownStyle.borderColor || "rgba(120, 130, 140, 0.3)"}; color: ${dropdownStyle.color || "#fff"}; border-radius: ${dropdownStyle.borderRadius || 4}px; z-index: 9999; font-size: 12px; max-height: 200px; overflow: auto; box-shadow: 0 2px 6px rgba(0,0,0,0.2); position: absolute; `; options.forEach((option) => { const optionEl = document.createElement("div"); optionEl.classList.add("bpgraph-node-dropdown-option"); optionEl.setAttribute("style", ` padding: 2px 4px; cursor: pointer; `); optionEl.setAttribute("data-value", option.value); optionEl.textContent = option.label; optionEl.addEventListener("click", (evt) => { evt.preventDefault(); evt.stopPropagation(); this.setInputValue(inputName, evt.target.dataset.value); this.findPortNode(inputId); this.hideDropdown(); const fieldEl = this.findPortNode(inputId, "field"); if (fieldEl) fieldEl.value = evt.target.textContent || ""; fieldEl.title = String(fieldEl.value); }); dropdownElement.appendChild(optionEl); }); this.dropdownElMap.set(inputName, dropdownElement); } updateOptions(inputId, inputName, options) { const dropdownElement = this.dropdownElMap.get(inputName); if (!dropdownElement) return; dropdownElement.innerHTML = ""; options.forEach((option) => { const optionEl = document.createElement("div"); optionEl.classList.add("bpgraph-node-dropdown-option"); optionEl.setAttribute("style", ` padding: 2px 4px; cursor: pointer; `); optionEl.setAttribute("data-value", option.value); optionEl.textContent = option.label; optionEl.addEventListener("click", (evt) => { evt.preventDefault(); evt.stopPropagation(); this.setInputValue(inputName, evt.target.dataset.value); this.findPortNode(inputId); this.hideDropdown(); const fieldEl = this.findPortNode(inputId, "field"); if (fieldEl) fieldEl.value = evt.target.textContent || ""; fieldEl.title = String(fieldEl.value); }); dropdownElement.appendChild(optionEl); }); } showDropdown = (evt) => { if (this.currentlyOpenDropdown) this.hideDropdown(); const fieldEl = evt.target; const portName = fieldEl.getAttribute("name"); if (!portName) return; const dropdownEl = this.dropdownElMap.get(portName); if (!dropdownEl) return; const rect = fieldEl.getBoundingClientRect(); dropdownEl.style.minWidth = `${rect.width}px`; dropdownEl.style.top = `${rect.bottom + 4}px`; dropdownEl.style.left = `${rect.left}px`; this.currentlyOpenDropdown = portName; this.paper?.el.appendChild(dropdownEl); }; hideDropdown = () => { const portName = this.currentlyOpenDropdown; if (document.activeElement && document.activeElement.getAttribute("name") === portName) return; const dropdownEl = this.dropdownElMap.get(portName || ""); this.currentlyOpenDropdown = null; if (!dropdownEl) return; this.paper?.el.removeChild(dropdownEl); }; filterDropdown = (evt) => { const fieldEl = evt.target; const portName = fieldEl.getAttribute("name"); if (!portName) return; const dropdownEl = this.dropdownElMap.get(portName); if (!dropdownEl) return; const filter = fieldEl.value.toLowerCase(); const pattern = filter.split("").map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*"); const regex = new RegExp(pattern, "i"); Array.from(dropdownEl.children).forEach((child) => { const optionEl = child; const label = optionEl.textContent || ""; if (regex.test(label)) optionEl.style.display = "block"; else optionEl.style.display = "none"; }); }; renderMarkup() { super.renderMarkup(); const baseTitle = this.findNode("base-node-title"); if (baseTitle) renderTitle(baseTitle, this.model); return this; } validateNumber(endInput = false) { return (evt) => { const fieldEl = evt.target; const value = fieldEl.value; if (endInput) { if (value) { const parsedValue = Number(fieldEl.value); if (isNaN(parsedValue)) fieldEl.style.borderColor = "red"; else { const style = this.model.get("style") || {}; const boxStyle = style.ports?.input?.editor?.box || {}; fieldEl.style.borderColor = boxStyle.borderColor || ""; } } } else if (value) { const filtered = value.replace(/[^\d.-]/g, ""); if (filtered !== value) fieldEl.value = filtered; } }; } updateFieldValue(type) { return (evt) => { const fieldEl = evt.target; const values = this.model.get("values") || {}; const portName = fieldEl.getAttribute("name"); if (!portName) return; let value = fieldEl.value; if (type === "number") value = fieldEl.value ? Number(fieldEl.value) : NaN; else if (type === "boolean") value = !values[portName]; fieldEl.title = String(value); this.setInputValue(portName, value); }; } setInputValue(inputName, value) { this.model.set("values", { ...this.model.get("values"), [inputName]: value }); this.model.graph.trigger("node:values:changed", this, inputName, value); } applyPortHighlights() { requestAnimationFrame(() => { setTimeout(() => { this._applyPortHighlights(); }, 0); }); } _applyPortHighlights() { const ports = this.model.getPorts(); const portIds = ports.map((port) => port.id); const toRemove = []; for (const portId of this.highlightedPorts) { if (!portIds.includes(portId)) { toRemove.push(portId); continue; } this.highlightPort(portId); } for (const portId of toRemove) { this.highlightedPorts.delete(portId); this.unhighlightPort(portId); } } refreshTitle() { const baseTitle = this.findNode("base-node-title"); if (baseTitle) { const title = this.model.get("title") || ""; const text = baseTitle.querySelector("text"); if (text) text.textContent = title; } } refreshDomValue(name, step = 0) { const values = this.model.get("values") || {}; const inputs = this.model.get("inputs") || []; const input = inputs.find((i) => i.name === name); if (!input) return; const inputEl = this.findPortNode(input.id, "field"); if (step < 10) requestAnimationFrame(() => { setTimeout(() => { this.refreshDomValue(name, step + 1); }, 0); }); if (!inputEl) return; let value = values[input.name]; switch (input.type) { case "string": if (typeof value !== "string") return; break; case "number": if (typeof value !== "number") return; value = isNaN(value) ? inputEl.value : String(value); break; case "boolean": if (inputEl instanceof SVGElement) { const polyline = inputEl.parentNode?.children[1]; if (!polyline) return; polyline.setAttribute("visibility", value ? "visible" : "hidden"); return; } return; case "select": if (typeof value !== "string") return; value = input._options?.find((option) => option.value === value)?.label || ""; break; } if (inputEl.value !== value) { inputEl.value = value; inputEl.title = String(value); } } highlightPort(portId, retry = 0) { if (!this.highlightedPorts.has(portId)) this.highlightedPorts.add(portId); const portNode = this.findPortNode(portId, "portBody"); if (portNode) { const style = this.model.get("style") || {}; const portStyle = (portId.startsWith("in-") ? style.ports?.input : style.ports?.output) || {}; const fill = portStyle.port?.highlightFill || "rgba(255, 255, 255, 0.8)"; const stroke = portStyle.port?.highlightStroke || "rgba(255, 255, 255, 1)"; const strokeWidth = portStyle.port?.highlightStrokeWidth ?? 1; portNode.setAttribute("fill", fill); portNode.setAttribute("stroke", stroke); portNode.setAttribute("stroke-width", String(strokeWidth)); } else if (retry < 10) requestAnimationFrame(() => { setTimeout(() => { this.highlightPort(portId, retry + 1); }, 0); }); } unhighlightPort(portId) { const portNode = this.findPortNode(portId, "portBody"); if (portNode) { const style = this.model.get("style") || {}; const portStyle = (portId.startsWith("in-") ? style.ports?.input : style.ports?.output) || {}; const fill = portStyle.port?.fill || "rgba(255, 255, 255, 0.5)"; const stroke = portStyle.port?.stroke || "rgba(255, 255, 255, 0.8)"; const strokeWidth = portStyle.port?.strokeWidth ?? 1; portNode.setAttribute("fill", fill); portNode.setAttribute("stroke", stroke); portNode.setAttribute("stroke-width", String(strokeWidth)); } } }; function renderTitle(g, model) { const title = model.get("title") || ""; const size = model.size(); const attrs = model.get("attrs") || {}; const rx = attrs["base-node-body"]?.rx ?? 4; const ry = attrs["base-node-body"]?.ry ?? 4; const style = model.get("style") || {}; const titleHeight = style.header?.height ?? 24; const titleFontSize = style.header?.fontSize ?? 12; const titleTop = style.header?.title?.y ?? 0; const titleLeft = style.header?.title?.x ?? 0; const textAlign = style.header?.textAlign ?? "left"; const titlePath = V_default("path", { d: ` M0,${titleHeight} L0,${ry} Q0,0 ${rx},0 L${size.width - rx},0 Q${size.width},0 ${size.width},${ry} L${size.width},${titleHeight} Z `, fill: style.header?.background ?? "#373C44" }).node; const textAnchor = textAlign === "left" ? "start" : textAlign === "right" ? "end" : "middle"; let startX = 0; switch (textAlign) { case "left": startX = 10; break; case "right": startX = size.width - 10; break; case "center": startX = size.width / 2; break; } const text = V_default("text", { x: startX + titleLeft, y: titleHeight / 2 + titleFontSize / 2 - 3 + titleTop, fill: "#fff", fontSize: titleFontSize, textAnchor }).node; text.textContent = title; g.appendChild(titlePath); g.appendChild(text); } //#endregion //#region src/adapters/joint/ScrollerController.ts var ScrollerController = class ScrollerController extends Listener { _viewport = { x: 0, y: 0, scale: 1 }; _isDragging = false; _lastClientX = 0; _lastClientY = 0; _spacePressed = false; static ZOOM_SENSITIVITY = .005; get isDragging() { return this._isDragging; } get viewport() { return this._viewport; } get scale() { return this._viewport.scale; } set scale(value) { this._viewport.scale = value; this.updatePaperTransform(); } get position() { return { x: this._viewport.x, y: this._viewport.y }; } set position(value) { this._viewport.x = value.x; this._viewport.y = value.y; this.updatePaperTransform(); } startListening() { const [{ paper }] = this.callbackArguments; paper.el.addEventListener("keydown", this.onKeyDown); paper.el.addEventListener("keyup", this.onKeyUp); paper.el.addEventListener("selectstart", this.onSelectStart); this.initPaperEvents(); } initPaperEvents() { const [{ paper }] = this.callbackArguments; const el = paper.el; el.addEventListener("mousedown", this.onMouseDown); el.addEventListener("mousemove", this.onMouseMove); el.addEventListener("mouseup", this.onMouseUp); paper.el.addEventListener("selectstart", this.onSelectStart); el.addEventListener("wheel", this.onMouseWheel, { passive: true }); } onKeyDown = (e) => { const [{ paper }] = this.callbackArguments; if (e.target !== paper.el) return; if (e.code === "Space") { this._spacePressed = true; paper.el.style.cursor = "grab"; } }; onKeyUp = (e) => { const [{ paper }] = this.callbackArguments; if (e.code === "Space") { this._spacePressed = false; paper.el.style.cursor = ""; } }; onMouseDown = (e) => { const [{ paper }] = this.callbackArguments; const el = paper.el; if (e.button === 0 && this._spacePressed || e.button === 2 && !this._spacePressed) { e.stopPropagation(); e.preventDefault(); this._isDragging = true; this._lastClientX = e.clientX; this._lastClientY = e.clientY; el.style.cursor = "grabbing"; } }; onMouseMove = (e) => { const [{ paper, graph }] = this.callbackArguments; if (!this.isDragging) return; const dx = e.clientX - this._lastClientX; const dy = e.clientY - this._lastClientY; this._viewport.x += dx; this._viewport.y += dy; paper.translate(this._viewport.x, this._viewport.y); graph.emit("viewport:change", this._viewport); this._lastClientX = e.clientX; this._lastClientY = e.clientY; }; onMouseUp = () => { const [{ paper }] = this.callbackArguments; if (this.isDragging) { this._isDragging = false; if (this._spacePressed) paper.el.style.cursor = "grab"; else paper.el.style.cursor = ""; } }; onMouseWheel = (e) => { const [{ paper, graph }] = this.callbackArguments; if (this.isDragging) return; const rect = paper.el.getBoundingClientRect(); const point = { x: e.clientX - rect.left, y: e.clientY - rect.top }; const localPoint = paper.clientToLocalPoint(point); this.viewport.scale += e.deltaY * -ScrollerController.ZOOM_SENSITIVITY; this.viewport.scale = Math.min(Math.max(.2, this.viewport.scale), 5); paper.scale(this.viewport.scale, this.viewport.scale); const newLocalPoint = paper.clientToLocalPoint(point); this.viewport.x += (newLocalPoint.x - localPoint.x) * this.viewport.scale; this.viewport.y += (newLocalPoint.y - localPoint.y) * this.viewport.scale; paper.translate(this.viewport.x, this.viewport.y); paper.setGridSize(10 / this.viewport.scale); graph.emit("viewport:change", this._viewport); }; updatePaperTransform() { const [{ paper }] = this.callbackArguments; paper.scale(this.viewport.scale, this.viewport.scale); paper.translate(this.viewport.x, this.viewport.y); paper.setGridSize(10 / this.viewport.scale); } onSelectStart = (e) => { if (this.isDragging) e.preventDefault(); }; stopListening() { super.stopListening(); const [{ paper }] = this.callbackArguments; const el = paper.el; el.removeEventListener("mousedown", this.onMouseDown); el.removeEventListener("mousemove", this.onMouseMove); el.removeEventListener("mouseup", this.onMouseUp); paper.el.removeEventListener("selectstart", this.onSelectStart); el.removeEventListener("wheel", this.onMouseWheel); paper.el.removeEventListener("keydown", this.onKeyDown); paper.el.removeEventListener("keyup", this.onKeyUp); } }; //#endregion //#region src/adapters/joint/SelectionController.ts var SelectionController = class SelectionController extends Listener { rect; startPoint; pointerDownTime = 0; hasMovedEnough = false; static MIN_MOVE_DISTANCE = 3; static MIN_PRESS_DURATION = 100; startListening() { const [{ paper }] = this.callbackArguments; this.listenTo(paper, "blank:pointerdown", this.onPointerDown, this); this.listenTo(paper, "blank:pointermove", this.onPointerMove, this); this.listenTo(paper, "blank:pointerup", this.onPointerUp, this); } onPointerDown({ paper }, evt) { const clientX = evt.clientX; const clientY = evt.clientY; const localPoint = paper.clientToLocalPoint(clientX, clientY); this.startPoint = paper.localToPagePoint(localPoint.x, localPoint.y); this.pointerDownTime = Date.now(); this.hasMovedEnough = false; } onPointerMove({ paper, className }, evt) { if (!this.startPoint) return; const clientX = evt.clientX; const clientY = evt.clientY; const localPoint = paper.clientToLocalPoint(clientX, clientY); const currentPoint = paper.localToPagePoint(localPoint.x, localPoint.y); const dx = Math.abs(currentPoint.x - this.startPoint.x); const dy = Math.abs(currentPoint.y - this.startPoint.y); const moved = dx > SelectionController.MIN_MOVE_DISTANCE || dy > SelectionController.MIN_MOVE_DISTANCE; if (!this.hasMovedEnough && moved) { const duration = Date.now() - (this.pointerDownTime ?? 0); if (duration > SelectionController.MIN_PRESS_DURATION) { this.rect = V_default("rect", { x: this.startPoint.x, y: this.startPoint.y, width: 1, height: 1, class: className ?? "", stroke: "#3498db", "stroke-width": 1, fill: "rgba(52,152,219,0.15)" }).node; paper.svg.appendChild(this.rect); this.hasMovedEnough = true; } } if (this.rect && this.hasMovedEnough) { const x1 = Math.min(this.startPoint.x, currentPoint.x); const y1 = Math.min(this.startPoint.y, currentPoint.y); const x2 = Math.max(this.startPoint.x, currentPoint.x); const y2 = Math.max(this.startPoint.y, currentPoint.y); V_default(this.rect).attr({ x: x1, y: y1, width: x2 - x1, height: y2 - y1 }); } } onPointerUp({ paper, onSelected }, evt) { if (!this.rect || !this.startPoint) return; const clientX = evt.clientX; const clientY = evt.clientY; const currentPoint = paper.clientToLocalPoint(clientX, clientY); const startPoint = paper.pageToLocalPoint(this.startPoint.x, this.startPoint.y); const x1 = Math.min(startPoint.x, currentPoint.x); const y1 = Math.min(startPoint.y, currentPoint.y); const x2 = Math.max(startPoint.x, currentPoint.x); const y2 = Math.max(startPoint.y, currentPoint.y); const bbox = new Rect(x1, y1, x2 - x1, y2 - y1); const elements = paper.model.getElements(); const links = paper.model.getLinks(); const selected = elements.filter((el) => bbox.intersect(el.getBBox())); const selectedLinks = links.filter((link) => { const linkView = paper.findViewByModel(link); if (!linkView) return false; const path = linkView.findNode("line"); if (!path) return false; return pathIntersectsRect(path, bbox); }); onSelected([...selected, ...selectedLinks]); this.rect.remove(); this.rect = void 0; this.startPoint = void 0; } }; function pathIntersectsRect(path, rect) { const length = path.getTotalLength(); const step = 5; for (let i = 0; i <= length; i += step) { const pt = path.getPointAtLength(i); if (pt.x >= rect.x && pt.x <= rect.x + rect.width && pt.y >= rect.y && pt.y <= rect.y + rect.height) return true; } return false; } //#endregion //#region src/adapters/joint/GroupEffectController.ts var GroupEffectController = class extends Listener { rect; cells = []; nodes = []; dragStartPoint; dragStartClientPoint; cellsStartPositions = /* @__PURE__ */ new Map(); rectStartPos; moveNodes = []; startListening() { const [{ paper }] = this.callbackArguments; this.listenTo(paper, "element:pointerdown", this.onElementPointerDown, this); this.listenTo(paper, "element:pointerup", this.onElementPointerUp, this); this.listenTo(paper, "element:pointermove", this.onElementPointerMove, this); this.listenTo(paper, "scale", this.onScale, this); this.listenTo(paper, "translate", this.onTranslate, this); } onScale() { if (this.cells.length <= 1) return; this.clearGroupEffect(); this.drawGroupEffect(); } onTranslate() { if (this.cells.length <= 1) return; this.clearGroupEffect(); this.drawGroupEffect(); } onElementPointerDown({ paper, adapter }, elementView) { if (!elementView.model.isElement()) return; this.dragStartPoint = { x: elementView.model.position().x, y: elementView.model.position().y }; this.moveNodes = [elementView.model]; this.rectStartPos = void 0; if (this.nodes.includes(elementView.model)) { this.moveNodes = this.nodes; this.rectStartPos = { x: this.rect ? parseFloat(this.rect.getAttribute("x") || "0") : 0, y: this.rect ? parseFloat(this.rect.getAttribute("y") || "0") : 0 }; this.dragStartClientPoint = paper.localToClientPoint(this.dragStartPoint.x, this.dragStartPoint.y); } this.moveNodes.forEach((cell) => { if (!cell.isElement()) return; this.cellsStartPositions.set(cell.id, cell.position()); const node = adapter.cellsMap.get(cell.id)?.cell; if (!node || !("position" in node)) return; adapter.graph.emit("node:dragstart", node); }); } onElementPointerMove({ adapter, paper }, elementView) { if (!this.moveNodes.length) return; if (!this.dragStartPoint) return; const pos = elementView.model.position(); const clientPos = paper.localToClientPoint(pos.x, pos.y); const clientOffsetX = clientPos.x - (this.dragStartClientPoint?.x ?? 0); const clientOffsetY = clientPos.y - (this.dragStartClientPoint?.y ?? 0); const offsetX = pos.x - (this.dragStartPoint?.x ?? 0); const offsetY = pos.y - (this.dragStartPoint?.y ?? 0); this.moveNodes.forEach((cell) => { const node = adapter.cellsMap.get(cell.id)?.cell; if (cell.id !== elementView.model.id) { const startPos = this.cellsStartPositions.get(cell.id); cell.position(startPos.x + offsetX, startPos.y + offsetY); } if (node && "position" in node) { const bbox = adapter.getElementBBox(elementView); node._bbox = bbox; adapter.graph.emit("node:dragmove", node); } }); if (this.rect && this.rectStartPos) { this.rect.setAttribute("x", String(this.rectStartPos.x + clientOffsetX)); this.rect.setAttribute("y", String(this.rectStartPos.y + clientOffsetY)); } } onElementPointerUp({ adapter, paper }, elementView) { if (!this.moveNodes.length) return; const dragStart = this.dragStartPoint; this.dragStartPoint = void 0; if (!dragStart) return; const dragEnd = elementView.model.position(); const offsetX = dragEnd.x - (dragStart?.x ?? 0); const offsetY = dragEnd.y - (dragStart?.y ?? 0); const clientPos = paper.localToClientPoint(dragEnd.x, dragEnd.y); const clientOffsetX = clientPos.x - (this.dragStartClientPoint?.x ?? 0); const clientOffsetY = clientPos.y - (this.dragStartClientPoint?.y ?? 0); if (dragEnd.x !== dragStart.x || dragEnd.y !== dragStart.y) { adapter.graph.startTransaction(); this.moveNodes.forEach((node) => { const startPos = this.cellsStartPositions.get(node.id); node.position(startPos.x + offsetX, startPos.y + offsetY); const instance = adapter.cellsMap.get(node.id)?.cell; if (instance && "position" in instance) { instance.position = { x: startPos.x + offsetX, y: startPos.y + offsetY }; const bbox = adapter.getElementBBox(elementView); instance._bbox = bbox; adapter.graph.emit("node:dragend", instance); } }); adapter.graph.commitTransaction(); if (this.rect && this.rectStartPos) { this.rect.setAttribute("x", String(this.rectStartPos.x + clientOffsetX)); this.rect.setAttribute("y", String(this.rectStartPos.y + clientOffsetY)); } this.rectStartPos = void 0; this.moveNodes = []; this.cellsStartPositions.clear(); } } groupCells(cells) { this.cells = cells; this.nodes = cells.filter((c) => c.isElement()); this.updateGroupEffect(); this.updateCellsHighlight(); } ungroupCells(cells) { this.cells = this.cells.filter((c) => !cells.includes(c)); this.nodes = this.cells.filter((c) => c.isElement()); this.updateGroupEffect(); this.updateCellsHighlight(); } clearGroups() { this.cells = []; this.nodes = []; this.updateGroupEffect(); this.updateCellsHighlight(); } updateCellsHighlight() { const [{ adapter }] = this.callbackArguments; adapter.joint.clearHighlights(); adapter.joint.highlightCells(this.cells); } drawGroupEffect() { const bbox = this.calculateBoundingBox(); const [{ paper }] = this.callbackArguments; if (!this.rect) { this.rect = V_default("rect", { x: 0, y: 0, width: 100, height: 100, class: "selection-effect", stroke: "#3c3ce7ff", "stroke-width": 2, fill: "rgba(60, 60, 60, 0.15)", rx: 4, ry: 4, "pointer-events": "none" }).node; paper.svg.appendChild(this.rect); } this.rect.setAttribute("x", String(bbox.x - 10)); this.rect.setAttribute("y", String(bbox.y - 10)); this.rect.setAttribute("width", String(bbox.width + 20)); this.rect.setAttribute("height", String(bbox.height + 20)); } updateGroupEffect() { if (this.nodes.length > 1) this.drawGroupEffect(); else this.clearGroupEffect(); } clearGroupEffect() { if (this.rect) { this.rect.remove(); this.rect = void 0; } } calculateBoundingBox() { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; const [{ paper }] = this.callbackArguments; this.nodes.forEach((node) => { const bbox = paper.localToPaperRect(node.getBBox()); minX = Math.min(minX, bbox.x); minY = Math.min(minY, bbox.y); maxX = Math.max(maxX, bbox.x + bbox.width); maxY = Math.max(maxY, bbox.y + bbox.height); }); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } }; //#endregion //#region src/adapters/joint/JointGraph.ts config.classNamePrefix = "bpgraph-"; const custom = { BlueprintNode, BlueprintNodeView }; const namespace = { custom, standard: standard_exports }; var JointGraph = class { graph; paper; scrollerController; selectionController; groupEffectController; adapter; constructor(adapter, options) { this.adapter = adapter; this.graph = new Graph({}); this.paper = new Paper({ model: this.graph, cellViewNamespace: namespace, frozen: false, async: true, clickThreshold: 5, snapLinks: { radius: 20 }, linkPinning: false, preventDefaultViewAction: false, preventDefaultBlankAction: false, sorting: Paper.sorting.APPROX, interactive: function(cellView) { return cellView.model.isElement(); }, defaultAnchor: (view, magnet, ...rest) => { const group = view.findAttribute("port-group", magnet); const anchorFn = group === "in" ? left : right; return anchorFn(view, magnet, ...rest); }, defaultConnectionPoint: { name: "anchor" }, defaultConnector: (sourcePoint, targetPoint, routePoints, opt, linkView) => { const link = linkView.model; const sourcePortId = link.get("source").port; const isSourceIn = sourcePortId?.startsWith("in-"); const isSourceOut = sourcePortId?.startsWith("out-"); if (isSourceIn) { opt.sourceDirection = curve.TangentDirections.LEFT; opt.targetDirection = curve.TangentDirections.RIGHT; } else if (isSourceOut) { opt.sourceDirection = curve.TangentDirections.RIGHT; opt.targetDirection = curve.TangentDirections.LEFT; } return curve(sourcePoint, targetPoint, routePoints, opt, linkView); }, validateMagnet: () => { this.adapter.graph.emit("start:connecting"); return true; }, defaultLink: () => new Link({ attrs: { line: { strokeWidth: this.adapter.graph.nodeRegistry.linkStyle.strokeWidth || 1, stroke: this.adapter.graph.nodeRegistry.linkStyle.stroke || "rgba(255,255,255,0.5)", targetMarker: { type: "none" } } } }), validateConnection: (sourceView, sourceMagnet, targetView, targetMagnet) => { if (sourceView === targetView) return false; const target = targetView.model; if (target.isLink()) return false; const sourceGroup = sourceView.findAttribute("port-group", sourceMagnet); const targetGroup = targetView.findAttribute("port-group", targetMagnet); if (sourceGroup === "in" && targetGroup !== "out") return false; if (sourceGroup === "out" && targetGroup !== "in") return false; if (sourceView && sourceMagnet && targetMagnet && targetView) { const sourcePortId = sourceView.findAttribute("port", sourceMagnet) || ""; const targetPortId = targetView.findAttribute("port", targetMagnet) || ""; const sourcePort = sourcePortId.startsWith("out-") ? sourceView.model.get("outputs")?.find((port) => port.id === sourcePortId) : sourceView.model.get("inputs")?.find((port) => port.id === sourcePortId); const targetPort = targetPortId.startsWith("in-") ? targetView.model.get("inputs")?.find((port) => port.id === targetPortId) : targetView.model.get("outputs")?.find((port) => port.id === targetPortId); const sourceNode = this.adapter.findNode(sourceView.model.id); const targetNode = this.adapter.findNode(targetView.model.id); if (sourcePort && targetPort) return this.adapter.graph.validateConnection(sourceNode, sourcePort, targetNode, targetPort); } return true; }, ...options }); this.initialize(); } initialize() { this.scrollerController = new ScrollerController({ paper: this.paper, graph: this.adapter.graph }); this.selectionController = new SelectionController({ paper: this.paper, onSelected: (cells) => { this.paper.trigger("selection:changed", cells); } }); this.groupEffectController = new GroupEffectController({ adapter: this.adapter, paper: this.paper, className: "selection-rectangle" }); this.initializeControllers(); this.paper.on("link:connect", (linkView) => { const source = linkView.model.get("source"); const target = linkView.model.get("target"); const sourceNodeView = this.paper.findViewByModel(source.id); const targetNodeView = this.paper.findViewByModel(target.id); const sourcePortId = source.port; const targetPortId = target.port; if (sourceNodeView && sourcePortId && targetNodeView && targetPortId) { sourceNodeView.highlightPort(sourcePortId); targetNodeView.highlightPort(targetPortId); } }); this.graph.on("remove", (cell) => { if (cell.isLink()) { const link = cell; const source = link.get("source"); const target = link.get("target"); const sourceNodeView = this.paper.findViewByModel(source.id); const targetNodeView = this.paper.findViewByModel(target.id); const sourcePortId = source.port; const targetPortId = target.port; if (!sourcePortId || !targetPortId) return; const sourceLinks = this.graph.getConnectedLinks(sourceNodeView.model, { inbound: true, outbound: true }); const targetLinks = this.graph.getConnectedLinks(targetNodeView.model, { inbound: true, outbound: true }); const stillConnectedFromSource = sourceLinks.some((l) => l.get("source").port === sourcePortId || l.get("target").port === sourcePortId); const stillConnectedFromTarget = targetLinks.some((l) => l.get("source").port === targetPortId || l.get("target").port === targetPortId); if (sourceNodeView && sourcePortId && !stillConnectedFromSource) sourceNodeView.unhighlightPort(sourcePortId); if (targetNodeView && targetPortId && !stillConnectedFromTarget) targetNodeView.unhighlightPort(targetPortId); } }); } initializeControllers() { if (this.paper.el) { this.scrollerController.stopListening(); this.selectionController.stopListening(); this.groupEffectController.stopListening(); this.scrollerController.startListening(); this.selectionController.startListening(); this.groupEffectController.startListening(); this.paper.el.removeEventListener("keydown", this.onKeyDown); this.paper.el.removeEventListener("keyup", this.onKeyUp); this.paper.el.addEventListener("keydown", this.onKeyDown); this.paper.el.addEventListener("keyup", this.onKeyUp); } } addNode(instance, style) { const inputs = instance.inputs; con