diff --git a/connections.js b/connections.js index f063400..5642177 100644 --- a/connections.js +++ b/connections.js @@ -60,12 +60,39 @@ export const connections = function(superClass) { firstUpdated() { this.addEventListener('port-click', this._handlePortClick); document.addEventListener('mousemove', this._updatePreviewConnection.bind(this)); - this.canvas.addEventListener('click', (e) => { + + document.addEventListener('click', (e) => { + // Check if clicking on an output port + const path = e.composedPath(); + const isOutputPort = path.some(el => + el instanceof HTMLElement && + el.classList && + el.classList.contains('output-ports') + ); + + // Only cancel connection if we're dragging AND it's not an output port + if (this.draggingConnection && !isOutputPort) { + // Check if we clicked on an input port + const isInputPort = path.some(el => + el instanceof HTMLElement && + el.classList && + el.classList.contains('input-ports') + ); + + // If not an input port, cancel the connection + if (!isInputPort) { + this.draggingConnection = null; + this.requestUpdate(); + } + } + + // Handle existing connection deselection if (e.target === this.canvas) { this.selectedConnection = null; this.requestUpdate(); } }); + super.firstUpdated(); } @@ -80,8 +107,31 @@ export const connections = function(superClass) { } _renderConnections() { + const bounds = this._calculateSVGBounds(); + + // Handle initial render when canvas is not ready + const style = this.canvas ? ` + position: absolute; + top: ${bounds.minY}px; + left: ${bounds.minX}px; + width: ${bounds.width}px; + height: ${bounds.height}px; + pointer-events: none; + ` : ` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + `; + return html` - + ${this.connections.map(conn => this._renderConnection(conn))} ${this.draggingConnection ? this._renderPreviewConnection() : null} @@ -138,7 +188,7 @@ export const connections = function(superClass) { const sourceNode = this.nodes.find(node => node.id === conn.sourceNodeId); const targetNode = this.nodes.find(node => node.id === conn.targetNodeId); - if (!sourceNode || !targetNode) return ''; + if (!sourceNode?.shadowRoot || !targetNode?.shadowRoot) 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}"]`); @@ -247,8 +297,17 @@ export const connections = function(superClass) { } _deleteConnection(connectionId) { + const connection = this.connections.find(conn => conn.id === connectionId); this.connections = this.connections.filter(conn => conn.id !== connectionId); this.selectedConnection = null; + + if (connection) { + this._dispatchChangeEvent({ + type: 'connection-removed', + data: { connectionId } + }); + } + this.requestUpdate(); } @@ -282,11 +341,48 @@ export const connections = function(superClass) { }; this.connections = [...this.connections, connection]; + + this._dispatchChangeEvent({ + type: 'connection-added', + data: connection + }); } } this.draggingConnection = null; this.requestUpdate(); } } + + _calculateSVGBounds() { + // If canvas is not ready yet, return default bounds + if (!this.canvas) { + return { + minX: 0, + minY: 0, + width: '100%', + height: '100%' + }; + } + + let minX = 0, minY = 0, maxX = 0, maxY = 0; + const canvasRect = this.canvas.getBoundingClientRect(); + + // Include all node positions + this.nodes.forEach(node => { + const rect = node.getBoundingClientRect(); + minX = Math.min(minX, (rect.left - canvasRect.left) / this.scale); + minY = Math.min(minY, (rect.top - canvasRect.top) / this.scale); + maxX = Math.max(maxX, (rect.right - canvasRect.left) / this.scale); + maxY = Math.max(maxY, (rect.bottom - canvasRect.top) / this.scale); + }); + + const padding = 1000; + return { + minX: minX - padding, + minY: minY - padding, + width: maxX - minX + (padding * 2), + height: maxY - minY + (padding * 2) + }; + } }; } \ No newline at end of file diff --git a/demo-node.js b/demo-node.js index 8aeee58..e5388b7 100644 --- a/demo-node.js +++ b/demo-node.js @@ -6,6 +6,7 @@ This program is available under Apache License Version 2.0 import { html, css } from 'lit'; import { TpFlowNode } from './tp-flow-node'; +import { TpFlowNodes } from './tp-flow-nodes.js'; class DemoNode extends TpFlowNode { static get styles() { @@ -53,4 +54,5 @@ class DemoNode extends TpFlowNode { } } -window.customElements.define('demo-node', DemoNode); \ No newline at end of file +window.customElements.define('demo-node', DemoNode); +TpFlowNodes.registerNode('MathNode', DemoNode); \ No newline at end of file diff --git a/panning.js b/panning.js index 7dffa99..54fe7bd 100644 --- a/panning.js +++ b/panning.js @@ -101,10 +101,25 @@ export const panning = function(superClass) { this.targetElement.style.transform = `translate(${this.currentX}px, ${this.currentY}px)`; } + + this.requestUpdate(); } } _endDrag() { + if (this.isDragging && this.targetElement instanceof TpFlowNode) { + this._dispatchChangeEvent({ + type: 'node-moved', + data: { + nodeId: this.targetElement.id, + position: { + x: this.currentX, + y: this.currentY + } + } + }); + } + this.isDragging = false; this.targetElement = null; } diff --git a/tp-flow-node.js b/tp-flow-node.js index 8c1d4ea..45a449c 100644 --- a/tp-flow-node.js +++ b/tp-flow-node.js @@ -20,13 +20,6 @@ export class TpFlowNode extends LitElement { 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; @@ -64,16 +57,29 @@ export class TpFlowNode extends LitElement { .port:hover { background: #888; } + + .delete-btn { + position: absolute; + top: 5px; + right: 5px; + width: 16px; + height: 16px; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s; + } + + .delete-btn:hover { + opacity: 1; + } ` ]; } render() { return html` -
- ${this.flowNodeType} -
+
${this.inputs.map((input, idx) => html`
-
+
${this.renderNodeContent()}
@@ -147,22 +153,38 @@ export class TpFlowNode extends LitElement { })); } - // Method to export node data + _handleDelete(e) { + e.stopPropagation(); // Prevent event from triggering other handlers + this.dispatchEvent(new CustomEvent('node-delete-requested', { + detail: { nodeId: this.id }, + bubbles: true, + composed: true + })); + } + exportData() { + // Get current transform + const transform = window.getComputedStyle(this).transform; + const matrix = new DOMMatrix(transform); + return { id: this.id, type: this.flowNodeType, - x: this.x, - y: this.y, + position: { + x: matrix.m41, + y: matrix.m42 + }, 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; + + // Apply position + if (data.position) { + this.style.transform = `translate(${data.position.x}px, ${data.position.y}px)`; + } } } \ No newline at end of file diff --git a/tp-flow-nodes.js b/tp-flow-nodes.js index 426261b..2298c68 100644 --- a/tp-flow-nodes.js +++ b/tp-flow-nodes.js @@ -9,7 +9,9 @@ import { connections, connectionStyles } from './connections.js'; import { panning } from './panning.js'; import { zoom } from './zoom.js'; -class TpFlowNodes extends zoom(panning(connections(LitElement))) { +export class TpFlowNodes extends zoom(panning(connections(LitElement))) { + static nodeTypes = new Map(); + static get styles() { return [ connectionStyles, @@ -53,32 +55,174 @@ class TpFlowNodes extends zoom(panning(connections(LitElement))) { constructor() { super(); this.nodes = []; - this.previewConnection = null; + this._boundDeleteHandler = this._handleNodeDeleteRequested.bind(this); + } + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('node-delete-requested', this._boundDeleteHandler); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('node-delete-requested', this._boundDeleteHandler); + } + + _handleNodeDeleteRequested(e) { + const { nodeId } = e.detail; + this.removeNode(nodeId); } - // Export flow chart data exportFlow() { return { nodes: this.nodes.map(node => node.exportData()), - connections: this.connections + connections: this.connections, + canvas: { + scale: this.scale, + position: { + x: this.currentX || 0, + y: this.currentY || 0 + } + } }; } - - // Import flow chart data - importFlow(flowData) { + + async importFlow(flowData) { // Clear existing this.nodes = []; this.connections = []; - // Create nodes - flowData.nodes.forEach(nodeData => { + // Create all nodes first + const nodes = flowData.nodes.map(nodeData => { const node = this._createNode(nodeData.type); node.importData(nodeData); - this.nodes.push(node); + return node; + }); + + // Set nodes and wait for render + this.nodes = nodes; + await this.updateComplete; + + // Wait for all nodes to be ready + await Promise.all(this.nodes.map(node => node.updateComplete)); + + // Only after nodes are ready, restore connections + this.connections = flowData.connections; + + // Restore canvas state + if (flowData.canvas) { + if (flowData.canvas.scale) { + this._applyZoom(flowData.canvas.scale); + } + if (flowData.canvas.position) { + this.currentX = flowData.canvas.position.x; + this.currentY = flowData.canvas.position.y; + this.canvas.style.transform = + `translate(${this.currentX}px, ${this.currentY}px) scale(${this.scale})`; + } + } + + await this.updateComplete; + } + + /** + * Register a node type + * @param {string} type Node type identifier + * @param {typeof TpFlowNode} nodeClass Node class to register + */ + static registerNode(type, nodeClass) { + TpFlowNodes.nodeTypes.set(type, nodeClass); + } + + /** + * Create a new node instance + * @param {string} type Node type identifier + * @param {Object} initialData Initial data for the node + * @param {number} x Initial x position + * @param {number} y Initial y position + * @returns {TpFlowNode} The created node instance + */ + createNode(type, initialData = {}, x = 0, y = 0) { + const nodeClass = TpFlowNodes.nodeTypes.get(type); + if (!nodeClass) { + throw new Error(`Unknown node type: ${type}`); + } + + const node = new nodeClass(); + node.setAttribute('part', 'node'); + node.setAttribute('exportparts', 'node-content'); + node.importData({ + id: `node_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + position: { x, y }, + data: initialData }); - // Restore connections - this.connections = flowData.connections; + this.nodes = [...this.nodes, node]; + + this._dispatchChangeEvent({ + type: 'node-added', + data: { nodeId: node.id, nodeType: type } + }); + + return node; + } + + _createNode(type) { + const nodeClass = TpFlowNodes.nodeTypes.get(type); + if (!nodeClass) { + throw new Error(`Unknown node type: ${type}`); + } + return new nodeClass(); + } + + /** + * Unregister a node type + * @param {string} type Node type identifier + * @returns {boolean} True if the type was unregistered, false if it didn't exist + */ + static unregisterNode(type) { + return TpFlowNodes.nodeTypes.delete(type); + } + + /** + * Remove a node by its ID + * @param {string} nodeId ID of the node to remove + * @returns {boolean} True if the node was found and removed, false otherwise + */ + removeNode(nodeId) { + const initialLength = this.nodes.length; + + // Remove the node + this.nodes = this.nodes.filter(node => node.id !== nodeId); + + // Remove any connections associated with this node + this.connections = this.connections.filter(conn => + conn.sourceNodeId !== nodeId && conn.targetNodeId !== nodeId + ); + + // Return true if a node was actually removed + const removed = this.nodes.length < initialLength; + + if (removed) { + this._dispatchChangeEvent({ + type: 'node-removed', + data: { nodeId } + }); + } + + return removed; + } + + _dispatchChangeEvent(detail = {}) { + this.dispatchEvent(new CustomEvent('flow-changed', { + detail: { + type: detail.type, // Type of change: 'node-added', 'node-removed', 'node-moved', 'connection-added', etc. + data: detail.data, // Additional data specific to the change + flow: this.exportFlow() // Current state of the entire flow + }, + bubbles: true, + composed: true + })); } }