import $ from "jquery"; import { renderMD } from "./Util"; import { icons } from "./Icons"; export default class ConfigMenu { /** @typedef {"checkbox" | "select" | "button" | "slider" | "number" | "text" | "textarea" | "time" | "color"} DribbblishConfigType */ /** * @typedef {Object} DribbblishConfigItem * @property {DribbblishConfigType} type * @property {String | DribbblishConfigArea} [area={name: "Main Settings", order: 0}] * @property {any} [data={}] * @property {Number} [order=0] order < 0 = Higher up | order > 0 = Lower Down * @property {String} key * @property {String} name * @property {String} [description=""] * @property {any} [defaultValue] * @property {Boolean} [hidden=false] * @property {Boolean} [resetButton=true] * @property {Boolean} [insertOnTop=false] * @property {Boolean} [fireInitialChange=true] * @property {Boolean} [save=true] * @property {validate} [validate] * @property {showChildren} [showChildren] * @property {onAppended} [onAppended] * @property {onChange} [onChange] * @property {DribbblishConfigItem[]} [children=[]] * @property {String} [parent=null] key of parent (set automatically) */ /** * @typedef DribbblishConfigArea * @property {String} name * @property {Number} [order=0] order < 0 = Higher up | order > 0 = Lower Down * @property {Boolean} [toggleable=true] */ /** * @callback validate * @this {DribbblishConfigItem} * @param {any} value * @returns {Boolean | String} */ /** * @callback showChildren * @this {DribbblishConfigItem} * @param {any} value * @returns {Boolean | String[]} */ /** * @callback onAppended * @this {DribbblishConfigItem} * @returns {void} */ /** * @callback onChange * @this {DribbblishConfigItem} * @param {any} value * @returns {void} */ /** @type {Object.} */ #config; /** @type {Spicetify.Menu.Item} */ #configButton; constructor() { this.#config = {}; this.#configButton = new Spicetify.Menu.Item("Dribbblish Settings", false, () => this.open()); this.#configButton.register(); const container = document.createElement("div"); container.id = "dribbblish-config"; container.innerHTML = /* html */ `

Dribbblish Settings

`; document.body.appendChild(container); $(".dribbblish-config-close").on("click", () => this.close()); $(".dribbblish-config-backdrop").on("click", () => this.close()); $(".dribbblish-config-search").on("input", (e) => this.#search(e.target.value)); } open() { $("#dribbblish-config").attr("active", true); } close() { $("#dribbblish-config").removeAttr("active"); } /** * @param {DribbblishConfigItem} options */ register(options) { /** @type {DribbblishConfigItem} */ const defaultOptions = { hidden: false, area: "Main Settings", order: 0, data: {}, name: "", description: "", hidden: false, resetButton: true, insertOnTop: false, fireInitialChange: true, save: true, validate: () => true, showChildren: () => true, onAppended: () => {}, onChange: () => {}, children: [], parent: null }; // Set Defaults options = { ...defaultOptions, ...options }; if (typeof options.area == "string") options.area = { name: options.area, order: 0 }; options.description = options.description .split("\n") .filter((line) => line.trim() != "") .map((line) => line.trim()) .join("\n"); options._onChange = options.onChange; options.onChange = (val) => { const isValid = validate(val) === true; $(`.dribbblish-config-item[key="${options.key}"]`).attr("changed", options.type != "button" && isValid && val != options.defaultValue ? "" : null); if (!isValid) return; this.set(options.key, val, options.save); options._onChange.call(options, val); const show = options.showChildren.call(options, val); options.children.forEach((child) => this.setHidden(child.key, Array.isArray(show) ? !show.includes(child.key) : !show)); }; options.children = options.children.map((child) => { return { ...child, area: options.area, parent: options.key, order: options.order ?? 0 + child.order ?? 0 }; }); this.#config[options.key] = options; function validate(val) { const isValid = options.validate.call(options, val); const $elem = $(`.dribbblish-config-item[key="${options.key}"]`); if (isValid === true) { $elem.attr("invalid", null).css("--validation-error", ""); } else { const error = isValid === false ? "Invalid" : isValid; $elem.attr("invalid", "").css("--validation-error", `"${error.replace(/"/g, `\\"`)}"`); } return isValid; } let elem; let input; if (options.type == "checkbox") { elem = document.createDocumentFragment(); input = document.createElement("input"); input.classList.add("dribbblish-config-input"); input.type = "checkbox"; input.classList.add("x-toggle-input"); input.checked = this.get(options.key); input.addEventListener("change", (e) => { options.onChange(e.target.checked); }); elem.appendChild(input); const indicator = document.createElement("span"); indicator.classList.add("x-toggle-indicatorWrapper"); indicator.innerHTML = /* html */ ``; elem.appendChild(indicator); } else if (options.type == "select") { input = document.createElement("select"); input.classList.add("dribbblish-config-input"); input.classList.add("main-dropDown-dropDown"); input.innerHTML = Object.entries(options.data) .map(([key, name]) => ``) .join(""); input.addEventListener("change", (e) => { options.onChange(e.target.value); }); } else if (options.type == "button") { if (typeof options.data != "string") options.data = options.name; options.fireInitialChange = false; options.resetButton = false; options.save = false; input = document.createElement("button"); input.classList.add("dribbblish-config-input"); input.type = "button"; input.classList.add("main-buttons-button", "main-button-primary"); input.innerHTML = /* html */ `
${options.data}
`; input.addEventListener("click", (e) => { options.onChange(true); }); } else if (options.type == "number") { // Validate if (options.defaultValue == null) options.defaultValue = 0; const _val = this.get(options.key); if (options.data.min != null && _val < options.data.min) this.set(options.key, options.data.min, options.save); if (options.data.max != null && _val > options.data.max) this.set(options.key, options.data.max, options.save); input = document.createElement("input"); input.classList.add("dribbblish-config-input"); input.type = "number"; input.value = this.get(options.key); input.step = options.data.step ?? 1; if (options.data.min != null) input.min = options.data.min; if (options.data.max != null) input.max = options.data.max; input.addEventListener("keypress", (e) => { // Prevent inputting + and e. if (["+", "e"].includes(e.key)) e.preventDefault(); }); input.addEventListener("input", (e) => { if (options.data.min != null && e.target.value < options.data.min) e.target.value = options.data.min; if (options.data.max != null && e.target.value > options.data.max) e.target.value = options.data.max; options.onChange(Number(e.target.value)); }); } else if (options.type == "text") { if (options.defaultValue == null) options.defaultValue = ""; input = document.createElement("input"); input.classList.add("dribbblish-config-input"); input.type = "text"; input.value = this.get(options.key); input.addEventListener("input", (e) => { options.onChange(e.target.value); }); } else if (options.type == "textarea") { if (options.defaultValue == null) options.defaultValue = ""; input = document.createElement("textarea"); input.classList.add("dribbblish-config-input"); input.value = this.get(options.key); input.addEventListener("input", (e) => { options.onChange(e.target.value); }); } else if (options.type == "slider") { // Validate if (options.defaultValue == null) options.defaultValue = 0; const val = this.get(options.key); if (options.data.min != null && val < options.data.min) this.set(options.key, options.data.min, options.save); if (options.data.max != null && val > options.data.max) this.set(options.key, options.data.max, options.save); input = document.createElement("input"); input.classList.add("dribbblish-config-input"); input.type = "range"; input.step = options.data.step ?? 1; input.min = options.data.min ?? 0; input.max = options.data.max ?? 100; input.value = this.get(options.key); input.setAttribute("tooltip", `${this.get(options.key)}${options.data.suffix ?? ""}`); input.addEventListener("input", (e) => { options.onChange(Number(e.target.value)); $(`#dribbblish-config-input-${options.key}`).attr("tooltip", `${e.target.value}${options.data?.suffix ?? ""}`); }); } else if (options.type == "time") { // Validate if (options.defaultValue == null) options.defaultValue = "00:00"; input = document.createElement("input"); input.classList.add("dribbblish-config-input"); input.type = "time"; input.value = this.get(options.key); input.addEventListener("input", (e) => { options.onChange(e.target.value); }); } else if (options.type == "color") { // Validate if (options.defaultValue == null) options.defaultValue = "#000000"; input = document.createElement("input"); input.classList.add("dribbblish-config-input"); input.type = "color"; input.value = this.get(options.key); input.addEventListener("input", (e) => { options.onChange(e.target.value); }); } else { throw new Error(`Config Type "${options.type}" invalid`); } this.#addInputHTML({ ...options, input: elem ?? input }); options.input = input; // Re-write internal config since some values may have changed this.#config[options.key] = options; validate(this.get(options.key)); $(`.dribbblish-config-item[key="${options.key}"]`).attr("changed", options.save && this.get(options.key) != options.defaultValue ? "" : null); options.children.forEach((child) => this.register(child)); options.onAppended.call(options); if (options.fireInitialChange) options.onChange(this.get(options.key)); } /** * @param {DribbblishConfigArea} area */ registerArea(area) { /** @type {DribbblishConfigArea} */ const defaultOptions = { toggleable: true, order: 0 }; area = { ...defaultOptions, ...area }; if (!document.querySelector(`.dribbblish-config-area[name="${area.name}"]`)) { this.#addAreaHTML(area); } else { throw new Error(`Area "${area.name}" already exists`); } } /** * * @param {String} key * @param {any} defaultValueOverride * @returns {any} */ get(key, defaultValueOverride) { const val = JSON.parse(this.#config[key]?.storageCache ?? localStorage.getItem(`dribbblish:config:${key}`) ?? null); // Turn undefined into null because `JSON.parse()` doesn't like undefined if (val == null || val?.type != this.#config[key]?.type) { localStorage.removeItem(`dribbblish:config:${key}`); return defaultValueOverride ?? this.#config[key]?.defaultValue; } return val.value; } /** * * @param {String} key * @param {any} val * @param {Boolean} [save=true] if setting should be stored in localStorage */ set(key, val, save = true) { val = { type: this.#config[key].type, value: val ?? this.#config[key].defaultValue }; this.#config[key].storageCache = JSON.stringify(val); if (save) localStorage.setItem(`dribbblish:config:${key}`, JSON.stringify(val)); } /** * * @param {String} key */ reset(key) { delete this.#config[key].storageCache; localStorage.removeItem(`dribbblish:config:${key}`); const options = this.#config[key]; const defaultVal = this.get(options.key); if (options.type == "checkbox") { options.input.checked = defaultVal; } else { options.input.value = defaultVal; if (options.type == "slider") options.input.setAttribute("tooltip", `${defaultVal}${options.data.suffix ?? ""}`); } options.onChange(defaultVal); } /** * * @param {String} key * @param {Boolean} hidden * @private */ setHidden(key, hidden, search = false) { this.#config[key].hidden = hidden; const $elem = $(`.dribbblish-config-item[key="${key}"]`); $elem.attr(search ? "hidden-override" : "hidden", hidden ? "" : null); // If element has children or a parent if ($elem.attr("parent") != null || $elem.attr("children") != null) { // Get parent of element block const $parent = $elem.attr("parent") != null ? $(`[children~="${key}"]`) : $elem; const $nextChildren = $parent.nextAll(`[parent="${$parent.attr("key")}"]`); // Make parent connect on bottom when children are visible $parent.attr("connect-bottom", $nextChildren.filter(":not([hidden]):not([hidden-override])").length > 0 ? "" : null); // Reset all children's bottom connection $nextChildren.each(function () { $(this).attr("connect-bottom", null); }); // Add bottom connection to all but the last visible child $nextChildren .filter(":not([hidden]):not([hidden-override])") .slice(0, -1) .each(function () { $(this).attr("connect-bottom", ""); }); //* NOTE: All children automatically have a top connection } } setDisabled(key, disabled) { this.#config[key].input.disabled = disabled; } getOptions(key) { return this.#config[key]; } export() { const obj = { EXPORTED_WITH: process.env.DRIBBBLISH_VERSION }; Object.entries(this.#config).forEach(([key, options]) => { if (options.save) obj[key] = this.get(key); }); return obj; } /** * @param {String} text */ #search(text) { text = text.trim().toLowerCase(); $(".dribbblish-config-area-header svg").css("display", text != "" ? "none" : ""); $(".dribbblish-config-area").attr("search", text != "" ? "" : null); const cmp = (s) => s.trim().toLowerCase().includes(text.trim().toLowerCase()); /** * @param {DribbblishConfigItem} options */ function cmpOpts(options) { let matches = cmp(options.name) || cmp($(`.dribbblish-config-item[key="${options.key}"] .dribbblish-config-item-header label`).text()); if (!matches && options.type == "select") matches = Object.values(options.data).some(cmp); return matches; } // Check every item for (const [key, options] of Object.entries(this.#config)) { this.setHidden(key, false, true); if (text == "") continue; const show = cmpOpts(options); this.setHidden(key, !show, true); } // Check every item's children and show it when any child is visible for (const [key, options] of Object.entries(this.#config)) { if (options.children.length != 0 && $(`.dribbblish-config-item[parent="${options.key}"]`).filter(":not([hidden]):not([hidden-override])").length != 0) this.setHidden(key, false, true); } // Hide areas without visible children for (const area of $(".dribbblish-config-area").toArray()) { const $area = $(area); const itemsVisible = $area.children(".dribbblish-config-area-items").children(":not([hidden]):not([hidden-override])").length; $area.css("display", text != "" && itemsVisible == 0 ? "none" : ""); } $(`.dribbblish-config-item`).filter(":not([hidden]):not([hidden-override])").length; } /** * @private * @param {DribbblishConfigItem} options */ #addInputHTML(options) { if (!document.querySelector(`.dribbblish-config-area[name="${options.area.name}"]`)) this.registerArea(options.area); const parent = document.querySelector(`.dribbblish-config-area[name="${options.area.name}"] .dribbblish-config-area-items`); const elem = document.createElement("div"); if (options.order != 0) elem.style.order = options.order; elem.classList.add("dribbblish-config-item"); elem.setAttribute("key", options.key); elem.setAttribute("type", options.type); if (options.hidden) elem.setAttribute("hidden", true); if (options.parent) elem.setAttribute("parent", options.parent); if (options.children.length > 0) elem.setAttribute("children", options.children.map((c) => c.key).join(" ")); elem.innerHTML = /* html */ ` ${ options.name != null && options.description != null ? /* html */ `

