From 8380c06a19f496b3870db1b2d4a2094386d369fb Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Fri, 11 Jul 2025 09:32:51 +0100 Subject: [PATCH] Support multiple suggestions at once --- .../@node-red/editor-client/src/js/nodes.js | 6 +- .../editor-client/src/js/ui/keyboard.js | 19 ++-- .../@node-red/editor-client/src/js/ui/view.js | 91 ++++++++++++++++--- 3 files changed, 93 insertions(+), 23 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 cd33e1f2d..71ec8c1a5 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 @@ -1909,6 +1909,7 @@ RED.nodes = (function() { * - 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) + * - eventContext - context to include in the `nodes:add` event */ function importNodes(newNodesObj,options) { // createNewIds,createMissingWorkspace) { const defOpts = { @@ -1917,7 +1918,8 @@ RED.nodes = (function() { markChanged: false, reimport: false, importMap: {}, - applyNodeDefaults: false + applyNodeDefaults: false, + eventContext: null } options = Object.assign({}, defOpts, options) options.importMap = options.importMap || {} @@ -2793,7 +2795,7 @@ RED.nodes = (function() { // Now the nodes have been fully updated, add them. for (i=0;i { + handleEvent(d3.event) + }) + + function handleEvent (evt) { if (!handlersActive) { return; } - if (metaKeyCodes[d3.event.keyCode]) { + if (metaKeyCodes[evt]) { return; } - var handler = resolveKeyEvent(d3.event); + var handler = resolveKeyEvent(evt); if (handler && handler.ondown) { if (typeof handler.ondown === "string") { RED.actions.invoke(handler.ondown); } else { handler.ondown(); } - d3.event.preventDefault(); - } - }); + evt.preventDefault(); + } + } function addHandler(scope,key,modifiers,ondown) { var mod = modifiers; @@ -700,7 +704,8 @@ RED.keyboard = (function() { formatKey: formatKey, validateKey: validateKey, disable: disable, - enable: enable + enable: enable, + handle: handleEvent } })(); 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 e0f73b5ae..21f4b9c99 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 @@ -793,7 +793,11 @@ RED.view = (function() { if (RED.workspaces.isLocked()) { return } - importNodes(clipboard,{generateIds: clipboardSource === 'copy', generateDefaultNames: clipboardSource === 'copy'}); + importNodes(clipboard, { + generateIds: clipboardSource === 'copy', + generateDefaultNames: clipboardSource === 'copy', + eventContext: { source: 'clipboard' } + }); }); RED.actions.add("core:detach-selected-nodes", function() { detachSelectedNodes() }) @@ -1746,6 +1750,7 @@ RED.view = (function() { } }, suggest: function (suggestion) { + // TypeSearch only provides one suggestion at a time if (suggestion?.nodes?.length > 0) { // Reposition the suggestion relative to the existing ghost node position const deltaX = (suggestion.nodes[0].x || 0) - point[0] @@ -5850,7 +5855,8 @@ RED.view = (function() { generateIds: false, generateDefaultNames: false, notify: true, - applyNodeDefaults: false + applyNodeDefaults: false, + eventContext: null } const addNewFlow = options.addFlow const touchImport = options.touchImport; @@ -5940,7 +5946,8 @@ RED.view = (function() { importMap: options.importMap, markChanged: true, modules: modules, - applyNodeDefaults: applyNodeDefaults + applyNodeDefaults: applyNodeDefaults, + eventContext: options.eventContext }); if (importResult) { var new_nodes = importResult.nodes; @@ -6512,6 +6519,8 @@ RED.view = (function() { */ function setSuggestedFlow (suggestion) { $(window).off('keydown.suggestedFlow') + $(window).off('mousedown.suggestedFlow'); + RED.keyboard.enable() if (!currentSuggestion && !suggestion) { // Avoid unnecessary redraws return @@ -6519,7 +6528,28 @@ RED.view = (function() { // Clear up any existing suggestion state clearSuggestedFlow() currentSuggestion = suggestion - if (suggestion?.nodes?.length > 0) { + if (suggestion && suggestion.nodes) { + // If suggestion.nodes is an array of arrays, then there are multiple suggestions being provided + // Normalise the shape of the suggestion.nodes to be an array of arrays + if (!Array.isArray(suggestion.nodes[0])) { + suggestion.nodes = [suggestion.nodes] + } + suggestion.count = suggestion.nodes.length + suggestion.currentIndex = 0 + suggestion.current = suggestion.nodes[suggestion.currentIndex] + refreshSuggestedFlow() + } else { + redraw() + } + } + + function refreshSuggestedFlow () { + const suggestion = currentSuggestion + suggestedNodes = [] + suggestedLinks = [] + + currentSuggestion.current = currentSuggestion.nodes[currentSuggestion.currentIndex] + if (suggestion.current.length > 0) { const nodeMap = {} const links = [] const positionOffset = { x: 0, y: 0 } @@ -6535,12 +6565,11 @@ RED.view = (function() { targetX += gridOffset.x } - positionOffset.x = targetX - (suggestion.nodes[0].x || 0) - positionOffset.y = targetY - (suggestion.nodes[0].y || 0) + positionOffset.x = targetX - (suggestion.current[0].x || 0) + positionOffset.y = targetY - (suggestion.current[0].y || 0) } - - suggestion.nodes.forEach(nodeConfig => { + suggestion.current.forEach(nodeConfig => { if (!nodeConfig.type || nodeConfig.type === 'group' || nodeConfig.type === 'subflow' || nodeConfig.type === 'tab') { // A node type we don't support previewing return @@ -6637,12 +6666,38 @@ RED.view = (function() { }) } if (!RED.typeSearch.isVisible()) { + // Disable the core keyboard handling so we get priority. + // Ideally we'd be able to do this via actions, but we can't currently scope + // actions finely enough to only be handled when the suggested flow is active. + RED.keyboard.disable() $(window).on('keydown.suggestedFlow', function (evt) { - if (evt.keyCode === 9) { // tab + $(window).off('keydown.suggestedFlow') + RED.keyboard.enable() + if (evt.keyCode === 9) { // tab; apply suggestion + evt.stopPropagation(); + evt.preventDefault(); applySuggestedFlow(); - } else { + } else if (evt.keyCode === 38 && currentSuggestion.count > 1) { // up arrow + evt.stopPropagation(); + evt.preventDefault(); + currentSuggestion.currentIndex-- + if (currentSuggestion.currentIndex < 0) { + currentSuggestion.currentIndex = currentSuggestion.count - 1 + } + refreshSuggestedFlow(); + } else if (evt.keyCode === 40 && currentSuggestion.count > 1) { // down arrow + evt.stopPropagation(); + evt.preventDefault(); + currentSuggestion.currentIndex++ + if (currentSuggestion.currentIndex === currentSuggestion.count) { + currentSuggestion.currentIndex = 0 + } + refreshSuggestedFlow(); + } else { // Anything else; clear the suggestion clearSuggestedFlow(); RED.view.redraw(true); + // manually push the event to the keyboard handler + RED.keyboard.handle(evt) } }); } @@ -6659,20 +6714,27 @@ RED.view = (function() { ghostNode.style('opacity', 1) } } - redraw(); + if (currentSuggestion.count > 1 && suggestedNodes.length > 0) { + suggestedNodes[0].status = { + text: `${currentSuggestion.currentIndex + 1} / ${currentSuggestion.count}`, + } + suggestedNodes[0].dirtyStatus = true + } + redraw() } function clearSuggestedFlow () { $(window).off('mousedown.suggestedFlow'); $(window).off('keydown.suggestedFlow') + RED.keyboard.enable() currentSuggestion = null suggestedNodes = [] suggestedLinks = [] } function applySuggestedFlow () { - if (currentSuggestion && currentSuggestion.nodes) { - const nodesToImport = currentSuggestion.nodes + if (currentSuggestion && currentSuggestion.current) { + const nodesToImport = currentSuggestion.current const sourceNode = currentSuggestion.source const sourcePort = currentSuggestion.sourcePort || 0 setSuggestedFlow(null) @@ -6681,7 +6743,8 @@ RED.view = (function() { touchImport: true, notify: false, // Ensure the node gets all of its defaults applied - applyNodeDefaults: true + applyNodeDefaults: true, + eventContext: { source: 'suggestion' } }) if (sourceNode) { const firstNode = result.nodeMap[nodesToImport[0].id]