wip
This commit is contained in:
parent
01bcdffaf8
commit
80f69bf09c
183
connections.js
Normal file
183
connections.js
Normal file
@ -0,0 +1,183 @@
|
||||
import { html, css, svg } from 'lit';
|
||||
|
||||
export const connectionStyles = css`
|
||||
.connections {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.connections path {
|
||||
transition: stroke 0.3s ease, stroke-width 0.3s ease;
|
||||
}
|
||||
|
||||
.connections path:hover {
|
||||
stroke: #999;
|
||||
stroke-width: 3;
|
||||
}
|
||||
`;
|
||||
|
||||
export const connections = function(superClass) {
|
||||
return class extends superClass {
|
||||
constructor() {
|
||||
super();
|
||||
this.connections = [];
|
||||
this.draggingConnection = null;
|
||||
this.mousePosition = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.addEventListener('port-click', this._handlePortClick);
|
||||
document.addEventListener('mousemove', this._updatePreviewConnection.bind(this));
|
||||
super.firstUpdated();
|
||||
}
|
||||
|
||||
_updatePreviewConnection(e) {
|
||||
if (this.draggingConnection) {
|
||||
this.mousePosition = {
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
};
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
_renderConnections() {
|
||||
return html`
|
||||
<svg class="connections" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;">
|
||||
${this.connections.map(conn => this._renderConnection(conn))}
|
||||
${this.draggingConnection ? this._renderPreviewConnection() : null}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderPreviewConnection() {
|
||||
const sourceNode = this.nodes.find(node => node.id === this.draggingConnection.sourceNodeId);
|
||||
if (!sourceNode) return '';
|
||||
|
||||
const sourcePort = sourceNode.shadowRoot.querySelector(
|
||||
`.output-ports [data-port-id="${this.draggingConnection.sourcePortId}"]`
|
||||
);
|
||||
if (!sourcePort) return '';
|
||||
|
||||
const sourceRect = sourcePort.getBoundingClientRect();
|
||||
const canvasRect = this.canvas.getBoundingClientRect();
|
||||
|
||||
const start = {
|
||||
x: sourceRect.left + sourceRect.width/2 - canvasRect.left,
|
||||
y: sourceRect.top + sourceRect.height/2 - canvasRect.top
|
||||
};
|
||||
|
||||
const end = {
|
||||
x: this.mousePosition.x - canvasRect.left,
|
||||
y: this.mousePosition.y - canvasRect.top
|
||||
};
|
||||
|
||||
const controlPoint1 = {
|
||||
x: start.x + Math.min(100, Math.abs(end.x - start.x) / 2),
|
||||
y: start.y
|
||||
};
|
||||
|
||||
const controlPoint2 = {
|
||||
x: end.x - Math.min(100, Math.abs(end.x - start.x) / 2),
|
||||
y: end.y
|
||||
};
|
||||
|
||||
const path = `M${start.x},${start.y} C${controlPoint1.x},${controlPoint1.y} ${controlPoint2.x},${controlPoint2.y} ${end.x},${end.y}`;
|
||||
|
||||
return svg`
|
||||
<path
|
||||
d="${path}"
|
||||
fill="none"
|
||||
stroke="#666"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="5,5"
|
||||
id="preview-connection"
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderConnection(conn) {
|
||||
const sourceNode = this.nodes.find(node => node.id === conn.sourceNodeId);
|
||||
const targetNode = this.nodes.find(node => node.id === conn.targetNodeId);
|
||||
|
||||
if (!sourceNode || !targetNode) return '';
|
||||
|
||||
const sourcePort = sourceNode.shadowRoot.querySelector(`.output-ports [data-port-id="${conn.sourcePortId}"]`);
|
||||
const targetPort = targetNode.shadowRoot.querySelector(`.input-ports [data-port-id="${conn.targetPortId}"]`);
|
||||
|
||||
if (!sourcePort || !targetPort) return '';
|
||||
|
||||
const sourceRect = sourcePort.getBoundingClientRect();
|
||||
const targetRect = targetPort.getBoundingClientRect();
|
||||
const canvasRect = this.canvas.getBoundingClientRect();
|
||||
|
||||
const start = {
|
||||
x: sourceRect.left + sourceRect.width/2 - canvasRect.left,
|
||||
y: sourceRect.top + sourceRect.height/2 - canvasRect.top
|
||||
};
|
||||
|
||||
const end = {
|
||||
x: targetRect.left + targetRect.width/2 - canvasRect.left,
|
||||
y: targetRect.top + targetRect.height/2 - canvasRect.top
|
||||
};
|
||||
|
||||
const controlPoint1 = {
|
||||
x: start.x + Math.min(100, Math.abs(end.x - start.x) / 2),
|
||||
y: start.y
|
||||
};
|
||||
|
||||
const controlPoint2 = {
|
||||
x: end.x - Math.min(100, Math.abs(end.x - start.x) / 2),
|
||||
y: end.y
|
||||
};
|
||||
|
||||
const path = `M${start.x},${start.y} C${controlPoint1.x},${controlPoint1.y} ${controlPoint2.x},${controlPoint2.y} ${end.x},${end.y}`;
|
||||
|
||||
return svg`
|
||||
<path
|
||||
d="${path}"
|
||||
fill="none"
|
||||
stroke="#666"
|
||||
stroke-width="2"
|
||||
id="${conn.id}"
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
_handlePortClick(e) {
|
||||
const { nodeId, portType, portId, portName } = e.detail;
|
||||
|
||||
if (!this.draggingConnection) {
|
||||
if (portType === 'output') {
|
||||
this.draggingConnection = {
|
||||
sourceNodeId: nodeId,
|
||||
sourcePortId: portId,
|
||||
sourcePortType: portType
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (portType === 'input' && nodeId !== this.draggingConnection.sourceNodeId) {
|
||||
const connectionExists = this.connections.some(conn =>
|
||||
conn.sourceNodeId === this.draggingConnection.sourceNodeId &&
|
||||
conn.sourcePortId === this.draggingConnection.sourcePortId &&
|
||||
conn.targetNodeId === nodeId &&
|
||||
conn.targetPortId === portId
|
||||
);
|
||||
|
||||
if (!connectionExists) {
|
||||
const connection = {
|
||||
id: `conn_${Date.now()}`,
|
||||
sourceNodeId: this.draggingConnection.sourceNodeId,
|
||||
sourcePortId: this.draggingConnection.sourcePortId,
|
||||
targetNodeId: nodeId,
|
||||
targetPortId: portId
|
||||
};
|
||||
|
||||
this.connections = [...this.connections, connection];
|
||||
}
|
||||
}
|
||||
this.draggingConnection = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
56
demo-node.js
Normal file
56
demo-node.js
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
@license
|
||||
Copyright (c) 2024 trading_peter
|
||||
This program is available under Apache License Version 2.0
|
||||
*/
|
||||
|
||||
import { html, css } from 'lit';
|
||||
import { TpFlowNode } from './tp-flow-node';
|
||||
|
||||
class DemoNode extends TpFlowNode {
|
||||
static get styles() {
|
||||
return [
|
||||
...super.styles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`
|
||||
];
|
||||
}
|
||||
|
||||
renderNodeContent() {
|
||||
return html`
|
||||
<select @change="${this._handleOperationChange}">
|
||||
<option value="add">Add</option>
|
||||
<option value="subtract">Subtract</option>
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return { };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.flowNodeType = 'MathNode';
|
||||
this.inputs = [
|
||||
{ name: 'value1' },
|
||||
{ name: 'value2' }
|
||||
];
|
||||
this.outputs = [
|
||||
{ name: 'result' }
|
||||
];
|
||||
this.data = {
|
||||
operation: 'add'
|
||||
};
|
||||
}
|
||||
|
||||
_handleOperationChange(e) {
|
||||
this.data.operation = e.target.value;
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('demo-node', DemoNode);
|
@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@tp/tp-element",
|
||||
"version": "0.0.1",
|
||||
"name": "@tp/tp-flow-nodes",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "tp-element.js",
|
||||
"main": "tp-flow-nodes.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.codeblob.work/tp-elements/tp-element.git"
|
||||
"url": "https://gitea.codeblob.work/tp-elements/tp-flow-nodes.git"
|
||||
},
|
||||
"author": "trading_peter",
|
||||
"license": "Apache-2.0",
|
||||
|
98
panning.js
Normal file
98
panning.js
Normal file
@ -0,0 +1,98 @@
|
||||
import { TpFlowNode } from './tp-flow-node.js';
|
||||
|
||||
export const panning = function(superClass) {
|
||||
return class extends superClass {
|
||||
static get properties() {
|
||||
return {
|
||||
isDragging: { type: Boolean },
|
||||
currentX: { type: Number },
|
||||
currentY: { type: Number },
|
||||
initialX: { type: Number },
|
||||
initialY: { type: Number },
|
||||
xOffset: { type: Number },
|
||||
yOffset: { type: Number },
|
||||
highestZIndex: { type: Number }
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.isDragging = false;
|
||||
this.currentX = 0;
|
||||
this.currentY = 0;
|
||||
this.initialX = 0;
|
||||
this.initialY = 0;
|
||||
this.xOffset = 0;
|
||||
this.yOffset = 0;
|
||||
this.targetElement = null;
|
||||
this.highestZIndex = 1;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
super.firstUpdated();
|
||||
this.canvas = this.shadowRoot.querySelector('.canvas');
|
||||
this.addEventListener('mousedown', this._startDrag.bind(this));
|
||||
document.addEventListener('mousemove', this._drag.bind(this));
|
||||
document.addEventListener('mouseup', this._endDrag.bind(this));
|
||||
}
|
||||
|
||||
_getTopNodeAtPoint(x, y) {
|
||||
const nodes = this.shadowRoot.elementsFromPoint(x, y)
|
||||
.filter(el => el instanceof TpFlowNode)
|
||||
.sort((a, b) => {
|
||||
const aZ = parseInt(getComputedStyle(a).zIndex) || 0;
|
||||
const bZ = parseInt(getComputedStyle(b).zIndex) || 0;
|
||||
return bZ - aZ;
|
||||
});
|
||||
|
||||
return nodes[0] || null;
|
||||
}
|
||||
|
||||
_bringToFront(element) {
|
||||
if (!(element instanceof TpFlowNode)) return;
|
||||
|
||||
this.highestZIndex++;
|
||||
element.style.zIndex = this.highestZIndex;
|
||||
}
|
||||
|
||||
_startDrag(e) {
|
||||
const topNode = this._getTopNodeAtPoint(e.clientX, e.clientY);
|
||||
|
||||
if (topNode) {
|
||||
this.targetElement = topNode;
|
||||
this._bringToFront(topNode);
|
||||
} else {
|
||||
this.targetElement = this.canvas;
|
||||
}
|
||||
|
||||
if (this.targetElement) {
|
||||
this.isDragging = true;
|
||||
|
||||
const transform = window.getComputedStyle(this.targetElement).transform;
|
||||
const matrix = new DOMMatrix(transform);
|
||||
this.xOffset = matrix.m41;
|
||||
this.yOffset = matrix.m42;
|
||||
|
||||
this.initialX = e.clientX - this.xOffset;
|
||||
this.initialY = e.clientY - this.yOffset;
|
||||
}
|
||||
}
|
||||
|
||||
_drag(e) {
|
||||
if (this.isDragging && this.targetElement) {
|
||||
e.preventDefault();
|
||||
|
||||
this.currentX = e.clientX - this.initialX;
|
||||
this.currentY = e.clientY - this.initialY;
|
||||
|
||||
this.targetElement.style.transform =
|
||||
`translate(${this.currentX}px, ${this.currentY}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
_endDrag() {
|
||||
this.isDragging = false;
|
||||
this.targetElement = null;
|
||||
}
|
||||
};
|
||||
};
|
@ -1,35 +0,0 @@
|
||||
/**
|
||||
@license
|
||||
Copyright (c) 2024 trading_peter
|
||||
This program is available under Apache License Version 2.0
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
|
||||
class TpElement extends LitElement {
|
||||
static get styles() {
|
||||
return [
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
const { } = this;
|
||||
|
||||
return html`
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return { };
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
window.customElements.define('tp-element', TpElement);
|
168
tp-flow-node.js
Normal file
168
tp-flow-node.js
Normal file
@ -0,0 +1,168 @@
|
||||
/**
|
||||
@license
|
||||
Copyright (c) 2024 trading_peter
|
||||
This program is available under Apache License Version 2.0
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
|
||||
// tp-flow-node.js
|
||||
export class TpFlowNode extends LitElement {
|
||||
static get styles() {
|
||||
return [
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
position: absolute;
|
||||
background: #2b2b2b;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.node-header {
|
||||
padding: 8px;
|
||||
background: #3b3b3b;
|
||||
border-bottom: 1px solid #4b4b4b;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.node-body {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr 20px;
|
||||
}
|
||||
|
||||
.node-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.node-ports {
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.input-ports, .output-ports {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.output-ports {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.port {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #666;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.port:hover {
|
||||
background: #888;
|
||||
}
|
||||
`
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="node-header">
|
||||
<slot name="title">${this.flowNodeType}</slot>
|
||||
</div>
|
||||
<div class="node-body">
|
||||
<div class="input-ports">
|
||||
${this.inputs.map((input, idx) => html`
|
||||
<div class="port"
|
||||
data-port-type="input"
|
||||
data-port-id="${idx}"
|
||||
data-port-name="${input.name}"
|
||||
@mousedown="${this._handlePortClick}">
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div class="node-content">
|
||||
${this.renderNodeContent()}
|
||||
</div>
|
||||
|
||||
<div class="output-ports">
|
||||
${this.outputs.map((output, idx) => html`
|
||||
<div class="port"
|
||||
data-port-type="output"
|
||||
data-port-id="${idx}"
|
||||
data-port-name="${output.name}"
|
||||
@mousedown="${this._handlePortClick}">
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderNodeContent() {
|
||||
console.warn('Your node should override the renderNodeContent method.');
|
||||
return null;
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
flowNodeType: { type: String },
|
||||
inputs: { type: Array },
|
||||
outputs: { type: Array },
|
||||
x: { type: Number },
|
||||
y: { type: Number },
|
||||
data: { type: Object }
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.flowNodeType = 'BaseNode';
|
||||
this.inputs = [];
|
||||
this.outputs = [];
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.data = {};
|
||||
}
|
||||
|
||||
_handlePortClick(e) {
|
||||
e.stopPropagation(); // Prevents the event from getting caught by the panning action.
|
||||
|
||||
const portEl = e.target;
|
||||
const detail = {
|
||||
nodeId: this.id,
|
||||
portType: portEl.dataset.portType,
|
||||
portId: portEl.dataset.portId,
|
||||
portName: portEl.dataset.portName
|
||||
};
|
||||
|
||||
this.dispatchEvent(new CustomEvent('port-click', {
|
||||
detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
// Method to export node data
|
||||
exportData() {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.flowNodeType,
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
data: this.data
|
||||
};
|
||||
}
|
||||
|
||||
// Method to import node data
|
||||
importData(data) {
|
||||
this.id = data.id;
|
||||
this.x = data.x;
|
||||
this.y = data.y;
|
||||
this.data = data.data;
|
||||
}
|
||||
}
|
82
tp-flow-nodes.js
Normal file
82
tp-flow-nodes.js
Normal file
@ -0,0 +1,82 @@
|
||||
/**
|
||||
@license
|
||||
Copyright (c) 2024 trading_peter
|
||||
This program is available under Apache License Version 2.0
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { connections, connectionStyles } from './connections.js';
|
||||
import { panning } from './panning.js';
|
||||
|
||||
class TpFlowNodes extends panning(connections(LitElement)) {
|
||||
static get styles() {
|
||||
return [
|
||||
connectionStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
`
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="canvas">
|
||||
${this.nodes.map(node => html`${node}`)}
|
||||
${this._renderConnections()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
nodes: { type: Array },
|
||||
connections: { type: Array }
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.nodes = [];
|
||||
this.previewConnection = null;
|
||||
}
|
||||
|
||||
// Export flow chart data
|
||||
exportFlow() {
|
||||
return {
|
||||
nodes: this.nodes.map(node => node.exportData()),
|
||||
connections: this.connections
|
||||
};
|
||||
}
|
||||
|
||||
// Import flow chart data
|
||||
importFlow(flowData) {
|
||||
// Clear existing
|
||||
this.nodes = [];
|
||||
this.connections = [];
|
||||
|
||||
// Create nodes
|
||||
flowData.nodes.forEach(nodeData => {
|
||||
const node = this._createNode(nodeData.type);
|
||||
node.importData(nodeData);
|
||||
this.nodes.push(node);
|
||||
});
|
||||
|
||||
// Restore connections
|
||||
this.connections = flowData.connections;
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('tp-flow-nodes', TpFlowNodes);
|
Loading…
x
Reference in New Issue
Block a user