tp-flow-nodes/tp-flow-nodes.js
2024-12-20 10:29:39 +01:00

230 lines
5.8 KiB
JavaScript

/**
@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';
import { zoom } from './zoom.js';
export class TpFlowNodes extends zoom(panning(connections(LitElement))) {
static nodeTypes = new Map();
static get styles() {
return [
connectionStyles,
css`
:host {
display: block;
overflow: hidden;
position: relative;
}
.canvas {
width: 100%;
height: 100%;
user-select: none;
position: absolute;
min-width: 100%;
min-height: 100%;
overflow: visible;
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._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);
}
exportFlow() {
return {
nodes: this.nodes.map(node => node.exportData()),
connections: this.connections,
canvas: {
scale: this.scale,
position: {
x: this.currentX || 0,
y: this.currentY || 0
}
}
};
}
async importFlow(flowData) {
// Clear existing
this.nodes = [];
this.connections = [];
// Create all nodes first
const nodes = flowData.nodes.map(nodeData => {
const node = this._createNode(nodeData.type);
node.importData(nodeData);
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
});
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
}));
}
}
window.customElements.define('tp-flow-nodes', TpFlowNodes);