wip
This commit is contained in:
parent
a892e1fcb2
commit
5e449e23b8
102
connections.js
102
connections.js
@ -60,12 +60,39 @@ export const connections = function(superClass) {
|
|||||||
firstUpdated() {
|
firstUpdated() {
|
||||||
this.addEventListener('port-click', this._handlePortClick);
|
this.addEventListener('port-click', this._handlePortClick);
|
||||||
document.addEventListener('mousemove', this._updatePreviewConnection.bind(this));
|
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) {
|
if (e.target === this.canvas) {
|
||||||
this.selectedConnection = null;
|
this.selectedConnection = null;
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
super.firstUpdated();
|
super.firstUpdated();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,8 +107,31 @@ export const connections = function(superClass) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_renderConnections() {
|
_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`
|
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.connections.map(conn => this._renderConnection(conn))}
|
||||||
${this.draggingConnection ? this._renderPreviewConnection() : null}
|
${this.draggingConnection ? this._renderPreviewConnection() : null}
|
||||||
</svg>
|
</svg>
|
||||||
@ -138,7 +188,7 @@ export const connections = function(superClass) {
|
|||||||
const sourceNode = this.nodes.find(node => node.id === conn.sourceNodeId);
|
const sourceNode = this.nodes.find(node => node.id === conn.sourceNodeId);
|
||||||
const targetNode = this.nodes.find(node => node.id === conn.targetNodeId);
|
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 sourcePort = sourceNode.shadowRoot.querySelector(`.output-ports [data-port-id="${conn.sourcePortId}"]`);
|
||||||
const targetPort = targetNode.shadowRoot.querySelector(`.input-ports [data-port-id="${conn.targetPortId}"]`);
|
const targetPort = targetNode.shadowRoot.querySelector(`.input-ports [data-port-id="${conn.targetPortId}"]`);
|
||||||
@ -247,8 +297,17 @@ export const connections = function(superClass) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_deleteConnection(connectionId) {
|
_deleteConnection(connectionId) {
|
||||||
|
const connection = this.connections.find(conn => conn.id === connectionId);
|
||||||
this.connections = this.connections.filter(conn => conn.id !== connectionId);
|
this.connections = this.connections.filter(conn => conn.id !== connectionId);
|
||||||
this.selectedConnection = null;
|
this.selectedConnection = null;
|
||||||
|
|
||||||
|
if (connection) {
|
||||||
|
this._dispatchChangeEvent({
|
||||||
|
type: 'connection-removed',
|
||||||
|
data: { connectionId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,11 +341,48 @@ export const connections = function(superClass) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.connections = [...this.connections, connection];
|
this.connections = [...this.connections, connection];
|
||||||
|
|
||||||
|
this._dispatchChangeEvent({
|
||||||
|
type: 'connection-added',
|
||||||
|
data: connection
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.draggingConnection = null;
|
this.draggingConnection = null;
|
||||||
this.requestUpdate();
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -6,6 +6,7 @@ This program is available under Apache License Version 2.0
|
|||||||
|
|
||||||
import { html, css } from 'lit';
|
import { html, css } from 'lit';
|
||||||
import { TpFlowNode } from './tp-flow-node';
|
import { TpFlowNode } from './tp-flow-node';
|
||||||
|
import { TpFlowNodes } from './tp-flow-nodes.js';
|
||||||
|
|
||||||
class DemoNode extends TpFlowNode {
|
class DemoNode extends TpFlowNode {
|
||||||
static get styles() {
|
static get styles() {
|
||||||
@ -54,3 +55,4 @@ class DemoNode extends TpFlowNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.customElements.define('demo-node', DemoNode);
|
window.customElements.define('demo-node', DemoNode);
|
||||||
|
TpFlowNodes.registerNode('MathNode', DemoNode);
|
15
panning.js
15
panning.js
@ -101,10 +101,25 @@ export const panning = function(superClass) {
|
|||||||
this.targetElement.style.transform =
|
this.targetElement.style.transform =
|
||||||
`translate(${this.currentX}px, ${this.currentY}px)`;
|
`translate(${this.currentX}px, ${this.currentY}px)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_endDrag() {
|
_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.isDragging = false;
|
||||||
this.targetElement = null;
|
this.targetElement = null;
|
||||||
}
|
}
|
||||||
|
@ -20,13 +20,6 @@ export class TpFlowNode extends LitElement {
|
|||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-header {
|
|
||||||
padding: 8px;
|
|
||||||
background: #3b3b3b;
|
|
||||||
border-bottom: 1px solid #4b4b4b;
|
|
||||||
border-radius: 4px 4px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-body {
|
.node-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 20px 1fr 20px;
|
grid-template-columns: 20px 1fr 20px;
|
||||||
@ -64,16 +57,29 @@ export class TpFlowNode extends LitElement {
|
|||||||
.port:hover {
|
.port:hover {
|
||||||
background: #888;
|
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() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<div class="node-header">
|
|
||||||
<slot name="title">${this.flowNodeType}</slot>
|
|
||||||
</div>
|
|
||||||
<div class="node-body">
|
<div class="node-body">
|
||||||
|
<div class="delete-btn" @click="${this._handleDelete}">✕</div>
|
||||||
<div class="input-ports">
|
<div class="input-ports">
|
||||||
${this.inputs.map((input, idx) => html`
|
${this.inputs.map((input, idx) => html`
|
||||||
<div class="port"
|
<div class="port"
|
||||||
@ -85,7 +91,7 @@ export class TpFlowNode extends LitElement {
|
|||||||
`)}
|
`)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="node-content">
|
<div class="node-content" part="node-content">
|
||||||
${this.renderNodeContent()}
|
${this.renderNodeContent()}
|
||||||
</div>
|
</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() {
|
exportData() {
|
||||||
|
// Get current transform
|
||||||
|
const transform = window.getComputedStyle(this).transform;
|
||||||
|
const matrix = new DOMMatrix(transform);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
type: this.flowNodeType,
|
type: this.flowNodeType,
|
||||||
x: this.x,
|
position: {
|
||||||
y: this.y,
|
x: matrix.m41,
|
||||||
|
y: matrix.m42
|
||||||
|
},
|
||||||
data: this.data
|
data: this.data
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to import node data
|
|
||||||
importData(data) {
|
importData(data) {
|
||||||
this.id = data.id;
|
this.id = data.id;
|
||||||
this.x = data.x;
|
|
||||||
this.y = data.y;
|
|
||||||
this.data = data.data;
|
this.data = data.data;
|
||||||
|
|
||||||
|
// Apply position
|
||||||
|
if (data.position) {
|
||||||
|
this.style.transform = `translate(${data.position.x}px, ${data.position.y}px)`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
164
tp-flow-nodes.js
164
tp-flow-nodes.js
@ -9,7 +9,9 @@ import { connections, connectionStyles } from './connections.js';
|
|||||||
import { panning } from './panning.js';
|
import { panning } from './panning.js';
|
||||||
import { zoom } from './zoom.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() {
|
static get styles() {
|
||||||
return [
|
return [
|
||||||
connectionStyles,
|
connectionStyles,
|
||||||
@ -53,32 +55,174 @@ class TpFlowNodes extends zoom(panning(connections(LitElement))) {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.nodes = [];
|
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() {
|
exportFlow() {
|
||||||
return {
|
return {
|
||||||
nodes: this.nodes.map(node => node.exportData()),
|
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
|
async importFlow(flowData) {
|
||||||
importFlow(flowData) {
|
|
||||||
// Clear existing
|
// Clear existing
|
||||||
this.nodes = [];
|
this.nodes = [];
|
||||||
this.connections = [];
|
this.connections = [];
|
||||||
|
|
||||||
// Create nodes
|
// Create all nodes first
|
||||||
flowData.nodes.forEach(nodeData => {
|
const nodes = flowData.nodes.map(nodeData => {
|
||||||
const node = this._createNode(nodeData.type);
|
const node = this._createNode(nodeData.type);
|
||||||
node.importData(nodeData);
|
node.importData(nodeData);
|
||||||
this.nodes.push(node);
|
return node;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore connections
|
// 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;
|
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
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user