diff --git a/.vscode/settings.json b/.vscode/settings.json index 9017f28..da092c3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,26 @@ { "cSpell.words": [ + "*action", + "*close", + "*content", + "*enctype", + "*evasion", + "*hacks", + "*id", + "*input", + "*length", + "*method", + "*novalidate", + "*open", + "*opener", + "*referrer", + "*target", + "*unreleased*", + "*votes", + "*voting", "anthro", "APIIQDB", - "banevasion", + "ban*", "bdrawer", "bitwolfy", "bloodlust", @@ -10,6 +28,7 @@ "callout", "cdnp", "colspan", + "commandfor", "cooldown", "Copybox", "Customizer", @@ -17,27 +36,27 @@ "Datetime", "defval", "Derpibooru", - "dialogclose", - "dialogopen", + "dialog*", "dmail", "dmails", "dnpcache", - "downvotes", - "downvoting", + "down*", "Dragonfruit", "dtext", "Earlopain", "favorited", - "fileinput", + "file*", "fitv", "fnct", "fontawesome", + "form*", "formspan", "forumdot", "Greasemonkey", "Helpdesk", "Hrefs", "IBTL", + "interestfor", "IQDB", "jqueryui", "Keybind", @@ -49,19 +68,18 @@ "lazyload", "Mairo", "MATCHNAME", - "maxlength", + "max*", "metascript", + "no*", "nonselectable", - "noopener", - "noreferrer", "octothorpes", "onshift", "paysite", "Pixiv", + "popovertarget*", "postface", "postid", - "postinghacks", - "postingunreleasedcontent", + "posting*", "propertychange", "pximg", "qedit", @@ -89,8 +107,8 @@ "unformat", "unmargin", "Unvote", - "upvotes", - "userid", + "up*", + "user*", "userscript", "userscripts", "webm", diff --git a/package.json b/package.json index 414e07c..63f7c9d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mod621", "displayName": "RE621 Mod Tools", "namespace": "mod621.bitwolfy.com", - "version": "6.9.0", + "version": "6.10.0", "description": "Moderation toolkit for e621.net", "author": "bitWolfy", "contributors": [ diff --git a/src/REMT.ts b/src/REMT.ts index db59bc5..f3a8a5e 100644 --- a/src/REMT.ts +++ b/src/REMT.ts @@ -26,6 +26,7 @@ import RipStaffNotes from "./js/components/RipStaffNotes"; import TimeKeeper from "./js/components/TimeKeeper"; import HtmlBuilder from "./js/utilities/HtmlBuilder"; import XM from "./js/models/api/XM"; +import { html } from "./js/utilities/HtmlTemplate"; export default class REMT { @@ -130,42 +131,88 @@ export default class REMT { */ private buildSettingsButton(addListeners = false) { this.settingsButton?.remove(); - Debug.logPrefix("Checking for settings..."); - const configs = Object.values(REMT._Registry).reduce((p, e) => { - if (!e) return p; - if (addListeners) e.on("settingsConfigured", () => this.buildSettingsButton()); - const v = e.settingsMenuDialogParameters; - if (v) p.push(v); - return p; - }, [] as SettingsDialogConfig[]); - if (configs.length <= 0) { - Debug.logPrefix("No settings registered"); - return; - } - Debug.logPrefix("Loading %s settings...", configs.length); - const cbs: JQuery[] = []; + Debug.logPrefix("Checking for component settings menus..."); + const configs: SettingsDialogConfig[] = [], cbs: JQuery[] = [], setTo: ((v: boolean, setCb?: boolean) => void)[] = []; + let areAnyEnabled = false; for (const [k, v] of Object.entries(REMT._Registry)) { if (!v) continue; - Debug.logPrefix("%s.Settings.enabled:\n\tInstance's accessor: %o\n\tRaw Stored Value: %o", k, v?.Settings.enabled, XM.Storage.getValue(k + "." + "enabled", undefined)); + if (addListeners) v.on("settingsConfigured", () => this.buildSettingsButton()); + const menuParams = v.settingsMenuDialogParameters; + if (menuParams) configs.push(menuParams); + // Debug.logPrefix("%s.Settings.enabled:\n\tInstance's accessor: %o\n\tRaw Stored Value: %o", k, v?.Settings.enabled, XM.Storage.getValue(k + "." + "enabled", undefined)); const cb = HtmlBuilder.inputCheckboxBuilder({ checked: XM.Storage.getValue(k + "." + "enabled", v?.Settings.enabled), value: k, id: `${Script.htmlPrefix}-enable-${k}`, name: `${Script.htmlPrefix}-enable-${k}`, }); - cb.addEventListener("change", _ => XM.Storage.setValue(k + "." + "enabled", v.Settings.enabled = cb.checked)); - cbs.push($(HtmlBuilder.labelBuilder({ + areAnyEnabled ||= cb.checked; + const st = (value: boolean, setCb = false) => { XM.Storage.setValue(k + "." + "enabled", v.Settings.enabled = value); if (setCb) cb.checked = value; }; + setTo.push(st); + cb.addEventListener("change", _ => st(cb.checked)); + const resetSettingsButton = HtmlBuilder.button({ + id: `${Script.htmlPrefix}-reset-${k}`, + name: `${Script.htmlPrefix}-reset-${k}`, + type: "button", + innerHtml: "Reset", + title: "Reset to Default Settings?", + }); + resetSettingsButton.addEventListener("click", _ => v.requestResetSettings()); + cbs.push($(html`
${HtmlBuilder.labelBuilder({ forElement: `${Script.htmlPrefix}-enable-${k}`, title: `Fully enable/disable the ${Util.pascalToTitle(k)} component?`, - innerHtml: [k, cb, `
`], - }))); + innerHtml: [k, ": ", cb], + })} ${resetSettingsButton}
` as HTMLDivElement)); + } + if (configs.length <= 0) { + Debug.logPrefix("No settings menus registered currently."); + // Must draw the menu if it has no chance of being drawn otherwise. + if (areAnyEnabled) return; + Debug.logPrefix("All components disabled; creating global settings menu..."); + } else + Debug.logPrefix("Creating settings menu for %s components...", configs.length); + const enableAll = HtmlBuilder.button({ + id: `${Script.htmlPrefix}-enable-all`, + type: "button", + innerHtml: "Enable All", + }), disableAll = HtmlBuilder.button({ + id: `${Script.htmlPrefix}-disable-all`, + type: "button", + innerHtml: "Disable All", + }); + /* enableAll.addEventListener("click", _ => cbs.forEach(e => { + const cb = e.children("input")[0]; + if (!cb.checked) { + cb.checked = true; + cb.dispatchEvent(new Event("change")); + } + })); + disableAll.addEventListener("click", _ => cbs.forEach(e => { + const cb = e.children("input")[0]; + if (cb.checked) { + cb.checked = false; + cb.dispatchEvent(new Event("change")); + } + })); */ + enableAll.addEventListener("click", _ => setTo.forEach(e => e(true, true))); + disableAll.addEventListener("click", _ => setTo.forEach(e => e(false, true))); + /* function setAll(to: boolean) { + cbs.forEach(e => e.children("input")[0].checked = to); } + enableAll.addEventListener("click", () => setAll(true)); + disableAll.addEventListener("click", () => setAll(false)); */ configs.push({ elements: [ - $(""), + $(""), $(`
`), ...cbs, + $(enableAll), + $(disableAll), ], + optionsOrTitle: { + title: "Global Settings", + defaultElements: [], + }, }); this.settingsButton = Util.DOM.addSettingsButton({ id: `${Script.htmlPrefix}-component-settings`, diff --git a/src/js/components/Component.ts b/src/js/components/Component.ts index a06bd3d..4c9dc85 100644 --- a/src/js/components/Component.ts +++ b/src/js/components/Component.ts @@ -12,20 +12,41 @@ import Util from "../utilities/Util"; export default class Component { - protected name: string; // Unique identifier for this component. Defaults to the class name. + /** + * Unique identifier for this component. Defaults to the class name. + */ + protected name: string; - private initialized = false; // Whether or not the component is currently running - private eventIndex = 0; // Used to provide IDs to the component event listeners - private constraintMatches: boolean; // Whether or not the declared page constraints match - private DOMLoadConditions: boolean | string; // DOM conditions that must be met for the component to load - private waitForFocus: boolean; // Wait for the window to come into focus before loading - private dependencies: string[]; // List of components names that need to be enabled + /** + * Whether or not the component is currently running + */ + private initialized = false; + /** + * Used to provide IDs to the component event listeners + */ + private eventIndex = 0; + /** + * Whether or not the declared page constraints match + */ + private constraintMatches: boolean; + /** + * DOM conditions that must be met for the component to load + */ + private DOMLoadConditions: boolean | string; + /** + * Wait for the window to come into focus before loading + */ + private waitForFocus: boolean; + /** + * List of components names that need to be enabled + */ + private dependencies: string[]; /** * Component settings * - * Defines default values and is used to access them via dynamic setters and getters, - * which are assigned over the values provided by child classes. + * Defines default values and is used to access them via dynamic setters and + * getters, which are assigned over the values provided by child classes. * The original values are stored in `SettingsDefaults`. * * The `SettingsCache` object stores the current values of the settings. @@ -43,7 +64,7 @@ export default class Component { * overwritten with dynamic setters and getters. */ private get SettingsDefaults(): Settings { - if (!this._SettingsDefaults) throw Error("Component.SettingsDefaults is not yet defined."); + if (!this._SettingsDefaults) throw Error(`${this.name}.SettingsDefaults is not yet defined.`); return this._SettingsDefaults; } /** @@ -333,21 +354,31 @@ export default class Component { this._settingsMenuDialogParameters = v; this.trigger("settingsConfigured"); } + + /** + * @todo Make protected again after creating the global settings component. + */ protected resetSettings() { for (const [k, v] of Object.entries(this.SettingsDefaults)) { this.Settings[k] = v; } } + public requestResetSettings(label = this.settingsDialogTitle) { + if (confirm(`Reset ${label} to defaults?`)) { + this.resetSettings(); + return true; + } + return false; + } + protected get prettyPrintName() { return Util.pascalToTitle(this.name); } protected get settingsDialogTitle() { return `${this.prettyPrintName} Settings`; } // #region Reset settings protected get settingsIdPrefix() { return `${this.prettyPrintName.replace(/\s+/g, "-")}-setting-`; } protected handleResetSettingsDialogElement(e: FormData, forceConfirm = true) { - if ( - e.get(`${this.settingsIdPrefix}resetSettings`) === "true" && - (!forceConfirm || confirm(`Reset ${this.settingsDialogTitle} to defaults?`)) - ) { + if (e.get(`${this.settingsIdPrefix}resetSettings`) === "true") { + if (forceConfirm) return this.requestResetSettings(); this.resetSettings(); return true; } diff --git a/src/js/utilities/HtmlBuilder.ts b/src/js/utilities/HtmlBuilder.ts index 092e661..09baf2f 100644 --- a/src/js/utilities/HtmlBuilder.ts +++ b/src/js/utilities/HtmlBuilder.ts @@ -1,46 +1,107 @@ import { html } from "./HtmlTemplate"; export default class HtmlBuilder { + // #region HTML Elements + // #region Attribute helpers private static buildBaseParams(obj: Partial) { - return `${obj.id ? ` id="${obj.id}"` : ""}${obj.className ? ` class="${obj.className}"` : ""}${obj.title ? ` title="${obj.title}"` : ""}`; + return [ + obj.id ? ` id="${obj.id}"` : "", + obj.className ? ` class="${obj.className}"` : "", + obj.title ? ` title="${obj.title}"` : "", + ].reduce((p, e) => p + e, ""); } + private static buildBaseInputParams(obj: Partial, recurse = false) { + const { disabled, form, name, type, ...remainder } = obj; + return [ + recurse ? this.buildBaseParams(remainder) : "", + type ? ` type="${type}"` : "", + name ? ` name="${name}"` : "", + form ? ` form="${form}"` : "", + disabled ? " disabled" : "", + ].reduce((p, e) => p + e, ""); + } + private static buildCommonInputParams(obj: Partial, recurse = false) { + const { + autocapitalize, + autocomplete, + list, + readonly, + required, + value, + ...remainder + } = obj; + return [ + recurse ? this.buildBaseInputParams(remainder) : "", + autocapitalize ? ` autocapitalize="${autocapitalize}"` : "", + autocomplete ? ` autocomplete="${autocomplete}"` : "", + list ? ` list="${list}"` : "", + value ? ` value="${value}"` : "", + readonly ? " readonly" : "", + required ? " required" : "", + ].reduce((p, e) => p + e, ""); + } + private static buildNumericInputParams(obj: Partial) { + const { + max, + min, + step, + } = obj; + return [ + max ? ` max="${max}"` : "", + min ? ` min="${min}"` : "", + step ? ` step="${step}"` : "", + ].reduce((p, e) => p + e, ""); + } + private static buildTextualInputParams(obj: Partial) { + const { + dirname, + maxlength, + minlength, + pattern, + placeholder, + size, + } = obj; + return [ + (dirname !== undefined) ? ` dirname="${dirname}"` : "", + (maxlength !== undefined) ? ` maxlength="${maxlength}"` : "", + (minlength !== undefined) ? ` minlength="${minlength}"` : "", + (pattern !== undefined) ? ` pattern="${pattern}"` : "", + (placeholder !== undefined) ? ` placeholder="${placeholder}"` : "", + (size !== undefined) ? ` size="${size}"` : "", + ].reduce((p, e) => p + e, ""); + } + // #endregion Attribute helpers public static inputCheckboxBuilder(obj: Partial = {}) { + obj.type = "checkbox"; return html`` as HTMLInputElement; + ${this.buildBaseInputParams(obj)} + ${this.buildBaseParams(obj)} + ${obj.checked ? "checked" : ""} + />` as HTMLInputElement; } public static inputNumberBuilder(obj: Partial = {}) { + obj.type = "number"; return html`` as HTMLInputElement; + ${this.buildBaseParams(obj)} + ${this.buildBaseInputParams(obj)} + ${this.buildCommonInputParams(obj)} + ${this.buildNumericInputParams(obj)} + />` as HTMLInputElement; } public static inputTextBuilder(obj: Partial = {}) { + obj.type = "text"; return html`` as HTMLInputElement; + ${this.buildBaseParams(obj)} + ${this.buildBaseInputParams(obj)} + ${this.buildCommonInputParams(obj)} + ${this.buildTextualInputParams(obj)} + />` as HTMLInputElement; } public static labelBuilder(obj: Partial) { return html`` as HTMLLabelElement; + ${obj.forElement ? ` for="${obj.forElement}"` : ""} + >${obj.innerHtml ?? ""}` as HTMLLabelElement; } public static optionBuilder(obj: Partial) { @@ -50,7 +111,7 @@ export default class HtmlBuilder { ${obj.label ? ` label="${obj.label}"` : ""} ${obj.selected ? " selected" : ""} ${obj.disabled ? " disabled" : ""} - >${typeof obj.innerHtml}`; + >${obj.innerHtml ?? ""}` as HTMLOptionElement; } public static selectBuilder(obj: Partial) { return html``; + >${obj.innerHtml ?? ""}` as HTMLSelectElement; + } + public static button(obj: Partial) { + return html`` as HTMLButtonElement; } + // #endregion HTML Elements } type BaseHtmlParams = { id: string, className: string, title: string }; type ParentHtmlParams = { innerHtml: string | HTMLElement | (string | HTMLElement)[] } & BaseHtmlParams; -type LabelParam = { forElement: string } & ParentHtmlParams; -type SelectParam = { - autocomplete: string, - autofocus: boolean, - disabled: boolean, - form: string, - multiple: boolean, - name: string, - required: boolean, - size: number, -} & ParentHtmlParams; -type OptionParam = { - disabled: boolean, - label: string, - selected: boolean, - value: string, -} & ParentHtmlParams; -type InputParams = { - type: string, +// #region elements +type InputTypes = "button" | "checkbox" | "color" | "date" | "datetime-local" | "email" | "file" | "hidden" | "image" | "month" | "number" | "password" | "radio" | "range" | "reset" | "search" | "submit" | "tel" | "text" | "time" | "url" | "week"/* | "datetime " */; +type BaseInputParams = { + type: InputTypes, name: string, // required: boolean, - value: string, + form: string, + disabled: string, } & BaseHtmlParams; -interface InputCheckboxParams extends InputParams { +type CommonInputParams = { + /** All except `url`, `email`, & `password` */ + autocapitalize: `none` | "off" | "sentences" | "on" | "words" | "characters", + /** All except `checkbox`, `radio`, & buttons */ + autocomplete: string, + /** All except `hidden`, `password`, `checkbox`, `radio`, & buttons */ + list: string, + /** All except `hidden`, `range`, `color`, `checkbox`, `radio`, & buttons */ + readonly: boolean, + /** All except `hidden`, `range`, `color`, & buttons */ + required: boolean, + /** All except `image` */ + value: string, +} & BaseInputParams; +type NumericInputParams = { + max: number; + min: number; + step: number | "any"; +}; +/** `text`, `search`, `url`, `tel`, `email`, & usually `password` */ +type TextualInputParams = { + /** Not for `password`, also for `hidden` */ + dirname: string; + maxlength: number; + minlength: number; + pattern: string; + /** Also for `number` */ + placeholder: string; + size: number; +} & CommonInputParams; +interface InputCheckboxParams extends Pick, BaseInputParams { type: "checkbox", - // required: boolean, checked: boolean, + /** + * Render as a switch? + * + * Limited browser support; ignored on unsupported browsers. [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#switch) + */ + switch: boolean, } -interface InputTextParams extends InputParams { +interface InputTextParams extends BaseInputParams, TextualInputParams, CommonInputParams { type: "text", - maxlength: number, - minlength: number, - placeholder: string, required: boolean, } -interface InputNumberParams extends InputParams { +interface InputNumberParams extends BaseInputParams, NumericInputParams, CommonInputParams { type: "number", - max: number, - min: number, - step: number | any, placeholder: string, required: boolean, -} \ No newline at end of file +} +// #endregion elements +type InputLikeAttributes = Pick & { autofocus: boolean }; +type SelectParam = { + autocomplete: string, + multiple: boolean, + required: boolean, + size: number, +} & InputLikeAttributes & ParentHtmlParams; +type OptionParam = { + disabled: boolean, + label: string, + selected: boolean, + value: string, +} & ParentHtmlParams; +type ButtonAttributes = /* Pick & */InputLikeAttributes & ParentHtmlParams & { + type: "button" | "submit" | "reset", + command: "show-modal" | "close" | "request-close" | "show-popover" | "hide-popover" | "toggle-popover" | `--${Command}`, + commandfor: string, + formaction: string, + formenctype: "application/x-www-form-urlencoded" | "multipart/form-data" | "text/plain", + formmethod: "post" | "get" | "dialog", + formnovalidate: boolean, + formtarget: "post" | "get" | "dialog", + interestfor: string, + popovertarget: string, + popovertargetaction: "hide" | "show" | "toggle", + value: string, +}; +type LabelParam = { forElement: string } & ParentHtmlParams;