From 083a25f302fdb9fbebbf4b2d94dc2f8e5ee511f1 Mon Sep 17 00:00:00 2001 From: pk Date: Tue, 25 Feb 2025 19:07:21 +0100 Subject: [PATCH] Fixes and improvements --- connections.js | 104 ++++++++++++++++++++++++++--------------- package.json | 6 ++- panning.js | 32 ++++++++++--- tp-flow-node-port.js | 109 +++++++++++++++++++++++++++++++++++++++++++ tp-flow-node.js | 74 ++++++++++++++--------------- tp-flow-nodes.js | 7 ++- 6 files changed, 247 insertions(+), 85 deletions(-) create mode 100644 tp-flow-node-port.js diff --git a/connections.js b/connections.js index 5642177..c10013e 100644 --- a/connections.js +++ b/connections.js @@ -9,16 +9,18 @@ export const connectionStyles = css` transition: stroke 0.3s ease, stroke-width 0.3s ease; pointer-events: all; cursor: pointer; + stroke: var(--connection-stroke-color, #999); + stroke-width: var(--connection-stroke-width, 3); } .connections path:hover { - stroke: #999; - stroke-width: 3; + stroke: var(--connection-stroke-color-hover, #999); + stroke-width: var(--connection-stroke-width-hover, 3); } .connections path.selected { - stroke: #3498db; - stroke-width: 3; + stroke: var(--connection-stroke-color-selected, #3498db); + stroke-width: var(--connection-stroke-width-selected, 3); } .delete-button-group { @@ -55,45 +57,56 @@ export const connections = function(superClass) { this.connections = []; this.draggingConnection = null; this.mousePosition = { x: 0, y: 0 }; + this._updatePreviewConnection = this._updatePreviewConnection.bind(this) + this._conDocClick = this._conDocClick.bind(this); } firstUpdated() { + super.firstUpdated(); + this.addEventListener('port-click', this._handlePortClick); - document.addEventListener('mousemove', this._updatePreviewConnection.bind(this)); + document.addEventListener('mousemove', this._updatePreviewConnection); + document.addEventListener('click', this._conDocClick); + } + + disconnectedCallback() { + super.disconnectedCallback(); - document.addEventListener('click', (e) => { - // Check if clicking on an output port - const path = e.composedPath(); - const isOutputPort = path.some(el => + this.removeEventListener('port-click', this._handlePortClick); + document.removeEventListener('mousemove', this._updatePreviewConnection); + document.removeEventListener('click', this._conDocClick); + } + + _conDocClick(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('output-ports') + el.classList.contains('input-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; + // If not an input port, cancel the connection + if (!isInputPort) { + this.draggingConnection = null; this.requestUpdate(); } - }); - - super.firstUpdated(); + } + + // Handle existing connection deselection + if (e.target === this.canvas) { + this.selectedConnection = null; + this.requestUpdate(); + } } _updatePreviewConnection(e) { @@ -117,6 +130,7 @@ export const connections = function(superClass) { width: ${bounds.width}px; height: ${bounds.height}px; pointer-events: none; + z-index: -1; ` : ` position: absolute; top: 0; @@ -124,10 +138,11 @@ export const connections = function(superClass) { width: 100%; height: 100%; pointer-events: none; + z-index: -1; `; return html` - conn.sourceNodeId === this.draggingConnection.sourceNodeId && conn.sourcePortId === this.draggingConnection.sourcePortId && diff --git a/package.json b/package.json index 1aeec1e..1840926 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tp/tp-flow-nodes", - "version": "1.0.0", + "version": "1.1.0", "description": "", "main": "tp-flow-nodes.js", "scripts": { @@ -13,6 +13,8 @@ "author": "trading_peter", "license": "Apache-2.0", "dependencies": { - "lit": "^3.0.0" + "lit": "^3.0.0", + "@tp/helpers": "^2.0.0", + "@tp/tp-timeout-strip": "^1.0.0" } } diff --git a/panning.js b/panning.js index 54fe7bd..7087ec3 100644 --- a/panning.js +++ b/panning.js @@ -7,6 +7,8 @@ export const panning = function(superClass) { isDragging: { type: Boolean }, currentX: { type: Number }, currentY: { type: Number }, + currentNodeX: { type: Number }, + currentNodeY: { type: Number }, initialX: { type: Number }, initialY: { type: Number }, xOffset: { type: Number }, @@ -20,6 +22,8 @@ export const panning = function(superClass) { this.isDragging = false; this.currentX = 0; this.currentY = 0; + this.currentNodeX = 0; + this.currentNodeY = 0; this.initialX = 0; this.initialY = 0; this.xOffset = 0; @@ -51,8 +55,17 @@ export const panning = function(superClass) { _bringToFront(element) { if (!(element instanceof TpFlowNode)) return; - this.highestZIndex++; - element.style.zIndex = this.highestZIndex; + // Get all elements and filter for TpFlowNode instances + const nodes = Array.from(this.shadowRoot.querySelector('.canvas').children) + .filter(node => node instanceof TpFlowNode); + + for (const node of nodes) { + node.style.zIndex = 1; + node.classList.remove('focused'); + } + + element.style.zIndex = 2; + element.classList.add('focused'); } _startDrag(e) { @@ -61,6 +74,11 @@ export const panning = function(superClass) { if (topNode) { this.targetElement = topNode; this._bringToFront(topNode); + + // Check if a element with the "drag-node" attribute is part of the event path. Only then we can start dragging. + if (!e.composedPath().some(el => typeof el.hasAttribute === 'function' && el.hasAttribute('drag-node'))) { + return; + } } else { this.targetElement = this.canvas; } @@ -96,10 +114,10 @@ export const panning = function(superClass) { `translate(${this.currentX}px, ${this.currentY}px) scale(${this.scale})`; } else { // For nodes, compensate for canvas scale - this.currentX = (e.clientX - this.initialX) / this.scale; - this.currentY = (e.clientY - this.initialY) / this.scale; + this.currentNodeX = (e.clientX - this.initialX) / this.scale; + this.currentNodeY = (e.clientY - this.initialY) / this.scale; this.targetElement.style.transform = - `translate(${this.currentX}px, ${this.currentY}px)`; + `translate(${this.currentNodeX}px, ${this.currentNodeY}px)`; } this.requestUpdate(); @@ -113,8 +131,8 @@ export const panning = function(superClass) { data: { nodeId: this.targetElement.id, position: { - x: this.currentX, - y: this.currentY + x: this.currentNodeX, + y: this.currentNodeY } } }); diff --git a/tp-flow-node-port.js b/tp-flow-node-port.js new file mode 100644 index 0000000..1183fdc --- /dev/null +++ b/tp-flow-node-port.js @@ -0,0 +1,109 @@ +/** +@license +Copyright (c) 2025 trading_peter +This program is available under Apache License Version 2.0 +*/ + +import '@tp/tp-timeout-strip/tp-timeout-strip.js'; +import { LitElement, html, css } from 'lit'; + +class TpFlowNodePort extends LitElement { + static get styles() { + return [ + css` + :host { + display: block; + position: relative; + } + + .connectionPoint { + width: 12px; + height: 12px; + background: #666; + border-radius: 50%; + cursor: pointer; + } + + .connectionPoint:hover { + background: #888; + } + + .tag { + position: absolute; + background: #888; + visibility: hidden; + pointer-events: none; + } + + :host([portType="input"]) .tag { + left: 0; + top: 0; + transform: translateX(calc(-100% - 10px)); + } + + :host([portType="output"]) .tag { + right: 0; + top: 0; + transform: translateX(calc(100% + 10px)); + } + + :host(:hover) .tag, + .tag[visible] { + visibility: visible; + pointer-events: all; + } + ` + ]; + } + + render() { + const { showTag, errorMsg, tagContent } = this; + + return html` +
+ + ${showTag || Boolean(tagContent) ? html` +
+ ${errorMsg ? html` +
+ ${errorMsg} +
+ ` : null} + ${tagContent ? html` +
${tagContent}
+ ` : null} + +
+ ` : null} + `; + } + + static get properties() { + return { + portType: { type: String, reflect: true }, + portId: { type: Number, reflect: true }, + portName: { type: String }, + tagContent: { type: String }, + errorMsg: { type: String }, + showTag: { type: Boolean }, + }; + } + + showConnectionError(msg, timeout = 0) { + this.errorMsg = msg; + this.showTag = true; + + if (timeout > 0) { + this.updateComplete.then(() => { + this.shadowRoot.querySelector('tp-timeout-strip').show(timeout); + }); + } + } + + _onTimeout() { + this.showTag = false; + this.errorMsg = null; + } +} + +window.customElements.define('tp-flow-node-port', TpFlowNodePort); \ No newline at end of file diff --git a/tp-flow-node.js b/tp-flow-node.js index a5ff961..8c95663 100644 --- a/tp-flow-node.js +++ b/tp-flow-node.js @@ -4,6 +4,7 @@ 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'; // tp-flow-node.js @@ -46,24 +47,14 @@ export class TpFlowNode extends LitElement { align-items: flex-end; } - .port { - width: 12px; - height: 12px; - background: #666; - border-radius: 50%; - cursor: pointer; - } - - .port:hover { - background: #888; + header { + display: flex; + align-items: center; + column-gap: 10px; + padding: 2px 5px; } .delete-btn { - position: absolute; - top: 5px; - right: 5px; - width: 16px; - height: 16px; cursor: pointer; opacity: 0.7; transition: opacity 0.2s; @@ -78,16 +69,17 @@ export class TpFlowNode extends LitElement { render() { return html` + ${this.renderNodeHeader()}
-
${this.inputs.map((input, idx) => html` -
-
+ + `)}
@@ -97,18 +89,28 @@ export class TpFlowNode extends LitElement {
${this.outputs.map((output, idx) => html` -
-
+ + `)}
`; } + renderNodeHeader() { + return html` +
+ A Node +
+
+ `; + } + renderNodeContent() { console.warn('Your node should override the renderNodeContent method.'); return null; @@ -133,19 +135,17 @@ export class TpFlowNode extends LitElement { this.data = {}; } + updated(changes) { + super.updated(changes); + + this.dispatchEvent(new CustomEvent('update-layout', { detail: this, bubbles: true, composed: true })); + } + _handlePortClick(e) { e.stopPropagation(); // Prevents the event from getting caught by the panning action. - const portEl = e.target; - const detail = { - nodeId: this.id, - portType: portEl.dataset.portType, - portId: portEl.dataset.portId, - portName: portEl.dataset.portName - }; - this.dispatchEvent(new CustomEvent('port-click', { - detail, + detail: { node: this, port: e.target }, bubbles: true, composed: true })); diff --git a/tp-flow-nodes.js b/tp-flow-nodes.js index ccc4400..36d0297 100644 --- a/tp-flow-nodes.js +++ b/tp-flow-nodes.js @@ -8,6 +8,7 @@ import { LitElement, html, css } from 'lit'; import { connections, connectionStyles } from './connections.js'; import { panning } from './panning.js'; import { zoom } from './zoom.js'; +import { repeat } from 'lit/directives/repeat.js'; export class TpFlowNodes extends zoom(panning(connections(LitElement))) { static nodeTypes = new Map(); @@ -36,10 +37,11 @@ export class TpFlowNodes extends zoom(panning(connections(LitElement))) { ]; } + // Using the repeat directive to render the nodes is important here to prevent the nodes from running into a inconsistent data state when nodes are removed. render() { return html`
- ${this.nodes.map(node => html`${node}`)} + ${repeat(this.nodes, node => node.id, node => html`${node}`)} ${this._renderConnections()}
`; @@ -61,6 +63,9 @@ export class TpFlowNodes extends zoom(panning(connections(LitElement))) { connectedCallback() { super.connectedCallback(); this.addEventListener('node-delete-requested', this._boundDeleteHandler); + this.addEventListener('update-layout', () => { + this.requestUpdate(); + }) } disconnectedCallback() {