This commit is contained in:
trading_peter 2024-12-20 10:29:39 +01:00
parent a892e1fcb2
commit 5e449e23b8
5 changed files with 313 additions and 34 deletions

View File

@ -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`
<svg class="connections" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;">
<svg
class="connections"
style="${style}"
viewBox="${this.canvas ? `${bounds.minX} ${bounds.minY} ${bounds.width} ${bounds.height}` : '0 0 100 100'}"
>
${this.connections.map(conn => this._renderConnection(conn))}
${this.draggingConnection ? this._renderPreviewConnection() : null}
</svg>
@ -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)
};
}
};
}

View File

@ -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);
window.customElements.define('demo-node', DemoNode);
TpFlowNodes.registerNode('MathNode', DemoNode);

View File

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

View File

@ -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`
<div class="node-header">
<slot name="title">${this.flowNodeType}</slot>
</div>
<div class="node-body">
<div class="delete-btn" @click="${this._handleDelete}"></div>
<div class="input-ports">
${this.inputs.map((input, idx) => html`
<div class="port"
@ -85,7 +91,7 @@ export class TpFlowNode extends LitElement {
`)}
</div>
<div class="node-content">
<div class="node-content" part="node-content">
${this.renderNodeContent()}
</div>
@ -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)`;
}
}
}

View File

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