diff --git a/README.md b/README.md
index 1ab27b7..79aa910 100644
--- a/README.md
+++ b/README.md
@@ -1 +1 @@
-# tp-element
+# tp-flow-nodes
diff --git a/connections.js b/connections.js
new file mode 100644
index 0000000..9497061
--- /dev/null
+++ b/connections.js
@@ -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`
+
+ `;
+ }
+
+ _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`
+
+ `;
+ }
+
+ _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`
+
+ `;
+ }
+
+ _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();
+ }
+ }
+ };
+}
\ No newline at end of file
diff --git a/demo-node.js b/demo-node.js
new file mode 100644
index 0000000..8aeee58
--- /dev/null
+++ b/demo-node.js
@@ -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`
+
+ `;
+ }
+
+ 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);
\ No newline at end of file
diff --git a/package.json b/package.json
index 24f0225..1aeec1e 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/panning.js b/panning.js
new file mode 100644
index 0000000..29e474c
--- /dev/null
+++ b/panning.js
@@ -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;
+ }
+ };
+};
\ No newline at end of file
diff --git a/tp-element.js b/tp-element.js
deleted file mode 100644
index 6195006..0000000
--- a/tp-element.js
+++ /dev/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);
diff --git a/tp-flow-node.js b/tp-flow-node.js
new file mode 100644
index 0000000..8c1d4ea
--- /dev/null
+++ b/tp-flow-node.js
@@ -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`
+
+
+
+
+
+ ${this.renderNodeContent()}
+
+
+
+ ${this.outputs.map((output, idx) => html`
+
+
+ `)}
+
+
+ `;
+ }
+
+ 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;
+ }
+}
\ No newline at end of file
diff --git a/tp-flow-nodes.js b/tp-flow-nodes.js
new file mode 100644
index 0000000..6a524a4
--- /dev/null
+++ b/tp-flow-nodes.js
@@ -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`
+
+ ${this.nodes.map(node => html`${node}`)}
+ ${this._renderConnections()}
+
+ `;
+ }
+
+ 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);