Allow palette.theme to be set via theme plugin and include icons

pull/5500/head
Nick O'Leary 2026-02-25 14:37:01 +00:00
parent 3e2e30f4dd
commit 65d68d27ca
No known key found for this signature in database
GPG Key ID: 4F2157149161A6C9
4 changed files with 196 additions and 141 deletions

View File

@ -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

View File

@ -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) {

View File

@ -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) {

View File

@ -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);