401 lines
14 KiB
JavaScript
401 lines
14 KiB
JavaScript
/**
|
||
* 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 = '<i class="' + (def.icon || 'fa fa-cube') + '"></i> ' + 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 += '<div class="drag-param"><span>' + pd.label + '</span><b>' +
|
||
(opt ? opt.text : (params[pd.name] !== undefined ? params[pd.name] : '')) + '</b></div>';
|
||
} else if (params[pd.name] !== undefined && String(params[pd.name]) !== '') {
|
||
body.innerHTML += '<div class="drag-param"><span>' + pd.label + '</span><b>' + params[pd.name] + '</b></div>';
|
||
}
|
||
});
|
||
|
||
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);
|