283 lines
7.1 KiB
JavaScript
283 lines
7.1 KiB
JavaScript
/**
|
|
@license
|
|
Copyright (c) 2024 trading_peter
|
|
This program is available under Apache License Version 2.0
|
|
*/
|
|
|
|
import './tp-flow-node-port.js';
|
|
import { LitElement, html, css } from 'lit';
|
|
|
|
export class TpFlowNode extends LitElement {
|
|
static get styles() {
|
|
return [
|
|
css`
|
|
:host {
|
|
display: block;
|
|
position: absolute;
|
|
background: #2b2b2b;
|
|
border-radius: 4px;
|
|
color: #fff;
|
|
min-width: 150px;
|
|
}
|
|
|
|
.node-body {
|
|
display: grid;
|
|
grid-template-columns: 20px 1fr 20px;
|
|
}
|
|
|
|
.node-content {
|
|
padding: 20px;
|
|
}
|
|
|
|
.node-ports {
|
|
justify-content: space-between;
|
|
padding: 8px;
|
|
}
|
|
|
|
.input-ports, .output-ports {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
align-items: flex-start;
|
|
justify-content: space-evenly;
|
|
}
|
|
|
|
.output-ports {
|
|
align-items: flex-end;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
align-items: center;
|
|
column-gap: 10px;
|
|
padding: 2px 5px;
|
|
}
|
|
|
|
.delete-btn {
|
|
cursor: pointer;
|
|
opacity: 0.7;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.delete-btn:hover {
|
|
opacity: 1;
|
|
}
|
|
`
|
|
];
|
|
}
|
|
|
|
render() {
|
|
return html`
|
|
${this.renderNodeHeader()}
|
|
<div class="node-body">
|
|
<div class="input-ports">
|
|
${this.inputs.map((input, idx) => html`
|
|
<tp-flow-node-port class="port" exportparts="connection-point"
|
|
portType="input"
|
|
.portId=${idx}
|
|
.portName=${input.name}
|
|
.tagContent=${input.tagContent}
|
|
.shape=${input.shape}
|
|
.color=${input.color}
|
|
.dataTypes=${input.dataTypes}
|
|
@mousedown="${this._handlePortClick}">
|
|
</tp-flow-node-port>
|
|
`)}
|
|
</div>
|
|
|
|
<div class="node-content" part="node-content">
|
|
${this.renderNodeContent()}
|
|
</div>
|
|
|
|
<div class="output-ports">
|
|
${this.outputs.map((output, idx) => html`
|
|
<tp-flow-node-port class="port" exportparts="connection-point"
|
|
portType="output"
|
|
.portId=${idx}
|
|
.portName=${output.name}
|
|
.tagContent=${output.tagContent}
|
|
.shape=${output.shape}
|
|
.color=${output.color}
|
|
.dataTypes=${output.dataTypes}
|
|
@mousedown="${this._handlePortClick}">
|
|
</tp-flow-node-port>
|
|
`)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderNodeHeader() {
|
|
return html`
|
|
<header drag-node>
|
|
A Node
|
|
${!this.readonly ? html`<div class="delete-btn" @click="${this._handleDelete}">✕</div>` : null}
|
|
</header>
|
|
`;
|
|
}
|
|
|
|
renderNodeContent() {
|
|
console.warn('Your node should override the renderNodeContent method.');
|
|
return null;
|
|
}
|
|
|
|
static get properties() {
|
|
return {
|
|
inputs: { type: Array },
|
|
outputs: { type: Array },
|
|
x: { type: Number },
|
|
y: { type: Number },
|
|
data: { type: Object },
|
|
readonly: { type: Boolean, reflect: true }
|
|
};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this.inputs = [];
|
|
this.outputs = [];
|
|
this.x = 0;
|
|
this.y = 0;
|
|
this.data = {};
|
|
this.readonly = false;
|
|
|
|
this._handleConnectionDragStart = this._handleConnectionDragStart.bind(this);
|
|
this._handleConnectionDragEnd = this._handleConnectionDragEnd.bind(this);
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
|
|
this.addEventListener('connection-drag-start', this._handleConnectionDragStart);
|
|
this.addEventListener('connection-drag-end', this._handleConnectionDragEnd);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
|
|
this.removeEventListener('connection-drag-start', this._handleConnectionDragStart);
|
|
this.removeEventListener('connection-drag-end', this._handleConnectionDragEnd);
|
|
}
|
|
|
|
/**
|
|
* Called when a connection drag starts somewhere on the canvas.
|
|
* The detail contains the source node, port ID, and the source port's dataTypes.
|
|
* Each node should evaluate its input ports and dim incompatible ones.
|
|
*
|
|
* Override `checkPortCompatibility(sourceDataTypes, targetDataTypes)` to implement
|
|
* type-aware dimming. Return true if compatible, false if not.
|
|
*
|
|
* @param {CustomEvent} e — detail: { sourceNode, sourcePortId, sourceDataTypes }
|
|
*/
|
|
_handleConnectionDragStart(e) {
|
|
// Don't dim ports on the source node itself.
|
|
if (e.detail.sourceNode === this) {
|
|
return;
|
|
}
|
|
|
|
const sourceDataTypes = e.detail.sourceDataTypes || [];
|
|
|
|
// Evaluate each input port.
|
|
const inputPorts = this.shadowRoot.querySelectorAll('.input-ports tp-flow-node-port');
|
|
|
|
inputPorts.forEach(port => {
|
|
const targetDataTypes = port.dataTypes || [];
|
|
|
|
const isCompatible = this.checkPortCompatibility(sourceDataTypes, targetDataTypes);
|
|
|
|
if (isCompatible) {
|
|
port.removeAttribute('dimmed');
|
|
port.setAttribute('compatible', '');
|
|
} else {
|
|
port.setAttribute('dimmed', '');
|
|
port.removeAttribute('compatible');
|
|
}
|
|
});
|
|
}
|
|
|
|
_handleConnectionDragEnd() {
|
|
const ports = this.shadowRoot.querySelectorAll('tp-flow-node-port');
|
|
|
|
ports.forEach(port => {
|
|
port.removeAttribute('dimmed');
|
|
port.removeAttribute('compatible');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Override this method to implement type-aware compatibility checking.
|
|
* Default: everything is compatible (no dimming).
|
|
*
|
|
* @param {string[]} sourceDataTypes — data types of the source port
|
|
* @param {string[]} targetDataTypes — data types of the target port
|
|
* @returns {boolean} true if compatible
|
|
*/
|
|
checkPortCompatibility(sourceDataTypes, targetDataTypes) {
|
|
return true;
|
|
}
|
|
|
|
updated(changes) {
|
|
super.updated(changes);
|
|
|
|
this.dispatchEvent(new CustomEvent('update-layout', { detail: this, bubbles: true, composed: true }));
|
|
}
|
|
|
|
_handlePortClick(e) {
|
|
e.stopPropagation();
|
|
|
|
this.dispatchEvent(new CustomEvent('port-click', {
|
|
detail: {
|
|
node: this,
|
|
port: e.target,
|
|
dataTypes: e.target.dataTypes,
|
|
},
|
|
bubbles: true,
|
|
composed: true
|
|
}));
|
|
}
|
|
|
|
_handleDelete(e) {
|
|
e.stopPropagation();
|
|
this.dispatchEvent(new CustomEvent('node-delete-requested', {
|
|
detail: { nodeId: this.id },
|
|
bubbles: true,
|
|
composed: true
|
|
}));
|
|
}
|
|
|
|
exportData() {
|
|
const transform = window.getComputedStyle(this).transform;
|
|
const matrix = new DOMMatrix(transform);
|
|
|
|
return {
|
|
id: this.id,
|
|
type: super.tagName.toLowerCase(),
|
|
position: {
|
|
x: matrix.m41,
|
|
y: matrix.m42
|
|
},
|
|
data: this.data
|
|
};
|
|
}
|
|
|
|
importData(data) {
|
|
this.id = data.id;
|
|
this.data = data.data;
|
|
|
|
if (data.position) {
|
|
this.style.transform = `translate(${data.position.x}px, ${data.position.y}px)`;
|
|
}
|
|
}
|
|
|
|
dispatchDataUpdate() {
|
|
this.dispatchEvent(new CustomEvent('flow-changed', {
|
|
detail: {
|
|
type: 'node-data-changed',
|
|
data: this.exportData()
|
|
},
|
|
bubbles: true,
|
|
composed: true
|
|
}));
|
|
}
|
|
}
|