diff --git a/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json index 1aa92c370..57b9e681e 100644 --- a/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json +++ b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json @@ -573,6 +573,7 @@ "filter": "filter nodes", "search": "search modules", "addCategory": "Add new...", + "loadingSuggestions": "Loading suggestions...", "label": { "subflows": "subflows", "network": "network", diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/keyboard.js b/packages/node_modules/@node-red/editor-client/src/js/ui/keyboard.js index fdf4fe1ef..a9868bb05 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/keyboard.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/keyboard.js @@ -35,6 +35,7 @@ RED.keyboard = (function() { "backspace": 8, "delete": 46, "space": 32, + "tab": 9, ";":186, "=":187, "+":187, // <- QWERTY specific 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 089898a95..56f4bf145 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 @@ -182,7 +182,6 @@ RED.typeSearch = (function() { 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; @@ -262,7 +261,12 @@ RED.typeSearch = (function() { } + let activeSuggestion function updateSuggestion(nodeItem) { + if (nodeItem === activeSuggestion) { + return + } + activeSuggestion = nodeItem if (suggestCallback) { if (!nodeItem) { suggestCallback(null); @@ -319,6 +323,7 @@ RED.typeSearch = (function() { } visible = true; } else { + updateSuggestion(null) dialog.hide(); searchResultsDiv.hide(); } @@ -359,9 +364,7 @@ RED.typeSearch = (function() { },200); } function hide(fast) { - if (suggestCallback) { - suggestCallback(null); - } + updateSuggestion(null) if (visible) { visible = false; if (dialog !== null) { @@ -460,32 +463,37 @@ RED.typeSearch = (function() { 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) + if (!opts.context?.virtualLink) { + // Check for suggestion plugins + const suggestionPlugins = RED.plugins.getPluginsByType('node-red-flow-suggestion-source'); + if (suggestionPlugins.length > 0) { + const suggestionItem = { + suggestionPlaceholder: true, + label: RED._('palette.loadingSuggestions'), + separator: true, + i: index++ + } + searchResults.editableList('addItem', suggestionItem); + suggestionPlugins[0].getSuggestions(opts.context).then(function (suggestedFlows) { + searchResults.editableList('removeItem', suggestionItem); + if (!Array.isArray(suggestedFlows)) { + suggestedFlows = [suggestedFlows]; + } + suggestedFlows.forEach(function(suggestion, index) { + const suggestedItem = { + suggestion: true, + separator: index === suggestedFlows.length - 1, + i: suggestionItem.i, + ...suggestion + } + if (!suggestion.label && suggestion.nodes && suggestion.nodes.length === 1 && suggestion.nodes[0].type) { + suggestedItem.label = getTypeLabel(suggestion.nodes[0].type, RED.nodes.getType(suggestion.nodes[0].type)); + } + searchResults.editableList('addItem', suggestedItem); + }) + }) + } + } for(i=0;i visible }; })(); diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view-tools.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view-tools.js index f5e0df05f..4ce791369 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/view-tools.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view-tools.js @@ -1147,8 +1147,8 @@ RED.view.tools = (function() { t: 'multi', events: historyEvents }) + RED.nodes.dirty(true) } - RED.nodes.dirty(true) RED.view.redraw() } } diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view.js index 08ab0ec0a..65f947d5f 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/view.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view.js @@ -1402,10 +1402,12 @@ RED.view = (function() { var lastAddedX; var lastAddedWidth; - const context = {} + const context = { + workspace: RED.workspaces.active() + } if (quickAddLink) { - context.source = quickAddLink.node.id; + context.source = quickAddLink.node; context.sourcePort = quickAddLink.port; context.sourcePortType = quickAddLink.portType; if (quickAddLink?.virtualLink) { @@ -1421,6 +1423,7 @@ RED.view = (function() { y:clientY-mainPos.top+ node_height/2 + 5 - (oy-point[1]), disableFocus: touchTrigger, filter: filter, + context, move: function(dx,dy) { if (ghostNode) { var pos = d3.transform(ghostNode.attr("transform")).translate; @@ -1469,8 +1472,9 @@ RED.view = (function() { if (quickAddLink) { // Need to attach the link to the suggestion. This is assumed to be the first // node in the array - as that's the one we've focussed on. - const targetNode = importResult.nodeMap[type.nodes[0].id] - + // We need to map from the suggested node's id to the imported node's id, + // and then get the proxy object for the node + const targetNode = RED.nodes.node(importResult.nodeMap[type.nodes[0].id].id) const drag_line = quickAddLink; let src = null, dst, src_port; if (drag_line.portType === PORT_TYPE_OUTPUT && (targetNode.inputs > 0 || drag_line.virtualLink) ) { @@ -1743,15 +1747,11 @@ RED.view = (function() { 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] + const deltaX = (suggestion.nodes[0].x || 0) - point[0] + const deltaY = (suggestion.nodes[0].y || 0) - 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 - } + node.x = (node.x || 0) - deltaX + node.y = (node.y || 0) - deltaY }) } setSuggestedFlow(suggestion); @@ -4762,6 +4762,10 @@ RED.view = (function() { .on("touchend",nodeTouchEnd) .on("mouseover",nodeMouseOver) .on("mouseout",nodeMouseOut); + } else if (d.__ghostClick) { + d3.select(mainRect) + .on("mousedown",d.__ghostClick) + .on("touchstart",d.__ghostClick) } 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"); @@ -5004,6 +5008,10 @@ RED.view = (function() { .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}) + } else if (d.__ghostClick) { + inputGroupPorts + .on("mousedown",d.__ghostClick) + .on("touchstart",d.__ghostClick) } } var numOutputs = d.outputs; @@ -5062,6 +5070,9 @@ RED.view = (function() { portPort.addEventListener("touchend", portTouchEndProxy); portPort.addEventListener("mouseover", portMouseOverProxy); portPort.addEventListener("mouseout", portMouseOutProxy); + } else if (d.__ghostClick) { + portPort.addEventListener("mousedown", d.__ghostClick) + portPort.addEventListener("touchstart", d.__ghostClick) } this.appendChild(portGroup); @@ -5363,6 +5374,10 @@ RED.view = (function() { } } }) + } else if (d.__ghostClick) { + d3.select(pathBack) + .on("mousedown",d.__ghostClick) + .on("touchstart",d.__ghostClick) } var pathOutline = document.createElementNS("http://www.w3.org/2000/svg","path"); @@ -6379,10 +6394,10 @@ RED.view = (function() { nn.w = RED.view.node_width; nn.h = Math.max(RED.view.node_height, (nn.outputs || 0) * 15); nn.resize = true; - if (x != null && typeof x == "number" && x >= 0) { + if (x != null && typeof x == "number") { nn.x = x; } - if (y != null && typeof y == "number" && y >= 0) { + if (y != null && typeof y == "number") { nn.y = y; } var historyEvent = { @@ -6472,7 +6487,9 @@ RED.view = (function() { * x: 0, * y: 0, * } - * ] + * ], + * "source": , + * "sourcePort": , * } * If `nodes` is a single node without an id property, it will be generated * using its default properties. @@ -6480,6 +6497,9 @@ RED.view = (function() { * 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. * + * `source`/`sourcePort` are option and used to indicate a node the suggestion should be connected to. + * If provided, a ghost wire will be added between the source and the first node in the suggestion. + * * Limitations: * - does not support groups, subflows or whole tabs * - does not support config nodes @@ -6490,6 +6510,7 @@ RED.view = (function() { * @param {Object} suggestion - The suggestion object */ function setSuggestedFlow (suggestion) { + $(window).off('keydown.suggestedFlow') if (!currentSuggestion && !suggestion) { // Avoid unnecessary redraws return @@ -6500,6 +6521,24 @@ RED.view = (function() { if (suggestion?.nodes?.length > 0) { const nodeMap = {} const links = [] + const positionOffset = { x: 0, y: 0 } + if (suggestion.source && suggestion.position === 'relative') { + // If the suggestion is relative to a source node, use its position plus a suitable offset + let targetX = suggestion.source.x + (suggestion.source.w || 120) + (3 * gridSize) + const targetY = suggestion.source.y + // Keep targetY where it is, but ensure targetX is grid aligned + if (snapGrid) { + // This isn't a perfect grid snap, as we don't have the true node width at this point. + // TODO: defer grid snapping until the node is created? + const gridOffset = RED.view.tools.calculateGridSnapOffsets({ x: targetX, y: targetY, w: node_width, h: node_height }); + targetX += gridOffset.x + } + + positionOffset.x = targetX - (suggestion.nodes[0].x || 0) + positionOffset.y = targetY - (suggestion.nodes[0].y || 0) + } + + suggestion.nodes.forEach(nodeConfig => { if (!nodeConfig.type || nodeConfig.type === 'group' || nodeConfig.type === 'subflow' || nodeConfig.type === 'tab') { // A node type we don't support previewing @@ -6507,8 +6546,9 @@ RED.view = (function() { } let node - if (nodeConfig.type === 'junction') { + nodeConfig.x = (nodeConfig.x || 0) + positionOffset.x + nodeConfig.y = (nodeConfig.y || 0) + positionOffset.y node = { _def: {defaults:{}}, type: 'junction', @@ -6529,6 +6569,8 @@ RED.view = (function() { // TODO: unknown node types could happen... return } + nodeConfig.x = (nodeConfig.x || 0) + positionOffset.x + nodeConfig.y = (nodeConfig.y || 0) + positionOffset.y const result = createNode(nodeConfig.type, nodeConfig.x, nodeConfig.y) if (!result) { return @@ -6551,6 +6593,11 @@ RED.view = (function() { node.id = nodeConfig.id || node.id node.__ghost = true; node.dirty = true; + if (suggestion.clickToApply) { + node.__ghostClick = function () { + applySuggestedFlow() + } + } nodeMap[node.id] = node if (nodeConfig.wires) { @@ -6579,6 +6626,30 @@ RED.view = (function() { suggestedLinks.push(link) } }) + if (suggestion.source && suggestedNodes[0]?._def?.inputs > 0) { + suggestedLinks.push({ + source: suggestion.source, + sourcePort: suggestion.sourcePort || 0, + target: suggestedNodes[0], + targetPort: 0, + __ghost: true + }) + } + if (!RED.typeSearch.isVisible()) { + $(window).on('keydown.suggestedFlow', function (evt) { + if (evt.keyCode === 9) { // tab + applySuggestedFlow(); + } else { + clearSuggestedFlow(); + RED.view.redraw(true); + } + }); + } + if (suggestion.clickToApply) { + $(window).on('mousedown.suggestedFlow', function (evnt) { + clearSuggestedFlow(); + }) + } } if (ghostNode) { if (suggestedNodes.length > 0) { @@ -6591,6 +6662,8 @@ RED.view = (function() { } function clearSuggestedFlow () { + $(window).off('mousedown.suggestedFlow'); + $(window).off('keydown.suggestedFlow') currentSuggestion = null suggestedNodes = [] suggestedLinks = [] @@ -6599,14 +6672,40 @@ RED.view = (function() { function applySuggestedFlow () { if (currentSuggestion && currentSuggestion.nodes) { const nodesToImport = currentSuggestion.nodes + const sourceNode = currentSuggestion.source + const sourcePort = currentSuggestion.sourcePort || 0 setSuggestedFlow(null) - return importNodes(nodesToImport, { + const result = importNodes(nodesToImport, { generateIds: true, touchImport: true, notify: false, // Ensure the node gets all of its defaults applied applyNodeDefaults: true }) + if (sourceNode) { + const firstNode = result.nodeMap[nodesToImport[0].id] + if (firstNode && firstNode._def?.inputs > 0) { + // Connect the source node to the first node in the suggestion + const link = { + source: sourceNode, + target: RED.nodes.node(firstNode.id), + sourcePort: sourcePort, + targetPort: 0 + }; + RED.nodes.addLink(link) + let historyEvent = RED.history.peek(); + if (historyEvent.t === "multi") { + historyEvent = historyEvent.events.find(e => e.t === "add") + } + if (historyEvent) { + historyEvent.links = historyEvent.links || []; + historyEvent.links.push(link); + } + RED.view.redraw(true); + } + } + + return result } }