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` + + ${this.connections.map(conn => this._renderConnection(conn))} + ${this.draggingConnection ? this._renderPreviewConnection() : null} + + `; + } + + _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.flowNodeType} +
+
+
+ ${this.inputs.map((input, idx) => 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);