HTML 工作流编辑器
以下是一个简单的工作流编辑器的HTML实现,包含基本的拖拽节点、连接线和可视化编辑功能:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>工作流编辑器</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
background-color: #f5f5f5;
}
#toolbar {
background-color: #333;
color: white;
padding: 10px;
display: flex;
justify-content: space-between;
}
#node-palette {
width: 200px;
background-color: #eee;
height: calc(100vh - 60px);
float: left;
padding: 10px;
overflow-y: auto;
}
.node-type {
background-color: white;
border: 1px solid #ccc;
padding: 8px;
margin-bottom: 10px;
cursor: move;
border-radius: 4px;
text-align: center;
}
#editor-container {
width: calc(100% - 220px);
height: calc(100vh - 60px);
float: right;
position: relative;
overflow: hidden;
}
#editor-surface {
width: 2000px;
height: 2000px;
background-color: white;
background-image: linear-gradient(#eee 1px, transparent 1px),
linear-gradient(90deg, #eee 1px, transparent 1px);
background-size: 20px 20px;
position: relative;
}
.workflow-node {
position: absolute;
width: 120px;
height: 60px;
background-color: #4CAF50;
color: white;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
user-select: none;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.workflow-node.input {
background-color: #2196F3;
}
.workflow-node.output {
background-color: #f44336;
}
.workflow-node.decision {
background-color: #FFC107;
color: #333;
}
.connector {
width: 12px;
height: 12px;
background-color: #333;
border-radius: 50%;
position: absolute;
cursor: pointer;
}
.input-connector {
left: -6px;
top: 50%;
transform: translateY(-50%);
}
.output-connector {
right: -6px;
top: 50%;
transform: translateY(-50%);
}
.connection {
position: absolute;
pointer-events: none;
z-index: -1;
}
button {
padding: 8px 12px;
margin: 0 5px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #45a049;
}
</style>
</head>
<body>
<div id="toolbar">
<div>
<button id="save-btn">保存</button>
<button id="load-btn">加载</button>
<button id="clear-btn">清空</button>
</div>
<div>
<span id="status">就绪</span>
</div>
</div>
<div id="node-palette">
<h3>节点类型</h3>
<div class="node-type input" draggable="true" data-type="input">输入节点</div>
<div class="node-type" draggable="true" data-type="process">处理节点</div>
<div class="node-type decision" draggable="true" data-type="decision">决策节点</div>
<div class="node-type output" draggable="true" data-type="output">输出节点</div>
</div>
<div id="editor-container">
<div id="editor-surface"></div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const editorSurface = document.getElementById('editor-surface');
const nodePalette = document.getElementById('node-palette');
const statusDisplay = document.getElementById('status');
let selectedConnector = null;
let nodes = [];
let connections = [];
// 从调色板拖拽节点
nodePalette.querySelectorAll('.node-type').forEach(nodeType => {
nodeType.addEventListener('dragstart', function(e) {
e.dataTransfer.setData('text/plain', this.getAttribute('data-type'));
});
});
// 在编辑面上放置节点
editorSurface.addEventListener('dragover', function(e) {
e.preventDefault();
});
editorSurface.addEventListener('drop', function(e) {
e.preventDefault();
const type = e.dataTransfer.getData('text/plain');
if (!type) return;
const rect = editorSurface.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
createNode(type, x, y);
updateStatus(`已创建 ${type} 节点`);
});
// 创建节点
function createNode(type, x, y) {
const node = document.createElement('div');
node.className = `workflow-node ${type}`;
node.style.left = `${x}px`;
node.style.top = `${y}px`;
let label = '';
switch(type) {
case 'input': label = '输入'; break;
case 'output': label = '输出'; break;
case 'decision': label = '决策'; break;
default: label = '处理';
}
node.textContent = label;
// 添加连接点
if (type !== 'input') {
const inputConnector = document.createElement('div');
inputConnector.className = 'connector input-connector';
node.appendChild(inputConnector);
inputConnector.addEventListener('mousedown', startConnection);
}
if (type !== 'output') {
const outputConnector = document.createElement('div');
outputConnector.className = 'connector output-connector';
node.appendChild(outputConnector);
outputConnector.addEventListener('mousedown', startConnection);
}
// 使节点可拖动
makeDraggable(node);
editorSurface.appendChild(node);
nodes.push({
element: node,
x, y,
type,
id: Date.now().toString()
});
return node;
}
// 使节点可拖动
function makeDraggable(element) {
let offsetX, offsetY, isDragging = false;
element.addEventListener('mousedown', function(e) {
if (e.target.classList.contains('connector')) return;
isDragging = true;
const rect = element.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
element.style.zIndex = '10';
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
const rect = editorSurface.getBoundingClientRect();
let x = e.clientX - rect.left - offsetX;
let y = e.clientY - rect.top - offsetY;
// 限制在编辑面内
x = Math.max(0, Math.min(x, editorSurface.offsetWidth - element.offsetWidth));
y = Math.max(0, Math.min(y, editorSurface.offsetHeight - element.offsetHeight));
element.style.left = `${x}px`;
element.style.top = `${y}px`;
// 更新节点位置数据
const node = nodes.find(n => n.element === element);
if (node) {
node.x = x;
node.y = y;
}
// 更新连接线
updateConnections();
});
document.addEventListener('mouseup', function() {
isDragging = false;
element.style.zIndex = '';
});
}
// 开始创建连接
function startConnection(e) {
e.stopPropagation();
selectedConnector = e.target;
document.addEventListener('mousemove', drawTempConnection);
document.addEventListener('mouseup', endConnection);
}
// 绘制临时连接线
function drawTempConnection(e) {
// 在实际应用中,这里会绘制一条临时连接线
}
// 结束连接创建
function endConnection(e) {
document.removeEventListener('mousemove', drawTempConnection);
document.removeEventListener('mouseup', endConnection);
if (!selectedConnector) return;
const targetElement = document.elementFromPoint(e.clientX, e.clientY);
if (!targetElement || !targetElement.classList.contains('connector')) {
selectedConnector = null;
return;
}
const sourceConnector = selectedConnector;
const targetConnector = targetElement;
// 检查是否可以连接(输入只能连输出,反之亦然)
if ((sourceConnector.classList.contains('input-connector') &&
targetConnector.classList.contains('input-connector')) ||
(sourceConnector.classList.contains('output-connector') &&
targetConnector.classList.contains('output-connector'))) {
updateStatus("无法连接: 输入只能连接输出,输出只能连接输入");
selectedConnector = null;
return;
}
// 确定源和目标(输出->输入)
let fromConnector, toConnector;
if (sourceConnector.classList.contains('output-connector')) {
fromConnector = sourceConnector;
toConnector = targetConnector;
} else {
fromConnector = targetConnector;
toConnector = sourceConnector;
}
createConnection(fromConnector, toConnector);
selectedConnector = null;
}
// 创建永久连接
function createConnection(fromConnector, toConnector) {
const connection = document.createElement('div');
connection.className = 'connection';
editorSurface.appendChild(connection);
const fromNode = fromConnector.parentElement;
const toNode = toConnector.parentElement;
const connectionObj = {
element: connection,
from: fromNode,
to: toNode,
fromConnector,
toConnector
};
connections.push(connectionObj);
updateConnection(connectionObj);
updateStatus("已创建连接");
}
// 更新连接线位置
function updateConnection(connection) {
const fromRect = connection.from.getBoundingClientRect();
const toRect = connection.to.getBoundingClientRect();
const editorRect = editorSurface.getBoundingClientRect();
const fromX = fromRect.left - editorRect.left +
(connection.fromConnector.classList.contains('output-connector') ? fromRect.width : 0);
const fromY = fromRect.top - editorRect.top + fromRect.height / 2;
const toX = toRect.left - editorRect.left +
(connection.toConnector.classList.contains('input-connector') ? 0 : toRect.width);
const toY = toRect.top - editorRect.top + toRect.height / 2;
// 简单的贝塞尔曲线连接
const path = `M ${fromX} ${fromY} C ${(fromX + toX) / 2} ${fromY}, ${(fromX + toX) / 2} ${toY}, ${toX} ${toY}`;
connection.element.innerHTML = `
<svg width="${editorSurface.offsetWidth}" height="${editorSurface.offsetHeight}">
<path d="${path}" stroke="#333" stroke-width="2" fill="none" marker-end="url(#arrowhead)" />
</svg>
`;
}
// 更新所有连接线
function updateConnections() {
connections.forEach(updateConnection);
}
// 工具栏按钮功能
document.getElementById('save-btn').addEventListener('click', function() {
const workflow = {
nodes: nodes.map(node => ({
id: node.id,
type: node.type,
x: node.x,
y: node.y
})),
connections: connections.map(conn => ({
from: nodes.findIndex(n => n.element === conn.from),
to: nodes.findIndex(n => n.element === conn.to)
}))
};
localStorage.setItem('workflow', JSON.stringify(workflow));
updateStatus("工作流已保存");
});
document.getElementById('load-btn').addEventListener('click', function() {
const saved = localStorage.getItem('workflow');
if (!saved) {
updateStatus("没有找到保存的工作流");
return;
}
// 清空当前工作流
editorSurface.innerHTML = '';
nodes = [];
connections = [];
const workflow = JSON.parse(saved);
// 重新创建节点
workflow.nodes.forEach(nodeData => {
const node = createNode(nodeData.type, nodeData.x, nodeData.y);
const nodeObj = nodes[nodes.length - 1];
nodeObj.id = nodeData.id;
});
// 重新创建连接
workflow.connections.forEach(connData => {
const fromNode = nodes[connData.from].element;
const toNode = nodes[connData.to].element;
const fromConnector = fromNode.querySelector('.output-connector');
const toConnector = toNode.querySelector('.input-connector');
if (fromConnector && toConnector) {
createConnection(fromConnector, toConnector);
}
});
updateStatus("工作流已加载");
});
document.getElementById('clear-btn').addEventListener('click', function() {
editorSurface.innerHTML = '';
nodes = [];
connections = [];
updateStatus("工作流已清空");
});
// 状态更新
function updateStatus(message) {
statusDisplay.textContent = message;
setTimeout(() => {
if (statusDisplay.textContent === message) {
statusDisplay.textContent = "就绪";
}
}, 3000);
}
// 添加箭头标记定义
const svgDefs = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgDefs.style.position = 'absolute';
svgDefs.style.width = '0';
svgDefs.style.height = '0';
svgDefs.style.overflow = 'hidden';
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
marker.setAttribute('id', 'arrowhead');
marker.setAttribute('markerWidth', '10');
marker.setAttribute('markerHeight', '7');
marker.setAttribute('refX', '9');
marker.setAttribute('refY', '3.5');
marker.setAttribute('orient', 'auto');
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
arrow.setAttribute('points', '0 0, 10 3.5, 0 7');
arrow.setAttribute('fill', '#333');
marker.appendChild(arrow);
defs.appendChild(marker);
svgDefs.appendChild(defs);
document.body.appendChild(svgDefs);
});
</script>
</body>
</html>
功能说明
这个工作流编辑器包含以下功能:
- 节点调色板:提供不同类型的节点(输入、处理、决策、输出)
- 拖拽创建节点:可以从调色板拖拽节点到编辑区域
- 节点连接:可以通过连接点创建节点之间的连线
- 节点拖动:可以拖动已创建的节点
- 基本工具栏:包含保存、加载和清空功能
- 状态显示:显示当前操作状态
扩展建议
要使这个编辑器更实用,你可以考虑添加:
- 节点属性编辑功能
- 更复杂的连接线样式(带箭头、标签等)
- 撤销/重做功能
- 工作流验证
- 导出为JSON或其他格式
- 缩放和平移功能
- 网格对齐和吸附功能
这个示例使用了纯HTML/CSS/JavaScript实现,对于更复杂的工作流编辑器,你可能需要考虑使用专门的库如jsPlumb、GoJS或React Flow等。