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() {
|
||||
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)
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
@ -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() {
|
||||
@ -54,3 +55,4 @@ class DemoNode extends TpFlowNode {
|
||||
}
|
||||
|
||||
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 =
|
||||
`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;
|
||||
}
|
||||
|
@ -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)`;
|
||||
}
|
||||
}
|
||||
}
|
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 { 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;
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
// 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