This commit is contained in:
trading_peter 2024-12-18 21:27:29 +01:00
parent 01bcdffaf8
commit 80f69bf09c
8 changed files with 592 additions and 40 deletions

View File

@ -1 +1 @@
# tp-element
# tp-flow-nodes

183
connections.js Normal file
View 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
View 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);

View File

@ -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
View 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;
}
};
};

View File

@ -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
View 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
View 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);