/** * drag-canvas.js — 拖拽编程画布核心(bricks 前端) * * 功能:块面板 → 拖入画布 / 点击添加 / 移动 / 连线 / 删除 / 选中参数面板 / 保存 / 编译 / 校验 * 数据结构与后端 drag 模块契约一致: * block = {id, type, x, y, params: {}} * conn = {from, fromPort, to, toPort} * * 依赖 bricks 环境(bricks_fetch / bricks_toast / eventbus)由宿主注入。 */ (function (global) { 'use strict'; var SVG_NS = 'http://www.w3.org/2000/svg'; var blockSeq = 1000; function uid(prefix) { blockSeq += 1; return (prefix || 'b') + '_' + Date.now().toString(36) + '_' + blockSeq; } /** * DragCanvas 构造:挂载到 container(必须含 .drag-stage 画布区) */ function DragCanvas(container, opts) { opts = opts || {}; this.container = container; this.blocks = opts.blocks || []; this.connections = opts.connections || []; this.blockDefs = opts.blockDefs || {categories: [], blocks: {}}; this.api = opts.api || {}; this.onChange = opts.onChange || null; this.selectedId = null; this.dragMode = null; // 'block' | 'conn' this.dragging = null; // {id, startX, startY, origX, origY} this.connStart = null; // {blockId, port, portType} this._binds = []; this._init(); } DragCanvas.prototype._init = function () { var self = this; var stage = this.container.querySelector('.drag-stage'); if (!stage) { return; } this.stage = stage; // 连线层(SVG 放在画布区底层) var svg = document.createElementNS(SVG_NS, 'svg'); svg.setAttribute('class', 'drag-lines'); svg.style.position = 'absolute'; svg.style.left = '0'; svg.style.top = '0'; svg.style.width = '100%'; svg.style.height = '100%'; svg.style.pointerEvents = 'none'; svg.style.zIndex = '1'; stage.appendChild(svg); this.svg = svg; // 画布区鼠标事件(连线 + 画布空白点击取消选中) stage.addEventListener('mousedown', function (e) { if (e.target === stage || e.target === svg) { self.selectBlock(null); } }); stage.addEventListener('mousemove', function (e) { self._onMouseMove(e); }); stage.addEventListener('mouseup', function (e) { self._onMouseUp(e); }); document.addEventListener('mouseup', function () { self._endConn(); }); this.render(); this._emitChange(); }; /* ─────────────── 块渲染 ─────────────── */ DragCanvas.prototype._blockDef = function (type) { return (this.blockDefs.blocks || {})[type] || null; }; DragCanvas.prototype.render = function () { var self = this; // 清空既有块 DOM(连线层保留) var olds = this.container.querySelectorAll('.drag-block'); for (var i = 0; i < olds.length; i++) { olds[i].parentNode.removeChild(olds[i]); } while (this.svg.lastChild) { this.svg.removeChild(this.svg.lastChild); } this.blocks.forEach(function (b) { var def = self._blockDef(b.type); if (!def) { return; } var el = document.createElement('div'); el.className = 'drag-block' + (b.id === self.selectedId ? ' selected' : ''); el.dataset.id = b.id; el.style.left = (b.x || 0) + 'px'; el.style.top = (b.y || 0) + 'px'; var cat = def.category || ''; var color = (self.blockDefs.categories || []).filter(function (c) { return c.key === cat; })[0] || {}; el.style.borderLeftColor = color.color || def.color || '#666'; var head = document.createElement('div'); head.className = 'drag-block-head'; head.innerHTML = ' ' + def.label; var body = document.createElement('div'); body.className = 'drag-block-body'; var params = b.params || {}; (def.params || []).forEach(function (pd) { if (pd.uitype === 'entity' || pd.uitype === 'select' || pd.uitype === 'boolean') { var opt = (pd.options || []).filter(function (o) { return String(o.value) === String(params[pd.name]); })[0]; body.innerHTML += '
' + pd.label + '' + (opt ? opt.text : (params[pd.name] !== undefined ? params[pd.name] : '')) + '
'; } else if (params[pd.name] !== undefined && String(params[pd.name]) !== '') { body.innerHTML += '
' + pd.label + '' + params[pd.name] + '
'; } }); var ports = document.createElement('div'); ports.className = 'drag-ports'; var inp = document.createElement('div'); inp.className = 'drag-port drag-port-in'; inp.dataset.block = b.id; inp.dataset.port = 'in'; inp.title = '输入'; var outWrap = document.createElement('div'); outWrap.className = 'drag-out-ports'; // 输出端口(含动态端口) var outs = def.ports.outputs.map(function (p) { return p.name; }); var dyn = this._dynamicOutputs(def, params); outs = outs.concat(dyn); outs.forEach(function (pn) { var o = document.createElement('div'); o.className = 'drag-port drag-port-out'; o.dataset.block = b.id; o.dataset.port = pn; o.title = pn; outWrap.appendChild(o); }); ports.appendChild(inp); ports.appendChild(outWrap); el.appendChild(head); el.appendChild(body); el.appendChild(ports); // 块交互 el.addEventListener('mousedown', function (e) { if (e.target.classList.contains('drag-port')) { return; } self.selectBlock(b.id); self.dragMode = 'block'; self.dragging = {id: b.id, startX: e.clientX, startY: e.clientY, origX: b.x || 0, origY: b.y || 0}; e.preventDefault(); e.stopPropagation(); }); el.addEventListener('dblclick', function () { if (self.onEditBlock) { self.onEditBlock(b); } }); // 端口连线起点 var pm = el.querySelectorAll('.drag-port-out'); for (var k = 0; k < pm.length; k++) { pm[k].addEventListener('mousedown', function (e) { e.stopPropagation(); e.preventDefault(); self._startConn(e.currentTarget.dataset.block, e.currentTarget.dataset.port, 'out'); }); } var pi = el.querySelectorAll('.drag-port-in'); for (var j = 0; j < pi.length; j++) { pi[j].addEventListener('mousedown', function (e) { e.stopPropagation(); e.preventDefault(); self._startConn(e.currentTarget.dataset.block, e.currentTarget.dataset.port, 'in'); }); } stage.appendChild(el); }, this); this.renderLines(); }; DragCanvas.prototype._dynamicOutputs = function (def, params) { var d = def.dynamic_outputs; if (!d) { return []; } var n = parseInt(params[d.param], 10); if (isNaN(n) || n < 2) { n = 2; } if (n > 8) { n = 8; } var out = []; for (var i = 1; i <= n; i++) { out.push(d.prefix + i); } return out; }; /* ─────────────── 连线渲染(SVG 贝塞尔) ─────────────── */ DragCanvas.prototype._portEl = function (blockId, port, ptype) { var blocks = this.container.querySelectorAll('.drag-block[data-id="' + blockId + '"]'); if (!blocks.length) { return null; } var el = blocks[0]; var q = ptype === 'out' ? el.querySelectorAll('.drag-port-out[data-port="' + port + '"]') : el.querySelectorAll('.drag-port-in[data-port="' + port + '"]'); return q.length ? q[0] : null; }; DragCanvas.prototype._portPos = function (blockId, port, ptype) { var el = this._portEl(blockId, port, ptype); if (!el) { return null; } var r = el.getBoundingClientRect(); var sr = this.stage.getBoundingClientRect(); return {x: r.left - sr.left + r.width / 2, y: r.top - sr.top + r.height / 2}; }; DragCanvas.prototype._makePath = function (a, b) { if (!a || !b) { return ''; } var dx = Math.max(20, Math.abs(b.x - a.x) / 2); return 'M ' + a.x + ' ' + a.y + ' C ' + (a.x + dx) + ' ' + a.y + ', ' + (b.x - dx) + ' ' + b.y + ', ' + b.x + ' ' + b.y; }; DragCanvas.prototype.renderLines = function () { var self = this; while (this.svg.lastChild) { this.svg.removeChild(this.svg.lastChild); } this.connections.forEach(function (c) { var a = self._portPos(c.from, c.fromPort, 'out'); var b = self._portPos(c.to, c.toPort, 'in'); if (!a || !b) { return; } var path = document.createElementNS(SVG_NS, 'path'); path.setAttribute('d', self._makePath(a, b)); path.setAttribute('stroke', '#7c8aa0'); path.setAttribute('stroke-width', '2'); path.setAttribute('fill', 'none'); self.svg.appendChild(path); }); // 连线中临时线 if (this.connStart && this.connMouse) { var tp = document.createElementNS(SVG_NS, 'path'); tp.setAttribute('d', this._makePath(this.connStart.pos, this.connMouse)); tp.setAttribute('stroke', '#4a90d9'); tp.setAttribute('stroke-width', '2'); tp.setAttribute('stroke-dasharray', '6,3'); tp.setAttribute('fill', 'none'); this.svg.appendChild(tp); } }; /* ─────────────── 交互:移动 / 连线 / 选中 ─────────────── */ DragCanvas.prototype._onMouseMove = function (e) { if (this.dragMode === 'block' && this.dragging) { var d = this.dragging; var b = this._block(d.id); if (b) { b.x = Math.max(0, Math.round(d.origX + (e.clientX - d.startX))); b.y = Math.max(0, Math.round(d.origY + (e.clientY - d.startY))); } var el = this.container.querySelector('.drag-block[data-id="' + d.id + '"]'); if (el) { el.style.left = (b ? b.x : 0) + 'px'; el.style.top = (b ? b.y : 0) + 'px'; } this.renderLines(); return; } if (this.connStart) { var sr = this.stage.getBoundingClientRect(); this.connMouse = {x: e.clientX - sr.left, y: e.clientY - sr.top}; this.renderLines(); } }; DragCanvas.prototype._onMouseUp = function (e) { if (this.dragMode === 'block' && this.dragging) { this.dragMode = null; this.dragging = null; this._emitChange(); return; } if (this.connStart) { var t = e.target; if (t && t.classList && t.classList.contains('drag-port')) { var targetType = t.classList.contains('drag-port-in') ? 'in' : 'out'; this._finishConn(t.dataset.block, t.dataset.port, targetType); } this._endConn(); } }; DragCanvas.prototype._startConn = function (blockId, port, ptype) { var pos = this._portPos(blockId, port, ptype); if (!pos) { return; } this.connStart = {blockId: blockId, port: port, ptype: ptype, pos: pos}; this.connMouse = pos; }; DragCanvas.prototype._finishConn = function (toBlock, toPort, toType) { if (!this.connStart) { return; } var s = this.connStart; var from, fromPort, to, toPort; if (s.ptype === 'out' && toType === 'in') { from = s.blockId; fromPort = s.port; to = toBlock; toPort = toPort; } else if (s.ptype === 'in' && toType === 'out') { from = toBlock; fromPort = toPort; to = s.blockId; toPort = s.port; } else { return; // 同向端口不允许连线 } if (from === to) { return; } // 自环拒绝 var key = from + ':' + fromPort + '->' + to + ':' + toPort; var dup = this.connections.some(function (c) { return c.from === from && c.fromPort === fromPort && c.to === to && c.toPort === toPort; }); if (dup) { return; } this.connections.push({from: from, fromPort: fromPort, to: to, toPort: toPort}); this.renderLines(); this._emitChange(); }; DragCanvas.prototype._endConn = function () { this.connStart = null; this.connMouse = null; this.renderLines(); }; DragCanvas.prototype._block = function (id) { for (var i = 0; i < this.blocks.length; i++) { if (this.blocks[i].id === id) { return this.blocks[i]; } } return null; }; DragCanvas.prototype.selectBlock = function (id) { this.selectedId = id; var els = this.container.querySelectorAll('.drag-block'); for (var i = 0; i < els.length; i++) { els[i].classList.toggle('selected', els[i].dataset.id === id); } if (this.onSelect) { this.onSelect(id ? this._block(id) : null); } }; /* ─────────────── 对外操作:加块 / 删块 / 删线 / 取图 ─────────────── */ DragCanvas.prototype.addBlock = function (type, x, y) { var def = this._blockDef(type); if (!def) { return null; } var params = {}; (def.params || []).forEach(function (p) { if (p.default !== undefined) { params[p.name] = p.default; } }); var b = {id: uid('b'), type: type, x: x != null ? x : 60 + this.blocks.length * 30, y: y != null ? y : 60, params: params}; this.blocks.push(b); this.render(); this.selectBlock(b.id); this._emitChange(); return b; }; DragCanvas.prototype.removeBlock = function (id) { this.blocks = this.blocks.filter(function (b) { return b.id !== id; }); this.connections = this.connections.filter(function (c) { return c.from !== id && c.to !== id; }); if (this.selectedId === id) { this.selectedId = null; } this.render(); this._emitChange(); }; DragCanvas.prototype.removeConnection = function (from, fromPort, to, toPort) { this.connections = this.connections.filter(function (c) { return !(c.from === from && c.fromPort === fromPort && c.to === to && c.toPort === toPort); }); this.renderLines(); this._emitChange(); }; DragCanvas.prototype.clear = function () { this.blocks = []; this.connections = []; this.selectedId = null; this.render(); this._emitChange(); }; DragCanvas.prototype.setBlockParams = function (id, params) { var b = this._block(id); if (!b) { return; } b.params = params || {}; this.render(); this._emitChange(); }; DragCanvas.prototype.getGraph = function () { return {blocks: this.blocks, connections: this.connections}; }; DragCanvas.prototype.setGraph = function (graph) { this.blocks = (graph && graph.blocks) || []; this.connections = (graph && graph.connections) || []; this.selectedId = null; this.render(); this._emitChange(); }; DragCanvas.prototype._emitChange = function () { if (this.onChange) { this.onChange(this.getGraph()); } }; global.DragCanvas = DragCanvas; })(window);