From 65d68d27cade47883a1941d42eaf82cd77c38961 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Wed, 25 Feb 2026 14:37:01 +0000 Subject: [PATCH] Allow palette.theme to be set via theme plugin and include icons --- .../editor-api/lib/admin/settings.js | 4 +- .../@node-red/editor-api/lib/editor/theme.js | 219 ++++++++++-------- .../editor-client/src/js/ui/utils.js | 106 +++++---- .../editor-api/lib/editor/theme_spec.js | 8 +- 4 files changed, 196 insertions(+), 141 deletions(-) diff --git a/packages/node_modules/@node-red/editor-api/lib/admin/settings.js b/packages/node_modules/@node-red/editor-api/lib/admin/settings.js index 425d42415..6939fdfd1 100644 --- a/packages/node_modules/@node-red/editor-api/lib/admin/settings.js +++ b/packages/node_modules/@node-red/editor-api/lib/admin/settings.js @@ -53,10 +53,10 @@ module.exports = { var opts = { user: req.user } - runtimeAPI.settings.getRuntimeSettings(opts).then(function(result) { + runtimeAPI.settings.getRuntimeSettings(opts).then(async function(result) { if (!settings.disableEditor) { result.editorTheme = result.editorTheme||{}; - var themeSettings = theme.settings(); + const themeSettings = await theme.settings(); if (themeSettings) { // result.editorTheme may already exist with the palette // disabled. Need to merge that into the receive settings diff --git a/packages/node_modules/@node-red/editor-api/lib/editor/theme.js b/packages/node_modules/@node-red/editor-api/lib/editor/theme.js index 1917b55fd..05f4a9053 100644 --- a/packages/node_modules/@node-red/editor-api/lib/editor/theme.js +++ b/packages/node_modules/@node-red/editor-api/lib/editor/theme.js @@ -42,7 +42,13 @@ var defaultContext = { var settings; var theme = null; +/** + * themeContext is an object passed to the mustache template to generate the editor index.html. +*/ var themeContext = clone(defaultContext); +/** + * themeSettings is an object passed to the editor client as the "editorTheme" property of the settings object + */ var themeSettings = null; var activeTheme = null; @@ -91,6 +97,119 @@ function serveFilesFromTheme(themeValue, themeApp, directory, baseDirectory) { return result } +/** + * Check if a theme is enabled and load its settings. + * This is done lazily as it has to happen after the plugins have been loaded, but before the editor is served. + */ +async function loadThemePlugin () { + if (activeTheme && !activeThemeInitialised) { + const themePlugin = await runtimeAPI.plugins.getPlugin({ + id:activeTheme + }); + if (themePlugin) { + if (themePlugin.css) { + const cssFiles = serveFilesFromTheme( + themePlugin.css, + themeApp, + "/css/", + themePlugin.path + ); + themeContext.page.css = cssFiles.concat(themeContext.page.css || []) + // Mutating `theme` is not ideal, but currently necessary as debug (packages/node_modules/@node-red/nodes/core/common/21-debug.js) + // accesses RED.settings.editorTheme.page._.css directly to apply theme to the debug pop-out window. + theme.page = theme.page || {_:{}} + theme.page._.css = cssFiles.concat(theme.page._.css || []) + } + if (themePlugin.scripts) { + const scriptFiles = serveFilesFromTheme( + themePlugin.scripts, + themeApp, + "/scripts/", + themePlugin.path + ) + themeContext.page.scripts = scriptFiles.concat(themeContext.page.scripts || []) + theme.page = theme.page || {_:{}} + theme.page._.scripts = scriptFiles.concat(theme.page._.scripts || []) + } + // check and load page settings from theme + if (themePlugin.page) { + if (themePlugin.page.favicon && !theme.page.favicon) { + const result = serveFilesFromTheme( + [themePlugin.page.favicon], + themeApp, + "/", + themePlugin.path + ) + if(result && result.length > 0) { + // update themeContext page favicon + themeContext.page.favicon = result[0] + } + } + if (themePlugin.page.tabicon && themePlugin.page.tabicon.icon && !theme.page.tabicon) { + const result = serveFilesFromTheme( + [themePlugin.page.tabicon.icon], + themeApp, + "/page/", + themePlugin.path + ) + if(result && result.length > 0) { + // update themeContext page tabicon + themeContext.page.tabicon.icon = result[0] + themeContext.page.tabicon.colour = themeContext.page.tabicon.colour || themeContext.page.tabicon.colour + } + } + // if the plugin has a title AND the users settings.js does NOT + if (themePlugin.page.title && !theme.page.title) { + themeContext.page.title = themePlugin.page.title || themeContext.page.title + } + } + // check and load header settings from theme + if (themePlugin.header) { + if (themePlugin.header.image && !theme.header.image) { + const result = serveFilesFromTheme( + [themePlugin.header.image], + themeApp, + "/header/", + themePlugin.path + ) + if(result && result.length > 0) { + // update themeContext header image + themeContext.header.image = result[0] + } + } + // if the plugin has a title AND the users settings.js does NOT have a title + if (themePlugin.header.title && !theme.header.title) { + themeContext.header.title = themePlugin.header.title || themeContext.header.title + } + // if the plugin has a header url AND the users settings.js does NOT + if (themePlugin.header.url && !theme.header.url) { + themeContext.header.url = themePlugin.header.url || themeContext.header.url + } + } + + if (Array.isArray(themePlugin.palette?.theme)) { + themeSettings.palette = themeSettings.palette || {}; + themeSettings.palette.theme = themePlugin.palette.theme; + // The theme is providing its own palette theme. It *might* include icons that need namespacing + // to the theme plugin module. + themePlugin.palette.theme.forEach(themeRule => { + if (themeRule.icon && themeRule.icon.indexOf("/") === -1) { + themeRule.icon = `${themePlugin.module}/${themeRule.icon}`; + } + }) + } + + // These settings are not exposed under `editorTheme`, so we don't have a merge strategy for them + // If they're defined in the theme plugin, they replace any settings.js values. + // But, this direct manipulation of `theme` is not ideal and relies on mutating a passed-in object + theme.codeEditor = theme.codeEditor || {} + theme.codeEditor.options = Object.assign({}, themePlugin.monacoOptions, theme.codeEditor.options); + theme.mermaid = Object.assign({}, themePlugin.mermaid, theme.mermaid) + } + activeThemeInitialised = true; + } +} + module.exports = { init: function(_settings, _runtimeAPI) { settings = _settings; @@ -232,6 +351,7 @@ module.exports = { res.json(themeContext); }) + // Copy the settings that need passing to the editor into themeSettings. if (theme.hasOwnProperty("menu")) { themeSettings.menu = theme.menu; } @@ -263,104 +383,11 @@ module.exports = { return themeApp; }, context: async function() { - if (activeTheme && !activeThemeInitialised) { - const themePlugin = await runtimeAPI.plugins.getPlugin({ - id:activeTheme - }); - if (themePlugin) { - if (themePlugin.css) { - const cssFiles = serveFilesFromTheme( - themePlugin.css, - themeApp, - "/css/", - themePlugin.path - ); - themeContext.page.css = cssFiles.concat(themeContext.page.css || []) - theme.page = theme.page || {_:{}} - theme.page._.css = cssFiles.concat(theme.page._.css || []) - } - if (themePlugin.scripts) { - const scriptFiles = serveFilesFromTheme( - themePlugin.scripts, - themeApp, - "/scripts/", - themePlugin.path - ) - themeContext.page.scripts = scriptFiles.concat(themeContext.page.scripts || []) - theme.page = theme.page || {_:{}} - theme.page._.scripts = scriptFiles.concat(theme.page._.scripts || []) - } - // check and load page settings from theme - if (themePlugin.page) { - if (themePlugin.page.favicon && !theme.page.favicon) { - const result = serveFilesFromTheme( - [themePlugin.page.favicon], - themeApp, - "/", - themePlugin.path - ) - if(result && result.length > 0) { - // update themeContext page favicon - themeContext.page.favicon = result[0] - theme.page = theme.page || {_:{}} - theme.page._.favicon = result[0] - } - } - if (themePlugin.page.tabicon && themePlugin.page.tabicon.icon && !theme.page.tabicon) { - const result = serveFilesFromTheme( - [themePlugin.page.tabicon.icon], - themeApp, - "/page/", - themePlugin.path - ) - if(result && result.length > 0) { - // update themeContext page tabicon - themeContext.page.tabicon.icon = result[0] - themeContext.page.tabicon.colour = themeContext.page.tabicon.colour || themeContext.page.tabicon.colour - theme.page = theme.page || {_:{}} - theme.page._.tabicon = theme.page._.tabicon || {} - theme.page._.tabicon.icon = themeContext.page.tabicon.icon - theme.page._.tabicon.colour = themeContext.page.tabicon.colour - } - } - // if the plugin has a title AND the users settings.js does NOT - if (themePlugin.page.title && !theme.page.title) { - themeContext.page.title = themePlugin.page.title || themeContext.page.title - } - } - // check and load header settings from theme - if (themePlugin.header) { - if (themePlugin.header.image && !theme.header.image) { - const result = serveFilesFromTheme( - [themePlugin.header.image], - themeApp, - "/header/", - themePlugin.path - ) - if(result && result.length > 0) { - // update themeContext header image - themeContext.header.image = result[0] - } - } - // if the plugin has a title AND the users settings.js does NOT have a title - if (themePlugin.header.title && !theme.header.title) { - themeContext.header.title = themePlugin.header.title || themeContext.header.title - } - // if the plugin has a header url AND the users settings.js does NOT - if (themePlugin.header.url && !theme.header.url) { - themeContext.header.url = themePlugin.header.url || themeContext.header.url - } - } - theme.codeEditor = theme.codeEditor || {} - theme.codeEditor.options = Object.assign({}, themePlugin.monacoOptions, theme.codeEditor.options); - - theme.mermaid = Object.assign({}, themePlugin.mermaid, theme.mermaid) - } - activeThemeInitialised = true; - } + await loadThemePlugin(); return themeContext; }, - settings: function() { + settings: async function() { + await loadThemePlugin(); return themeSettings; }, serveFile: function(baseUrl,file) { diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/utils.js b/packages/node_modules/@node-red/editor-client/src/js/ui/utils.js index fa91059db..436cd3221 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/utils.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/utils.js @@ -1110,20 +1110,36 @@ RED.utils = (function() { return result; } + /** + * Get the default icon for a given node based on its definition. + * @param {*} def + * @param {*} node + * @returns + */ function getDefaultNodeIcon(def,node) { def = def || {}; var icon_url; if (node && node.type === "subflow") { icon_url = "node-red/subflow.svg"; - } else if (typeof def.icon === "function") { - try { - icon_url = def.icon.call(node); - } catch(err) { - console.log("Definition error: "+def.type+".icon",err); - icon_url = "arrow-in.svg"; - } } else { - icon_url = def.icon; + let themeRule = nodeIconCache[def.type] + if (themeRule === undefined) { + // If undefined, we've not checked the theme yet + nodeIconCache[def.type] = getThemeOverrideForNode(def, 'icon') || null; + themeRule = nodeIconCache[def.type]; + } + if (themeRule) { + icon_url = themeRule.icon; + } else if (typeof def.icon === "function") { + try { + icon_url = def.icon.call(node); + } catch(err) { + console.log("Definition error: "+def.type+".icon",err); + icon_url = "arrow-in.svg"; + } + } else { + icon_url = def.icon; + } } var iconPath = separateIconPath(icon_url); @@ -1249,48 +1265,60 @@ RED.utils = (function() { return label } - var nodeColorCache = {}; + let nodeColorCache = {}; + let nodeIconCache = {} function clearNodeColorCache() { nodeColorCache = {}; } - function getNodeColor(type, def) { - def = def || {}; - var result = def.color; - var paletteTheme = RED.settings.theme('palette.theme') || []; + /** + * Checks if there is a theme override for the given node definition and property + * @param {*} def node definition + * @param {*} property either 'color' or 'icon' + * @returns the theme override value if there is a match, otherwise null + */ + function getThemeOverrideForNode(def, property) { + const paletteTheme = RED.settings.theme('palette.theme') || []; if (paletteTheme.length > 0) { - if (!nodeColorCache.hasOwnProperty(type)) { - nodeColorCache[type] = def.color; - var l = paletteTheme.length; - for (var i = 0; i < l; i++ ){ - var themeRule = paletteTheme[i]; - if (themeRule.hasOwnProperty('category')) { - if (!themeRule.hasOwnProperty('_category')) { - themeRule._category = new RegExp(themeRule.category); - } - if (!themeRule._category.test(def.category)) { - continue; - } + for (let i = 0; i < paletteTheme.length; i++ ){ + const themeRule = paletteTheme[i]; + if (themeRule.hasOwnProperty('category')) { + if (!themeRule.hasOwnProperty('_category')) { + themeRule._category = new RegExp(themeRule.category); } - if (themeRule.hasOwnProperty('type')) { - if (!themeRule.hasOwnProperty('_type')) { - themeRule._type = new RegExp(themeRule.type); - } - if (!themeRule._type.test(type)) { - continue; - } + if (!themeRule._category.test(def.category)) { + continue; } - nodeColorCache[type] = themeRule.color || def.color; - break; + } + if (themeRule.hasOwnProperty('type')) { + if (!themeRule.hasOwnProperty('_type')) { + themeRule._type = new RegExp(themeRule.type); + } + if (!themeRule._type.test(def.type)) { + continue; + } + } + // We have found a rule that matches - now see if it provides the requested property + if (themeRule.hasOwnProperty(property)) { + return themeRule; } } - result = nodeColorCache[type]; } - if (result) { - return result; - } else { - return "#ddd"; + return null; + } + + function getNodeColor(type, def) { + def = def || {}; + if (!nodeColorCache.hasOwnProperty(type)) { + const paletteTheme = RED.settings.theme('palette.theme') || []; + if (paletteTheme.length > 0) { + const themeRule = getThemeOverrideForNode(def, 'color'); + nodeColorCache[type] = themeRule?.color || def.color; + } else { + nodeColorCache[type] = def.color; + } } + return nodeColorCache[type] || "#ddd"; } function addSpinnerOverlay(container,contain) { diff --git a/test/unit/@node-red/editor-api/lib/editor/theme_spec.js b/test/unit/@node-red/editor-api/lib/editor/theme_spec.js index 2c660415f..a578cd20b 100644 --- a/test/unit/@node-red/editor-api/lib/editor/theme_spec.js +++ b/test/unit/@node-red/editor-api/lib/editor/theme_spec.js @@ -53,7 +53,7 @@ describe("api/editor/theme", function () { context.asset.should.have.a.property("main", "red/main.min.js"); context.asset.should.have.a.property("vendorMonaco", "vendor/monaco/monaco-bootstrap.js"); - should.not.exist(theme.settings()); + should.not.exist(await theme.settings()); }); it("uses non-minified js files when in dev mode", async function () { @@ -158,7 +158,7 @@ describe("api/editor/theme", function () { context.should.have.a.property("login"); context.login.should.have.a.property("image", "theme/login/image"); - var settings = theme.settings(); + var settings = await theme.settings(); settings.should.have.a.property("deployButton"); settings.deployButton.should.have.a.property("type", "simple"); settings.deployButton.should.have.a.property("label", "Save"); @@ -199,7 +199,7 @@ describe("api/editor/theme", function () { }); - it("test explicit userMenu set to true in theme setting", function () { + it("test explicit userMenu set to true in theme setting", async function () { theme.init({ editorTheme: { userMenu: true, @@ -208,7 +208,7 @@ describe("api/editor/theme", function () { theme.app(); - var settings = theme.settings(); + var settings = await theme.settings(); settings.should.have.a.property("userMenu"); settings.userMenu.should.be.eql(true);