${options.name}

` : "" }
`; elem.querySelector(".dribbblish-config-item-input > label").appendChild(options.input); if (options.resetButton && options.name != null && options.description != null) { const resetBtn = document.createElement("button"); resetBtn.ariaLabel = "Reset"; resetBtn.className = "dribbblish-config-item-reset main-trackCreditsModal-closeBtn"; resetBtn.innerHTML = icons.get("delete-outline", { size: 20, title: "Reset Setting" }); resetBtn.addEventListener("click", () => { this.reset(options.key); }); elem.querySelector(".dribbblish-config-item-header > h2").appendChild(resetBtn); } if (options.insertOnTop && parent.children.length > 0) { parent.insertBefore(elem, parent.children[0]); } else { parent.appendChild(elem); } } /** * * @param {DribbblishConfigArea} area */ #addAreaHTML(area) { const areaElem = document.createElement("div"); areaElem.classList.add("dribbblish-config-area"); if (area.order != 0) areaElem.style.order = area.order; const uncollapsedAreas = JSON.parse(localStorage.getItem("dribbblish:config-areas:uncollapsed") ?? "[]"); if (area.toggleable && !uncollapsedAreas.includes(area.name)) areaElem.toggleAttribute("collapsed"); areaElem.setAttribute("name", area.name); areaElem.innerHTML = /* html */ `

${area.name} ${!area.toggleable ? "" : icons.get("expand-more", { size: 24, scale: 1.2 })}

`; document.querySelector(".dribbblish-config-areas").appendChild(areaElem); if (area.toggleable) { areaElem.querySelector("h2").addEventListener("click", () => { if (document.querySelector(".dribbblish-config-search").value.trim() != "") return; areaElem.toggleAttribute("collapsed"); let uncollapsedAreas = JSON.parse(localStorage.getItem("dribbblish:config-areas:uncollapsed") ?? "[]"); if (areaElem.hasAttribute("collapsed")) { uncollapsedAreas = uncollapsedAreas.filter((areaName) => areaName != area.name); } else { uncollapsedAreas.push(area.name); } localStorage.setItem("dribbblish:config-areas:uncollapsed", JSON.stringify(uncollapsedAreas)); }); } } }