Enable UserCSS style theme settings

This commit is contained in:
Zack Rauen 2023-08-31 18:41:53 -04:00
parent e5dc449130
commit e0787a8816
6 changed files with 150 additions and 36 deletions

View File

@ -130,29 +130,23 @@ export default class AddonManager {
extractMeta(fileContent, filename) {
const firstLine = fileContent.split("\n")[0];
const hasOldMeta = firstLine.includes("//META") && firstLine.includes("*//");
if (hasOldMeta) return this.parseOldMeta(fileContent, filename);
const hasNewMeta = firstLine.includes("/**");
if (hasNewMeta) return this.parseNewMeta(fileContent);
throw new AddonError(filename, filename, Strings.Addons.metaNotFound, {message: "", stack: fileContent}, this.prefix);
const hasMetaComment = firstLine.includes("/**");
if (!hasMetaComment) throw new AddonError(filename, filename, Strings.Addons.metaNotFound, {message: "", stack: fileContent}, this.prefix);
const metaInfo = this.parseJSDoc(fileContent);
/**
* Okay we have a meta JSDoc, let's validate it
* and do some extra parsing for advanced options
*/
if (!metaInfo.author || typeof(metaInfo.author) !== "string") metaInfo.author = Strings.Addons.unknownAuthor;
if (!metaInfo.version || typeof(metaInfo.version) !== "string") metaInfo.version = "???";
if (!metaInfo.description || typeof(metaInfo.description) !== "string") metaInfo.description = Strings.Addons.noDescription;
return metaInfo;
}
parseOldMeta(fileContent, filename) {
const meta = fileContent.split("\n")[0];
const metaData = meta.substring(meta.lastIndexOf("//META") + 6, meta.lastIndexOf("*//"));
let parsed = null;
try {
parsed = JSON.parse(metaData);
}
catch (err) {
throw new AddonError(filename, filename, Strings.Addons.metaError, err, this.prefix);
}
if (!parsed || !parsed.name) throw new AddonError(filename, filename, Strings.Addons.missingNameData, {message: "", stack: meta}, this.prefix);
parsed.format = "json";
return parsed;
}
parseNewMeta(fileContent) {
parseJSDoc(fileContent) {
const block = fileContent.split("/**", 2)[1].split("*/", 1)[0];
const out = {};
let field = "";
@ -160,8 +154,15 @@ export default class AddonManager {
for (const line of block.split(splitRegex)) {
if (line.length === 0) continue;
if (line.charAt(0) === "@" && line.charAt(1) !== " ") {
out[field] = accum.trim();
const l = line.indexOf(" ");
if (!out[field]) {
out[field] = accum.trim();
}
else {
if (!Array.isArray(out[field])) out[field] = [out[field]];
out[field].push(accum.trim());
}
let l = line.indexOf(" ");
if (l < 0) l = line.length;
field = line.substring(1, l);
accum = line.substring(l + 1);
}
@ -169,7 +170,13 @@ export default class AddonManager {
accum += " " + line.replace("\\n", "\n").replace(escapedAtRegex, "@");
}
}
out[field] = accum.trim();
if (!out[field]) {
out[field] = accum.trim();
}
else {
if (!Array.isArray(out[field])) out[field] = [out[field]];
out[field].push(accum.trim());
}
delete out[""];
out.format = "jsdoc";
return out;
@ -181,9 +188,7 @@ export default class AddonManager {
fileContent = stripBOM(fileContent);
const stats = fs.statSync(filename);
const addon = this.extractMeta(fileContent, path.basename(filename));
if (!addon.author) addon.author = Strings.Addons.unknownAuthor;
if (!addon.version) addon.version = "???";
if (!addon.description) addon.description = Strings.Addons.noDescription;
// if (!addon.name || !addon.author || !addon.description || !addon.version) return new AddonError(addon.name || path.basename(filename), filename, "Addon is missing name, author, description, or version", {message: "Addon must provide name, author, description, and version.", stack: ""}, this.prefix);
addon.id = addon.name || path.basename(filename);
addon.slug = path.basename(filename).replace(this.extension, "").replace(/ /g, "-");

View File

@ -8,12 +8,16 @@ import AddonManager from "./addonmanager";
import Settings from "./settingsmanager";
import DOMManager from "./dommanager";
import Strings from "./strings";
import DataStore from "./datastore";
import Utilities from "./utilities";
import Toasts from "@ui/toasts";
import Modals from "@ui/modals";
import SettingsRenderer from "@ui/settings";
const varRegex = /^(checkbox|text|color|select|number|range)\s+([A-Za-z0-9-_]+)\s+"([^"]+)"\s+(.*)$/;
export default new class ThemeManager extends AddonManager {
get name() {return "ThemeManager";}
get extension() {return ".theme.css";}
@ -64,10 +68,54 @@ export default new class ThemeManager extends AddonManager {
if (!addon.name || !addon.author || !addon.description || !addon.version) return new AddonError(addon.name || addon.filename, addon.filename, "Addon is missing name, author, description, or version", {message: "Addon must provide name, author, description, and version.", stack: ""}, this.prefix);
}
extractMeta(fileContent, filename) {
const metaInfo = super.extractMeta(fileContent, filename);
if (!metaInfo.var) return metaInfo;
if (!Array.isArray(metaInfo.var)) metaInfo.var = [metaInfo.var];
const variables = [];
for (const v of metaInfo.var) {
const match = v.match(varRegex);
if (!match || match.length !== 5) continue;
const type = match[1];
const variable = match[2];
const name = match[3];
const value = match[4];
if (type === "checkbox") variables.push({type: "switch", id: variable, name: name, value: parseInt(value) === 1});
if (type === "text") variables.push({type: "text", id: variable, name: name, value: value});
if (type === "color") variables.push({type: "color", id: variable, name: name, value: value, defaultValue: value});
if (type === "number" || type === "range") {
// [default, min, max, step, units]
const parsed = JSON.parse(value);
variables.push({type: type === "number" ? type : "slider", id: variable, name: name, value: parsed[0], min: parsed[1], max: parsed[2], step: parsed[3]});
}
if (type === "select") {
const parsed = JSON.parse(value);
let selected, options;
if (Array.isArray(parsed)) {
selected = parsed.find(o => o.endsWith("*")).replace("*", "");
options = parsed.map(o => ({label: o.replace("*", ""), value: o.replace("*", "")}));
}
else {
selected = Object.keys(parsed).find(k => k.endsWith("*"));
selected = parsed[selected];
options = Object.entries(parsed).map(a => ({label: a[0].replace("*", ""), value: a[1]}));
}
variables.push({type: "dropdown", id: variable, name: name, options: options, value: selected || options[0].value});
}
}
metaInfo.var = variables;
return metaInfo;
}
requireAddon(filename) {
const addon = super.requireAddon(filename);
addon.css = addon.fileContent;
delete addon.fileContent;
this.loadThemeSettings(addon);
if (addon.format == "json") addon.css = addon.css.split("\n").slice(1).join("\n");
return addon;
}
@ -79,6 +127,7 @@ export default new class ThemeManager extends AddonManager {
const addon = typeof(idOrAddon) == "string" ? this.addonList.find(p => p.id == idOrAddon) : idOrAddon;
if (!addon) return;
DOMManager.injectTheme(addon.slug + "-theme-container", addon.css);
DOMManager.injectTheme(addon.slug + "-theme-settings", this.buildCSSVars(addon));
Toasts.show(Strings.Addons.enabled.format({name: addon.name, version: addon.version}));
}
@ -86,6 +135,50 @@ export default new class ThemeManager extends AddonManager {
const addon = typeof(idOrAddon) == "string" ? this.addonList.find(p => p.id == idOrAddon) : idOrAddon;
if (!addon) return;
DOMManager.removeTheme(addon.slug + "-theme-container");
DOMManager.removeTheme(addon.slug + "-theme-settings");
Toasts.show(Strings.Addons.disabled.format({name: addon.name, version: addon.version}));
}
getThemeSettingsPanel(themeId, vars) {
return SettingsRenderer.getSettingsGroup(vars, Utilities.debounce((id, value) => this.updateThemeSettings(themeId, id, value), 100));
}
loadThemeSettings(addon) {
const all = DataStore.getData("theme_settings") || {};
const stored = all?.[addon.id];
if (!stored) return;
for (const v of addon.var) {
if (v.id in stored) v.value = stored[v.id];
}
}
updateThemeSettings(themeId, id, value) {
const addon = this.addonList.find(p => p.id == themeId);
const varToUpdate = addon.var.find(v => v.id === id);
varToUpdate.value = value;
DOMManager.injectTheme(addon.slug + "-theme-settings", this.buildCSSVars(addon));
this.saveThemeSettings(themeId);
}
saveThemeSettings(themeId) {
const all = DataStore.getData("theme_settings") || {};
const addon = this.addonList.find(p => p.id == themeId);
const data = {};
for (const v of addon.var) {
data[v.id] = v.value;
}
all[themeId] = data;
DataStore.setData("theme_settings", all);
}
buildCSSVars(idOrAddon) {
const addon = typeof(idOrAddon) == "string" ? this.addonList.find(p => p.id == idOrAddon) : idOrAddon;
const lines = [`:root {`];
for (const v of addon.var) {
const value = typeof(v.value) === "boolean" ? v.value ? 1 : 0 : v.value;
lines.push(` --${v.id}: ${value};`);
}
lines.push(`}`);
return lines.join("\n");
}
};

View File

@ -50,7 +50,7 @@
flex-wrap: wrap;
align-content: flex-start;
margin-left: 5px !important;
max-width: 340px;
max-width: 310px;
}
.bd-color-picker-swatch-item {

View File

@ -40,6 +40,10 @@ export default class Modals {
}
static get ModalQueue() {return this._ModalQueue ??= [];}
static get ConfirmationModal() {return this._ConfirmationModal ??= WebpackModules.getModule(m => m?.toString?.()?.includes(".confirmButtonColor"), {searchExports: true}) ?? ConfirmationModal;}
static get ModalRoot() {return this._ModalRoot ??= WebpackModules.getModule(m => m?.toString?.()?.includes("ENTERING") && m?.toString?.()?.includes("headerId"), {searchExports: true}) ?? ModalRoot;}
static get ModalComponents() {return this._ModalComponents ??= WebpackModules.getByProps("Header", "Footer");}
static get Buttons() {return this._Buttons ??= WebpackModules.getModule(m => m.BorderColors, {searchExports: true});}
static async initialize() {
const names = ["ModalActions"];
@ -194,9 +198,10 @@ export default class Modals {
].filter(Boolean));
});
}
}, React.createElement(ConfirmationModal, Object.assign({
}, React.createElement(this.ConfirmationModal, Object.assign({
header: title,
danger: danger,
danger: danger ? this.Buttons.Colors.RED : this.Buttons.Colors.BRAND,
confirmButtonColor: danger ? this.Buttons.Colors.RED : this.Buttons.Colors.BRAND,
confirmText: confirmText,
cancelText: cancelText,
onConfirm: onConfirm,
@ -263,15 +268,16 @@ export default class Modals {
if (typeof(child) === "function") child = React.createElement(child);
const options = {
className: "bd-addon-modal",
size: ModalRoot.Sizes.MEDIUM,
className: "bd-addon-modal " + this.ModalComponents.Sizes.MEDIUM ?? this.ModalRoot.Sizes.MEDIUM,
size: this.ModalComponents.Sizes.MEDIUM ?? this.ModalRoot.Sizes.MEDIUM,
header: `${name} Settings`,
cancelText: null,
confirmText: Strings.Modals.done
confirmText: Strings.Modals.done,
confirmButtonColor: this.Buttons.Colors.BRAND
};
return this.openModal(props => {
return React.createElement(ErrorBoundary, null, React.createElement(ConfirmationModal, Object.assign(options, props), child));
return React.createElement(ErrorBoundary, null, React.createElement(this.ConfirmationModal, Object.assign(options, props), child));
});
}

View File

@ -58,6 +58,15 @@ export default new class SettingsRenderer {
})];
}
getSettingsGroup(settings, onChange) {
return () => React.createElement(SettingsGroup, {
onChange: onChange,
shown: true,
collapsible: false,
settings: settings
});
}
getAddonPanel(title, addonList, addonState, options = {}) {
return () => React.createElement(AddonList, Object.assign({}, {
title: title,

View File

@ -3,6 +3,7 @@ import Strings from "@modules/strings";
import Events from "@modules/emitter";
import DataStore from "@modules/datastore";
import DiscordModules from "@modules/discordmodules";
import ThemeManager from "@modules/thememanager";
import Button from "../base/button";
import SettingsTitle from "./title";
@ -154,8 +155,8 @@ export default function AddonList({prefix, type, title, folder, addonList, addon
}
return sorted.map(addon => {
const hasSettings = addon.instance && typeof(addon.instance.getSettingsPanel) === "function";
const getSettings = hasSettings && addon.instance.getSettingsPanel.bind(addon.instance);
const hasSettings = (addon.var && addon.var.length) || (addon.instance && typeof(addon.instance.getSettingsPanel) === "function");
const getSettings = hasSettings && (addon.var ? ThemeManager.getThemeSettingsPanel(addon.id, addon.var) : addon.instance.getSettingsPanel.bind(addon.instance));
return <ErrorBoundary><AddonCard disabled={addon.partial} type={type} editAddon={() => triggerEdit(addon.id)} deleteAddon={() => triggerDelete(addon.id)} key={addon.id} enabled={addonState[addon.id]} addon={addon} onChange={onChange} reload={reload} hasSettings={hasSettings} getSettingsPanel={getSettings} /></ErrorBoundary>;
});
}, [addonList, addonState, onChange, reload, triggerDelete, triggerEdit, type, sort, ascending, query, forced]); // eslint-disable-line react-hooks/exhaustive-deps