From cc2ef506e167c1fbdaffc9f22519c7c235afa7e5 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Tue, 13 May 2025 14:12:10 +0100 Subject: [PATCH] Add node suggestion api to editor and apply to typeSearch --- .../@node-red/editor-client/src/js/nodes.js | 32 +- .../editor-client/src/js/ui/typeSearch.js | 151 +++-- .../src/js/ui/view-annotations.js | 2 +- .../@node-red/editor-client/src/js/ui/view.js | 563 ++++++++++++++---- .../editor-client/src/sass/flow.scss | 15 + 5 files changed, 590 insertions(+), 173 deletions(-) diff --git a/packages/node_modules/@node-red/editor-client/src/js/nodes.js b/packages/node_modules/@node-red/editor-client/src/js/nodes.js index 131d40d18..2f0bc810d 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/nodes.js +++ b/packages/node_modules/@node-red/editor-client/src/js/nodes.js @@ -689,7 +689,7 @@ RED.nodes = (function() { } } - function addNode(n) { + function addNode(n, opt) { let newNode if (!n.__isProxy__) { newNode = new Proxy(n, nodeProxyHandler) @@ -728,7 +728,7 @@ RED.nodes = (function() { nodeLinks[n.id] = {in:[],out:[]}; } } - RED.events.emit('nodes:add',newNode); + RED.events.emit('nodes:add',newNode, opt); return newNode } function addLink(l) { @@ -1848,14 +1848,23 @@ RED.nodes = (function() { * - id:copy - import with new id * - id:replace - import over the top of existing * - modules: map of module:version - hints for unknown nodes + * - applyNodeDefaults - whether to apply default values to the imported nodes (default: false) */ function importNodes(newNodesObj,options) { // createNewIds,createMissingWorkspace) { - const defOpts = { generateIds: false, addFlow: false, markChanged: false, reimport: false, importMap: {} } + const defOpts = { + generateIds: false, + addFlow: false, + markChanged: false, + reimport: false, + importMap: {}, + applyNodeDefaults: false + } options = Object.assign({}, defOpts, options) options.importMap = options.importMap || {} const createNewIds = options.generateIds; const reimport = (!createNewIds && !!options.reimport) const createMissingWorkspace = options.addFlow; + const applyNodeDefaults = options.applyNodeDefaults; var i; var n; var newNodes; @@ -2234,6 +2243,13 @@ RED.nodes = (function() { for (d in def.defaults) { if (def.defaults.hasOwnProperty(d)) { configNode[d] = n[d]; + if (applyNodeDefaults && n[d] === undefined) { + // If the node has a default value, but the imported node does not + // set it, then set it to the default value + if (def.defaults[d].value !== undefined) { + configNode[d] = JSON.parse(JSON.stringify(def.defaults[d].value)) + } + } configNode._config[d] = JSON.stringify(n[d]); if (def.defaults[d].type) { configNode._configNodeReferences.add(n[d]) @@ -2508,6 +2524,13 @@ RED.nodes = (function() { for (d in node._def.defaults) { if (node._def.defaults.hasOwnProperty(d) && d !== 'inputs' && d !== 'outputs') { node[d] = n[d]; + if (applyNodeDefaults && n[d] === undefined) { + // If the node has a default value, but the imported node does not + // set it, then set it to the default value + if (node._def.defaults[d].value !== undefined) { + node[d] = JSON.parse(JSON.stringify(node._def.defaults[d].value)) + } + } node._config[d] = JSON.stringify(n[d]); } } @@ -2761,7 +2784,8 @@ RED.nodes = (function() { workspaces:new_workspaces, subflows:new_subflows, missingWorkspace: missingWorkspace, - removedNodes: removedNodes + removedNodes: removedNodes, + nodeMap: node_map } } diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/typeSearch.js b/packages/node_modules/@node-red/editor-client/src/js/ui/typeSearch.js index f284f2464..06e09ca19 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/typeSearch.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/typeSearch.js @@ -14,6 +14,7 @@ RED.typeSearch = (function() { var addCallback; var cancelCallback; var moveCallback; + var suggestCallback var typesUsed = {}; @@ -104,13 +105,13 @@ RED.typeSearch = (function() { var index = Math.max(0,selected); if (index < children.length) { var n = $(children[index]).find(".red-ui-editableList-item-content").data('data'); - if (!/^_action_:/.test(n.type)) { + if (!n.nodes && !/^_action_:/.test(n.type)) { typesUsed[n.type] = Date.now(); } if (n.def.outputs === 0) { confirm(n); } else { - addCallback(n.type,true); + addCallback(n, true); } $("#red-ui-type-search-input").val("").trigger("keyup"); setTimeout(function() { @@ -142,7 +143,7 @@ RED.typeSearch = (function() { if (activeFilter === "" ) { return true; } - if (data.recent || data.common) { + if (data.recent || data.common || data.suggestion) { return false; } return (activeFilter==="")||(data.index.indexOf(activeFilter) > -1); @@ -164,56 +165,98 @@ RED.typeSearch = (function() { } return Ai-Bi; }, - addItem: function(container,i,object) { - var def = object.def; - object.index = object.type.toLowerCase(); - if (object.separator) { + addItem: function(container, i, nodeItem) { + // nodeItem can take multiple forms + // - A node type: {type: "inject", def: RED.nodes.getType("inject"), label: "Inject"} + // - A flow suggestion: { suggestion: true, nodes: [] } + // - A placeholder suggestion: { suggestionPlaceholder: true, label: 'loading suggestions...' } + + let nodeDef = nodeItem.def; + let nodeType = nodeItem.type; + if (nodeItem.suggestion && nodeItem.nodes.length > 0) { + nodeDef = RED.nodes.getType(nodeItem.nodes[0].type); + nodeType = nodeItem.nodes[0].type; + } + + nodeItem.index = nodeItem.type?.toLowerCase() || ''; + if (nodeItem.separator) { container.addClass("red-ui-search-result-separator") } - var div = $('
',{class:"red-ui-search-result"}).appendTo(container); - - var nodeDiv = $('
',{class:"red-ui-search-result-node"}).appendTo(div); - if (object.type === "junction") { + const div = $('
',{class:"red-ui-search-result"}).appendTo(container); + const nodeDiv = $('
',{class:"red-ui-search-result-node"}).appendTo(div); + + if (nodeItem.suggestionPlaceholder) { + nodeDiv.addClass("red-ui-palette-icon-suggestion") + const iconContainer = $('
',{class:"red-ui-palette-icon-container"}).appendTo(nodeDiv); + $('').appendTo(iconContainer); + } else if (nodeType === "junction") { nodeDiv.addClass("red-ui-palette-icon-junction"); - } else if (/^_action_:/.test(object.type)) { - nodeDiv.addClass("red-ui-palette-icon-junction") } else { - var colour = RED.utils.getNodeColor(object.type,def); - nodeDiv.css('backgroundColor',colour); + nodeDiv.css('backgroundColor', RED.utils.getNodeColor(nodeType, nodeDef)); } - var icon_url = RED.utils.getNodeIcon(def); - var iconContainer = $('
',{class:"red-ui-palette-icon-container"}).appendTo(nodeDiv); - RED.utils.createIconElement(icon_url, iconContainer, false); + if (nodeDef) { + // Add the node icon + const icon_url = RED.utils.getNodeIcon(nodeDef); + const iconContainer = $('
',{class:"red-ui-palette-icon-container"}).appendTo(nodeDiv); + RED.utils.createIconElement(icon_url, iconContainer, false); + } - if (/^subflow:/.test(object.type)) { - var sf = RED.nodes.subflow(object.type.substring(8)); + if (/^subflow:/.test(nodeType)) { + var sf = RED.nodes.subflow(nodeType.substring(8)); if (sf.in.length > 0) { $('
',{class:"red-ui-search-result-node-port"}).appendTo(nodeDiv); } if (sf.out.length > 0) { $('
',{class:"red-ui-search-result-node-port red-ui-search-result-node-output"}).appendTo(nodeDiv); } - } else if (!/^_action_:/.test(object.type) && object.type !== "junction") { - if (def.inputs > 0) { + } else if (nodeDef && nodeType !== "junction") { + if (nodeDef.inputs > 0) { $('
',{class:"red-ui-search-result-node-port"}).appendTo(nodeDiv); } - if (def.outputs > 0) { + if (nodeDef.outputs > 0) { $('
',{class:"red-ui-search-result-node-port red-ui-search-result-node-output"}).appendTo(nodeDiv); } } var contentDiv = $('
',{class:"red-ui-search-result-description"}).appendTo(div); - var label = object.label; - object.index += "|"+label.toLowerCase(); + var label = nodeItem.label; + nodeItem.index += "|"+label.toLowerCase(); $('
',{class:"red-ui-search-result-node-label"}).text(label).appendTo(contentDiv); + nodeItem.element = container; + div.on("click", function(evt) { evt.preventDefault(); - confirm(object); + confirm(nodeItem); }); + div.on('mouseenter', function() { + if (suggestCallback) { + if (nodeItem.nodes) { + // This is a multi-node suggestion + suggestCallback({ + nodes: nodeItem.nodes + }); + } else if (nodeItem.type) { + // Single node suggestion + suggestCallback({ + nodes: [{ + x: 0, + y: 0, + type: nodeItem.type + }] + }); + } + } + }) + div.on('mouseleave', function() { + if (suggestCallback) { + suggestCallback(null); + } + }) + }, scrollOnAdd: false }); @@ -221,10 +264,10 @@ RED.typeSearch = (function() { } function confirm(def) { hide(); - if (!/^_action_:/.test(def.type)) { + if (!def.nodes && !/^_action_:/.test(def.type)) { typesUsed[def.type] = Date.now(); } - addCallback(def.type); + addCallback(def); } function handleMouseActivity(evt) { @@ -274,6 +317,7 @@ RED.typeSearch = (function() { addCallback = opts.add; cancelCallback = opts.cancel; moveCallback = opts.move; + suggestCallback = opts.suggest; RED.events.emit("type-search:open"); //shade.show(); if ($("#red-ui-main-container").height() - opts.y - 195 < 0) { @@ -356,11 +400,11 @@ RED.typeSearch = (function() { (!filter.output || def.outputs > 0) } function refreshTypeList(opts) { - var i; + let i; searchResults.editableList('empty'); searchInput.searchBox('value','').focus(); selected = -1; - var common = [ + const common = [ 'inject','debug','function','change','switch','junction' ].filter(function(t) { return applyFilter(opts.filter,t,RED.nodes.getType(t)); }); @@ -371,7 +415,7 @@ RED.typeSearch = (function() { // common.push('_action_:core:split-wire-with-link-nodes') // } - var recentlyUsed = Object.keys(typesUsed); + let recentlyUsed = Object.keys(typesUsed); recentlyUsed.sort(function(a,b) { return typesUsed[b]-typesUsed[a]; }); @@ -379,9 +423,10 @@ RED.typeSearch = (function() { return applyFilter(opts.filter,t,RED.nodes.getType(t)) && common.indexOf(t) === -1; }); - var items = []; + const items = []; + RED.nodes.registry.getNodeTypes().forEach(function(t) { - var def = RED.nodes.getType(t); + const def = RED.nodes.getType(t); if (def.set?.enabled !== false && def.category !== 'config' && t !== 'unknown' && t !== 'tab') { items.push({type:t,def: def, label:getTypeLabel(t,def)}); } @@ -389,18 +434,46 @@ RED.typeSearch = (function() { items.push({ type: 'junction', def: { inputs:1, outputs: 1, label: 'junction', type: 'junction'}, label: 'junction' }) items.sort(sortTypeLabels); - var commonCount = 0; - var item; - var index = 0; + let index = 0; + + // const suggestionItem = { + // suggestionPlaceholder: true, + // label: 'loading suggestions...', + // separator: true, + // i: index++ + // } + // searchResults.editableList('addItem', suggestionItem); + // setTimeout(function() { + // searchResults.editableList('removeItem', suggestionItem); + + // const suggestedItem = { + // suggestion: true, + // label: 'Change/Debug Combo', + // separator: true, + // i: suggestionItem.i, + // nodes: [ + // { id: 'suggestion-1', type: 'change', x: 0, y: 0, wires:[['suggestion-2']] }, + // { id: 'suggestion-2', type: 'function', outputs: 3, x: 200, y: 0, wires:[['suggestion-3'],['suggestion-4'],['suggestion-6']] }, + // { id: 'suggestion-3', _g: 'suggestion-group-1', type: 'debug', x: 375, y: -40 }, + // { id: 'suggestion-4', _g: 'suggestion-group-1', type: 'debug', x: 375, y: 0 }, + // { id: 'suggestion-5', _g: 'suggestion-group-1', type: 'debug', x: 410, y: 40 }, + // { id: 'suggestion-6', type: 'junction', wires: [['suggestion-5']], x:325, y:40 } + // ] + // } + // searchResults.editableList('addItem', suggestedItem); + // }, 1000) + for(i=0;i 0 || drag_line.virtualLink) ) { + src = drag_line.node; + src_port = drag_line.port; + dst = targetNode; + } else if (drag_line.portType === PORT_TYPE_INPUT && (targetNode.outputs > 0 || drag_line.virtualLink)) { + src = targetNode; + dst = drag_line.node; + src_port = 0; + } + if (src && dst) { + var link = {source: src, sourcePort:src_port, target: dst}; + RED.nodes.addLink(link); + const historyEvent = RED.history.peek() + if (historyEvent.t === 'add') { + historyEvent.links = historyEvent.links || [] + historyEvent.links.push(link) + } else { + // TODO: importNodes *can* generate a multi history event + // but we don't currently support that + } + } + if (quickAddLink.el) { + quickAddLink.el.remove(); + } + quickAddLink = null; + } + updateActiveNodes(); + updateSelection(); + redraw(); + + return + } else { + type = type.type + } + } var nn; var historyEvent; if (/^_action_:/.test(type)) { @@ -1479,7 +1556,7 @@ RED.view = (function() { if (nn.type === 'junction') { nn = RED.nodes.addJunction(nn); } else { - nn = RED.nodes.add(nn); + nn = RED.nodes.add(nn, { source: 'typeSearch' }); } if (quickAddLink) { var drag_line = quickAddLink; @@ -1662,6 +1739,22 @@ RED.view = (function() { quickAddActive = false; ghostNode.remove(); } + }, + suggest: function (suggestion) { + if (suggestion?.nodes?.length > 0) { + // Reposition the suggestion relative to the existing ghost node position + const deltaX = suggestion.nodes[0].x - point[0] + const deltaY = suggestion.nodes[0].y - point[1] + suggestion.nodes.forEach(node => { + if (Object.hasOwn(node, 'x')) { + node.x = node.x - deltaX + } + if (Object.hasOwn(node, 'y')) { + node.y = node.y - deltaY + } + }) + } + setSuggestedFlow(suggestion); } }); @@ -4576,20 +4669,28 @@ RED.view = (function() { nodeLayer.selectAll(".red-ui-flow-subflow-port-input").remove(); nodeLayer.selectAll(".red-ui-flow-subflow-port-status").remove(); } - - var node = nodeLayer.selectAll(".red-ui-flow-node-group").data(activeNodes,function(d){return d.id}); + let nodesToDraw = activeNodes; + if (suggestedNodes.length > 0) { + nodesToDraw = [...activeNodes, ...suggestedNodes] + } + var node = nodeLayer.selectAll(".red-ui-flow-node-group").data(nodesToDraw,function(d){return d.id}); node.exit().each(function(d,i) { - RED.hooks.trigger("viewRemoveNode",{node:d,el:this}) + if (!d.__ghost) { + RED.hooks.trigger("viewRemoveNode",{node:d,el:this}) + } }).remove(); var nodeEnter = node.enter().insert("svg:g") .attr("class", "red-ui-flow-node red-ui-flow-node-group") - .classed("red-ui-flow-subflow", activeSubflow != null); + .classed("red-ui-flow-subflow", activeSubflow != null) nodeEnter.each(function(d,i) { this.__outputs__ = []; this.__inputs__ = []; var node = d3.select(this); + if (d.__ghost) { + node.classed("red-ui-flow-node-ghost",true); + } var nodeContents = document.createDocumentFragment(); var isLink = (d.type === "link in" || d.type === "link out") var hideLabel = d.hasOwnProperty('l')?!d.l : isLink; @@ -4624,19 +4725,21 @@ RED.view = (function() { bgButton.setAttribute("width",16); bgButton.setAttribute("height",node_height-12); bgButton.setAttribute("fill", RED.utils.getNodeColor(d.type,d._def)); - d3.select(bgButton) - .on("mousedown",function(d) {if (!lasso && isButtonEnabled(d)) {focusView();d3.select(this).attr("fill-opacity",0.2);d3.event.preventDefault(); d3.event.stopPropagation();}}) - .on("mouseup",function(d) {if (!lasso && isButtonEnabled(d)) { d3.select(this).attr("fill-opacity",0.4);d3.event.preventDefault();d3.event.stopPropagation();}}) - .on("mouseover",function(d) {if (!lasso && isButtonEnabled(d)) { d3.select(this).attr("fill-opacity",0.4);}}) - .on("mouseout",function(d) {if (!lasso && isButtonEnabled(d)) { - var op = 1; - if (d._def.button.toggle) { - op = d[d._def.button.toggle]?1:0.2; - } - d3.select(this).attr("fill-opacity",op); - }}) - .on("click",nodeButtonClicked) - .on("touchstart",function(d) { nodeButtonClicked.call(this,d); d3.event.preventDefault();}) + if (!d.__ghost) { + d3.select(bgButton) + .on("mousedown",function(d) {if (!lasso && isButtonEnabled(d)) {focusView();d3.select(this).attr("fill-opacity",0.2);d3.event.preventDefault(); d3.event.stopPropagation();}}) + .on("mouseup",function(d) {if (!lasso && isButtonEnabled(d)) { d3.select(this).attr("fill-opacity",0.4);d3.event.preventDefault();d3.event.stopPropagation();}}) + .on("mouseover",function(d) {if (!lasso && isButtonEnabled(d)) { d3.select(this).attr("fill-opacity",0.4);}}) + .on("mouseout",function(d) {if (!lasso && isButtonEnabled(d)) { + var op = 1; + if (d._def.button.toggle) { + op = d[d._def.button.toggle]?1:0.2; + } + d3.select(this).attr("fill-opacity",op); + }}) + .on("click",nodeButtonClicked) + .on("touchstart",function(d) { nodeButtonClicked.call(this,d); d3.event.preventDefault();}) + } buttonGroup.appendChild(bgButton); node[0][0].__buttonGroupButton__ = bgButton; @@ -4651,13 +4754,15 @@ RED.view = (function() { mainRect.setAttribute("ry", 5); mainRect.setAttribute("fill", RED.utils.getNodeColor(d.type,d._def)); node[0][0].__mainRect__ = mainRect; - d3.select(mainRect) - .on("mouseup",nodeMouseUp) - .on("mousedown",nodeMouseDown) - .on("touchstart",nodeTouchStart) - .on("touchend",nodeTouchEnd) - .on("mouseover",nodeMouseOver) - .on("mouseout",nodeMouseOut); + if (!d.__ghost) { + d3.select(mainRect) + .on("mouseup",nodeMouseUp) + .on("mousedown",nodeMouseDown) + .on("touchstart",nodeTouchStart) + .on("touchend",nodeTouchEnd) + .on("mouseover",nodeMouseOver) + .on("mouseout",nodeMouseOut); + } nodeContents.appendChild(mainRect); //node.append("rect").attr("class", "node-gradient-top").attr("rx", 6).attr("ry", 6).attr("height",30).attr("stroke","none").attr("fill","url(#gradient-top)").style("pointer-events","none"); //node.append("rect").attr("class", "node-gradient-bottom").attr("rx", 6).attr("ry", 6).attr("height",30).attr("stroke","none").attr("fill","url(#gradient-bottom)").style("pointer-events","none"); @@ -4739,7 +4844,10 @@ RED.view = (function() { node[0][0].appendChild(nodeContents); - RED.hooks.trigger("viewAddNode",{node:d,el:this}) + if (!d.__ghost) { + // Do not trigger hooks for ghost nodes + RED.hooks.trigger("viewAddNode",{node:d,el:this}) + } }); var nodesReordered = false; @@ -4862,13 +4970,15 @@ RED.view = (function() { var inputPorts = thisNode.selectAll(".red-ui-flow-port-input"); if ((!isLink || (showAllLinkPorts === -1 && !activeLinkNodes[d.id])) && d.inputs === 0 && !inputPorts.empty()) { inputPorts.each(function(d,i) { - RED.hooks.trigger("viewRemovePort",{ - node:d, - el:self, - port:d3.select(this)[0][0], - portType: "input", - portIndex: 0 - }) + if (!d.__ghost) { + RED.hooks.trigger("viewRemovePort",{ + node:d, + el:self, + port:d3.select(this)[0][0], + portType: "input", + portIndex: 0 + }) + } }).remove(); } else if (((isLink && (showAllLinkPorts===PORT_TYPE_INPUT||activeLinkNodes[d.id]))|| d.inputs === 1) && inputPorts.empty()) { var inputGroup = thisNode.append("g").attr("class","red-ui-flow-port-input"); @@ -4886,13 +4996,15 @@ RED.view = (function() { inputGroupPorts[0][0].__data__ = this.__data__; inputGroupPorts[0][0].__portType__ = PORT_TYPE_INPUT; inputGroupPorts[0][0].__portIndex__ = 0; - inputGroupPorts.on("mousedown",function(d){portMouseDown(d,PORT_TYPE_INPUT,0);}) - .on("touchstart",function(d){portMouseDown(d,PORT_TYPE_INPUT,0);d3.event.preventDefault();}) - .on("mouseup",function(d){portMouseUp(d,PORT_TYPE_INPUT,0);} ) - .on("touchend",function(d){portMouseUp(d,PORT_TYPE_INPUT,0);d3.event.preventDefault();} ) - .on("mouseover",function(d){portMouseOver(d3.select(this),d,PORT_TYPE_INPUT,0);}) - .on("mouseout",function(d) {portMouseOut(d3.select(this),d,PORT_TYPE_INPUT,0);}); - RED.hooks.trigger("viewAddPort",{node:d,el: this, port: inputGroup[0][0], portType: "input", portIndex: 0}) + if (!d.__ghost) { + inputGroupPorts.on("mousedown",function(d){portMouseDown(d,PORT_TYPE_INPUT,0);}) + .on("touchstart",function(d){portMouseDown(d,PORT_TYPE_INPUT,0);d3.event.preventDefault();}) + .on("mouseup",function(d){portMouseUp(d,PORT_TYPE_INPUT,0);} ) + .on("touchend",function(d){portMouseUp(d,PORT_TYPE_INPUT,0);d3.event.preventDefault();} ) + .on("mouseover",function(d){portMouseOver(d3.select(this),d,PORT_TYPE_INPUT,0);}) + .on("mouseout",function(d) {portMouseOut(d3.select(this),d,PORT_TYPE_INPUT,0);}); + RED.hooks.trigger("viewAddPort",{node:d,el: this, port: inputGroup[0][0], portType: "input", portIndex: 0}) + } } var numOutputs = d.outputs; if (isLink && d.type === "link out") { @@ -4907,13 +5019,15 @@ RED.view = (function() { // Remove extra ports while (this.__outputs__.length > numOutputs) { var port = this.__outputs__.pop(); - RED.hooks.trigger("viewRemovePort",{ - node:d, - el:this, - port:port, - portType: "output", - portIndex: this.__outputs__.length - }) + if (!d.__ghost) { + RED.hooks.trigger("viewRemovePort",{ + node:d, + el:this, + port:port, + portType: "output", + portIndex: this.__outputs__.length + }) + } port.remove(); } for(var portIndex = 0; portIndex < numOutputs; portIndex++ ) { @@ -4941,16 +5055,20 @@ RED.view = (function() { portPort.__data__ = this.__data__; portPort.__portType__ = PORT_TYPE_OUTPUT; portPort.__portIndex__ = portIndex; - portPort.addEventListener("mousedown", portMouseDownProxy); - portPort.addEventListener("touchstart", portTouchStartProxy); - portPort.addEventListener("mouseup", portMouseUpProxy); - portPort.addEventListener("touchend", portTouchEndProxy); - portPort.addEventListener("mouseover", portMouseOverProxy); - portPort.addEventListener("mouseout", portMouseOutProxy); + if (!d.__ghost) { + portPort.addEventListener("mousedown", portMouseDownProxy); + portPort.addEventListener("touchstart", portTouchStartProxy); + portPort.addEventListener("mouseup", portMouseUpProxy); + portPort.addEventListener("touchend", portTouchEndProxy); + portPort.addEventListener("mouseover", portMouseOverProxy); + portPort.addEventListener("mouseout", portMouseOutProxy); + } this.appendChild(portGroup); this.__outputs__.push(portGroup); - RED.hooks.trigger("viewAddPort",{node:d,el: this, port: portGroup, portType: "output", portIndex: portIndex}) + if (!d.__ghost) { + RED.hooks.trigger("viewAddPort",{node:d,el: this, port: portGroup, portType: "output", portIndex: portIndex}) + } } else { portGroup = this.__outputs__[portIndex]; } @@ -5067,8 +5185,10 @@ RED.view = (function() { } } } - - RED.hooks.trigger("viewRedrawNode",{node:d,el:this}) + if (!d.__ghost) { + // Only trigger redraw hooks for non-ghost nodes + RED.hooks.trigger("viewRedrawNode",{node:d,el:this}) + } }); if (nodesReordered) { @@ -5077,13 +5197,20 @@ RED.view = (function() { }) } + let junctionsToDraw = activeJunctions; + if (suggestedJunctions.length > 0) { + junctionsToDraw = [...activeJunctions, ...suggestedJunctions] + } var junction = junctionLayer.selectAll(".red-ui-flow-junction").data( - activeJunctions, + junctionsToDraw, d => d.id ) var junctionEnter = junction.enter().insert("svg:g").attr("class","red-ui-flow-junction") junctionEnter.each(function(d,i) { var junction = d3.select(this); + if (d.__ghost) { + junction.classed("red-ui-flow-junction-ghost",true); + } var contents = document.createDocumentFragment(); // d.added = true; var junctionBack = document.createElementNS("http://www.w3.org/2000/svg","rect"); @@ -5177,8 +5304,12 @@ RED.view = (function() { }) + let linksToDraw = activeLinks + if (suggestedLinks.length > 0) { + linksToDraw = [...activeLinks, ...suggestedLinks] + } var link = linkLayer.selectAll(".red-ui-flow-link").data( - activeLinks, + linksToDraw, function(d) { return d.source.id+":"+d.sourcePort+":"+d.target.id+":"+d.target.i; } @@ -5189,44 +5320,50 @@ RED.view = (function() { var l = d3.select(this); var pathContents = document.createDocumentFragment(); + if (d.__ghost) { + l.classed("red-ui-flow-link-ghost",true); + } + d.added = true; var pathBack = document.createElementNS("http://www.w3.org/2000/svg","path"); pathBack.__data__ = d; pathBack.setAttribute("class","red-ui-flow-link-background red-ui-flow-link-path"+(d.link?" red-ui-flow-link-link":"")); this.__pathBack__ = pathBack; pathContents.appendChild(pathBack); - d3.select(pathBack) - .on("mousedown",linkMouseDown) - .on("touchstart",linkTouchStart) - .on("mousemove", function(d) { - if (mouse_mode === RED.state.SLICING) { + if (!d.__ghost) { + d3.select(pathBack) + .on("mousedown",linkMouseDown) + .on("touchstart",linkTouchStart) + .on("mousemove", function(d) { + if (mouse_mode === RED.state.SLICING) { - selectedLinks.add(d) - l.classed("red-ui-flow-link-splice",true) - redraw() - } else if (mouse_mode === RED.state.SLICING_JUNCTION && !d.link) { - if (!l.classed("red-ui-flow-link-splice")) { - // Find intersection point - var lineLength = pathLine.getTotalLength(); - var pos; - var delta = Infinity; - for (var i = 0; i < lineLength; i++) { - var linePos = pathLine.getPointAtLength(i); - var posDeltaX = Math.abs(linePos.x-(d3.event.offsetX / scaleFactor)) - var posDeltaY = Math.abs(linePos.y-(d3.event.offsetY / scaleFactor)) - var posDelta = posDeltaX*posDeltaX + posDeltaY*posDeltaY - if (posDelta < delta) { - pos = linePos - delta = posDelta - } - } - d._sliceLocation = pos selectedLinks.add(d) l.classed("red-ui-flow-link-splice",true) redraw() + } else if (mouse_mode === RED.state.SLICING_JUNCTION && !d.link) { + if (!l.classed("red-ui-flow-link-splice")) { + // Find intersection point + var lineLength = pathLine.getTotalLength(); + var pos; + var delta = Infinity; + for (var i = 0; i < lineLength; i++) { + var linePos = pathLine.getPointAtLength(i); + var posDeltaX = Math.abs(linePos.x-(d3.event.offsetX / scaleFactor)) + var posDeltaY = Math.abs(linePos.y-(d3.event.offsetY / scaleFactor)) + var posDelta = posDeltaX*posDeltaX + posDeltaY*posDeltaY + if (posDelta < delta) { + pos = linePos + delta = posDelta + } + } + d._sliceLocation = pos + selectedLinks.add(d) + l.classed("red-ui-flow-link-splice",true) + redraw() + } } - } - }) + }) + } var pathOutline = document.createElementNS("http://www.w3.org/2000/svg","path"); pathOutline.__data__ = d; @@ -5688,16 +5825,21 @@ RED.view = (function() { * - generateIds - whether to automatically generate new ids for all imported nodes * - generateDefaultNames - whether to automatically update any nodes with clashing * default names + * - notify - whether to show a notification if the import was successful */ function importNodes(newNodesObj,options) { options = options || { addFlow: false, touchImport: false, generateIds: false, - generateDefaultNames: false + generateDefaultNames: false, + notify: true, + applyNodeDefaults: false } - var addNewFlow = options.addFlow - var touchImport = options.touchImport; + const addNewFlow = options.addFlow + const touchImport = options.touchImport; + const showNotification = options.notify ?? true + const applyNodeDefaults = options.applyNodeDefaults ?? false if (mouse_mode === RED.state.SELECTING_NODE) { return; @@ -5781,7 +5923,8 @@ RED.view = (function() { addFlow: addNewFlow, importMap: options.importMap, markChanged: true, - modules: modules + modules: modules, + applyNodeDefaults: applyNodeDefaults }); if (importResult) { var new_nodes = importResult.nodes; @@ -5792,6 +5935,7 @@ RED.view = (function() { var new_subflows = importResult.subflows; var removedNodes = importResult.removedNodes; var new_default_workspace = importResult.missingWorkspace; + const nodeMap = importResult.nodeMap; if (addNewFlow && new_default_workspace) { RED.workspaces.show(new_default_workspace.id); } @@ -5813,16 +5957,18 @@ RED.view = (function() { var dx = mouse_position[0]; var dy = mouse_position[1]; - if (movingSet.length() > 0) { - var root_node = movingSet.get(0).n; - dx = root_node.x; - dy = root_node.y; + if (!touchImport) { + if (movingSet.length() > 0) { + const root_node = movingSet.get(0).n; + dx = root_node.x; + dy = root_node.y; + } } var minX = 0; var minY = 0; var i; - var node,group; + var node; var l =movingSet.length(); for (i=0;i 0) { + counts.push(RED._("clipboard.flow",{count:new_workspaces.length})); + } + if (newNodeCount > 0) { + counts.push(RED._("clipboard.node",{count:newNodeCount})); + } + if (newGroupCount > 0) { + counts.push(RED._("clipboard.group",{count:newGroupCount})); + } + if (newConfigNodeCount > 0) { + counts.push(RED._("clipboard.configNode",{count:newConfigNodeCount})); + } + if (new_subflows.length > 0) { + counts.push(RED._("clipboard.subflow",{count:new_subflows.length})); + } + if (removedNodes && removedNodes.length > 0) { + counts.push(RED._("clipboard.replacedNodes",{count:removedNodes.length})); + } + if (counts.length > 0) { + var countList = "
  • "+counts.join("
  • ")+"
"; + RED.notify("

"+RED._("clipboard.nodesImported")+"

"+countList,{id:"clipboard"}); } - }) - var newGroupCount = new_groups.length; - var newJunctionCount = new_junctions.length; - if (new_workspaces.length > 0) { - counts.push(RED._("clipboard.flow",{count:new_workspaces.length})); } - if (newNodeCount > 0) { - counts.push(RED._("clipboard.node",{count:newNodeCount})); + return { + nodeMap } - if (newGroupCount > 0) { - counts.push(RED._("clipboard.group",{count:newGroupCount})); - } - if (newConfigNodeCount > 0) { - counts.push(RED._("clipboard.configNode",{count:newConfigNodeCount})); - } - if (new_subflows.length > 0) { - counts.push(RED._("clipboard.subflow",{count:new_subflows.length})); - } - if (removedNodes && removedNodes.length > 0) { - counts.push(RED._("clipboard.replacedNodes",{count:removedNodes.length})); - } - if (counts.length > 0) { - var countList = "
  • "+counts.join("
  • ")+"
"; - RED.notify("

"+RED._("clipboard.nodesImported")+"

"+countList,{id:"clipboard"}); - } - + } + return { + nodeMap: {} } } catch(error) { if (error.code === "import_conflict") { @@ -6307,6 +6459,157 @@ RED.view = (function() { node.highlighted = true; RED.view.redraw(); } + + /** + * Add a suggested flow to the workspace. + * + * This appears as a ghost set of nodes. + * + * { + * "nodes": [ + * { + * type: "node-type", + * x: 0, + * y: 0, + * } + * ] + * } + * If `nodes` is a single node without an id property, it will be generated + * using its default properties. + * + * If `nodes` has multiple, they must all have ids and will be assumed to be 'importable'. + * In other words, a piece of valid flow json. + * + * Limitations: + * - does not support groups, subflows or whole tabs + * - does not support config nodes + * + * To clear the current suggestion, pass in `null`. + * + * + * @param {Object} suggestion - The suggestion object + */ + function setSuggestedFlow (suggestion) { + if (!currentSuggestion && !suggestion) { + // Avoid unnecessary redraws + return + } + // Clear up any existing suggestion state + clearSuggestedFlow() + currentSuggestion = suggestion + if (suggestion?.nodes?.length > 0) { + const nodeMap = {} + const links = [] + suggestion.nodes.forEach(nodeConfig => { + if (!nodeConfig.type || nodeConfig.type === 'group' || nodeConfig.type === 'subflow' || nodeConfig.type === 'tab') { + // A node type we don't support previewing + return + } + + let node + + if (nodeConfig.type === 'junction') { + node = { + _def: {defaults:{}}, + type: 'junction', + z: RED.workspaces.active(), + id: RED.nodes.id(), + x: nodeConfig.x, + y: nodeConfig.y, + w: 0, h: 0, + outputs: 1, + inputs: 1, + dirty: true, + moved: true + } + } else { + const def = RED.nodes.getType(nodeConfig.type) + if (!def || def.category === 'config') { + // Unknown node or config node + // TODO: unknown node types could happen... + return + } + const result = createNode(nodeConfig.type, nodeConfig.x, nodeConfig.y) + if (!result) { + return + } + node = result.node + node["_"] = node._def._; + + for (d in node._def.defaults) { + if (node._def.defaults.hasOwnProperty(d) && d !== 'inputs' && d !== 'name') { + if (nodeConfig[d] !== undefined) { + node[d] = nodeConfig[d] + } else if (node._def.defaults[d].value) { + node[d] = JSON.parse(JSON.stringify(node._def.defaults[d].value)) + } + } + } + suggestedNodes.push(node) + } + if (node) { + node.id = nodeConfig.id || node.id + node.__ghost = true; + node.dirty = true; + nodeMap[node.id] = node + + if (nodeConfig.wires) { + nodeConfig.wires.forEach((wire, i) => { + if (wire.length > 0) { + wire.forEach(targetId => { + links.push({ + sourceId: nodeConfig.id || node.id, + sourcePort: i, + targetId: targetId, + targetPort: 0, + __ghost: true + }) + }) + } + }) + } + } + }) + links.forEach(link => { + const sourceNode = nodeMap[link.sourceId] + const targetNode = nodeMap[link.targetId] + if (sourceNode && targetNode) { + link.source = sourceNode + link.target = targetNode + suggestedLinks.push(link) + } + }) + } + if (ghostNode) { + if (suggestedNodes.length > 0) { + ghostNode.style('opacity', 0) + } else { + ghostNode.style('opacity', 1) + } + } + redraw(); + } + + function clearSuggestedFlow () { + currentSuggestion = null + suggestedNodes = [] + suggestedLinks = [] + } + + function applySuggestedFlow () { + if (currentSuggestion && currentSuggestion.nodes) { + const nodesToImport = currentSuggestion.nodes + setSuggestedFlow(null) + return importNodes(nodesToImport, { + generateIds: true, + touchImport: true, + notify: false, + // Ensure the node gets all of its defaults applied + applyNodeDefaults: true + }) + } + } + return { init: init, state:function(state) { @@ -6567,6 +6870,8 @@ RED.view = (function() { width: space_width, height: space_height }; - } + }, + setSuggestedFlow, + applySuggestedFlow }; })(); diff --git a/packages/node_modules/@node-red/editor-client/src/sass/flow.scss b/packages/node_modules/@node-red/editor-client/src/sass/flow.scss index ad055b97c..944536845 100644 --- a/packages/node_modules/@node-red/editor-client/src/sass/flow.scss +++ b/packages/node_modules/@node-red/editor-client/src/sass/flow.scss @@ -161,7 +161,15 @@ svg:not(.red-ui-workspace-lasso-active) { fill: var(--red-ui-group-default-label-color); } +.red-ui-flow-node-ghost { + opacity: 0.6; + rect.red-ui-flow-node { + stroke: var(--red-ui-node-border-placeholder); + stroke-dasharray:10,4; + stroke-width: 2; + } +} .red-ui-flow-node-unknown { stroke-dasharray:10,4; @@ -401,6 +409,13 @@ g.red-ui-flow-node-selected { g.red-ui-flow-link-selected path.red-ui-flow-link-line { stroke: var(--red-ui-node-selected-color); } + +g.red-ui-flow-link-ghost path.red-ui-flow-link-line { + stroke: var(--red-ui-node-border-placeholder); + stroke-width: 2; + stroke-dasharray: 10, 4; +} + g.red-ui-flow-link-unknown path.red-ui-flow-link-line { stroke: var(--red-ui-link-unknown-color); stroke-width: 2;