"use strict"; class Prx { static addHook (prop, hook) { this.px._hooks[prop] = this.px._hooks[prop] || []; this.px._hooks[prop].push(hook); } static addHookAll (hook) { this.px._hooksAll.push(hook); } static toString () { return JSON.stringify(this, (k, v) => k === "px" ? undefined : v); } static copy () { return JSON.parse(Prx.toString.bind(this)()); } static get (toProxy) { toProxy.px = { addHook: Prx.addHook.bind(toProxy), addHookAll: Prx.addHookAll.bind(toProxy), toString: Prx.toString.bind(toProxy), copy: Prx.copy.bind(toProxy), _hooksAll: [], _hooks: {}, }; return new Proxy(toProxy, { set: (object, prop, value) => { object[prop] = value; toProxy.px._hooksAll.forEach(hook => hook(prop, value)); if (toProxy.px._hooks[prop]) toProxy.px._hooks[prop].forEach(hook => hook(prop, value)); return true; }, deleteProperty: (object, prop) => { delete object[prop]; toProxy.px._hooksAll.forEach(hook => hook(prop, null)); if (toProxy.px._hooks[prop]) toProxy.px._hooks[prop].forEach(hook => hook(prop, null)); return true; }, }); } } /** * @mixin * @param {Class} Cls */ function MixinProxyBase (Cls) { class MixedProxyBase extends Cls { constructor (...args) { super(...args); this.__hooks = {}; this.__hooksAll = {}; this.__hooksTmp = null; this.__hooksAllTmp = null; } _getProxy (hookProp, toProxy) { return new Proxy(toProxy, { set: (object, prop, value) => { return this._doProxySet(hookProp, object, prop, value); }, deleteProperty: (object, prop) => { if (!(prop in object)) return true; const prevValue = object[prop]; Reflect.deleteProperty(object, prop); this._doFireHooksAll(hookProp, prop, undefined, prevValue); if (this.__hooks[hookProp] && this.__hooks[hookProp][prop]) this.__hooks[hookProp][prop].forEach(hook => hook(prop, undefined, prevValue)); return true; }, }); } _doProxySet (hookProp, object, prop, value) { if (object[prop] === value) return true; const prevValue = object[prop]; Reflect.set(object, prop, value); this._doFireHooksAll(hookProp, prop, value, prevValue); this._doFireHooks(hookProp, prop, value, prevValue); return true; } /** As per `_doProxySet`, but the hooks are run strictly in serial. */ async _pDoProxySet (hookProp, object, prop, value) { if (object[prop] === value) return true; const prevValue = object[prop]; Reflect.set(object, prop, value); if (this.__hooksAll[hookProp]) for (const hook of this.__hooksAll[hookProp]) await hook(prop, value, prevValue); if (this.__hooks[hookProp] && this.__hooks[hookProp][prop]) for (const hook of this.__hooks[hookProp][prop]) await hook(prop, value, prevValue); return true; } _doFireHooks (hookProp, prop, value, prevValue) { if (this.__hooks[hookProp] && this.__hooks[hookProp][prop]) this.__hooks[hookProp][prop].forEach(hook => hook(prop, value, prevValue)); } _doFireHooksAll (hookProp, prop, value, prevValue) { if (this.__hooksAll[hookProp]) this.__hooksAll[hookProp].forEach(hook => hook(prop, undefined, prevValue)); } // ...Not to be confused with... _doFireAllHooks (hookProp) { if (this.__hooks[hookProp]) Object.entries(this.__hooks[hookProp]).forEach(([prop, hk]) => hk(prop)); } /** * Register a hook versus a root property on the state object. **INTERNAL CHANGES TO CHILD OBJECTS ON THE STATE * OBJECT ARE NOT TRACKED**. * @param hookProp The state object. * @param prop The root property to track. * @param hook The hook to run. Will be called with two arguments; the property and the value of the property being * modified. */ _addHook (hookProp, prop, hook) { ProxyBase._addHook_to(this.__hooks, hookProp, prop, hook); if (this.__hooksTmp) ProxyBase._addHook_to(this.__hooksTmp, hookProp, prop, hook); return hook; } static _addHook_to (obj, hookProp, prop, hook) { ((obj[hookProp] = obj[hookProp] || {})[prop] = (obj[hookProp][prop] || [])).push(hook); } _addHookAll (hookProp, hook) { ProxyBase._addHookAll_to(this.__hooksAll, hookProp, hook); if (this.__hooksAllTmp) ProxyBase._addHookAll_to(this.__hooksAllTmp, hookProp, hook); return hook; } static _addHookAll_to (obj, hookProp, hook) { (obj[hookProp] = obj[hookProp] || []).push(hook); } _removeHook (hookProp, prop, hook) { ProxyBase._removeHook_from(this.__hooks, hookProp, prop, hook); if (this.__hooksTmp) ProxyBase._removeHook_from(this.__hooksTmp, hookProp, prop, hook); } static _removeHook_from (obj, hookProp, prop, hook) { if (obj[hookProp] && obj[hookProp][prop]) { const ix = obj[hookProp][prop].findIndex(hk => hk === hook); if (~ix) obj[hookProp][prop].splice(ix, 1); } } _removeHooks (hookProp, prop) { if (this.__hooks[hookProp]) delete this.__hooks[hookProp][prop]; if (this.__hooksTmp && this.__hooksTmp[hookProp]) delete this.__hooksTmp[hookProp][prop]; } _removeHookAll (hookProp, hook) { ProxyBase._removeHookAll_from(this.__hooksAll, hookProp, hook); if (this.__hooksAllTmp) ProxyBase._removeHook_from(this.__hooksAllTmp, hookProp, hook); } static _removeHookAll_from (obj, hookProp, hook) { if (obj[hookProp]) { const ix = obj[hookProp].findIndex(hk => hk === hook); if (~ix) obj[hookProp].splice(ix, 1); } } _resetHooks (hookProp) { if (hookProp !== undefined) delete this.__hooks[hookProp]; else Object.keys(this.__hooks).forEach(prop => delete this.__hooks[prop]); } _resetHooksAll (hookProp) { if (hookProp !== undefined) delete this.__hooksAll[hookProp]; else Object.keys(this.__hooksAll).forEach(prop => delete this.__hooksAll[prop]); } _saveHookCopiesTo (obj) { this.__hooksTmp = obj; } _saveHookAllCopiesTo (obj) { this.__hooksAllTmp = obj; } /** * Object.assign equivalent, overwrites values on the current proxied object with some new values, * then trigger all the appropriate event handlers. * @param hookProp Hook property, e.g. "state". * @param proxyProp Proxied object property, e.g. "_state". * @param underProp Underlying object property, e.g. "__state". * @param toObj * @param isOverwrite If the overwrite should clean/delete all data from the object beforehand. */ _proxyAssign (hookProp, proxyProp, underProp, toObj, isOverwrite) { const oldKeys = Object.keys(this[proxyProp]); const nuKeys = new Set(Object.keys(toObj)); const dirtyKeyValues = {}; if (isOverwrite) { oldKeys.forEach(k => { if (!nuKeys.has(k) && this[underProp] !== undefined) { const prevValue = this[proxyProp][k]; delete this[underProp][k]; dirtyKeyValues[k] = prevValue; } }); } nuKeys.forEach(k => { if (!CollectionUtil.deepEquals(this[underProp][k], toObj[k])) { const prevValue = this[proxyProp][k]; this[underProp][k] = toObj[k]; dirtyKeyValues[k] = prevValue; } }); Object.entries(dirtyKeyValues) .forEach(([k, prevValue]) => { this._doFireHooksAll(hookProp, k, this[underProp][k], prevValue); if (this.__hooks[hookProp] && this.__hooks[hookProp][k]) this.__hooks[hookProp][k].forEach(hk => hk(k, this[underProp][k], prevValue)); }); } _proxyAssignSimple (hookProp, toObj, isOverwrite) { return this._proxyAssign(hookProp, `_${hookProp}`, `__${hookProp}`, toObj, isOverwrite); } } return MixedProxyBase; } class ProxyBase extends MixinProxyBase(class {}) {} globalThis.ProxyBase = ProxyBase; class UiUtil { /** * @param string String to parse. * @param [fallbackEmpty] Fallback number if string is empty. * @param [opts] Options Object. * @param [opts.max] Max allowed return value. * @param [opts.min] Min allowed return value. * @param [opts.fallbackOnNaN] Return value if not a number. */ static strToInt (string, fallbackEmpty = 0, opts) { return UiUtil._strToNumber(string, fallbackEmpty, opts, true); } /** * @param string String to parse. * @param [fallbackEmpty] Fallback number if string is empty. * @param [opts] Options Object. * @param [opts.max] Max allowed return value. * @param [opts.min] Min allowed return value. * @param [opts.fallbackOnNaN] Return value if not a number. */ static strToNumber (string, fallbackEmpty = 0, opts) { return UiUtil._strToNumber(string, fallbackEmpty, opts, false); } static _strToNumber (string, fallbackEmpty = 0, opts, isInt) { opts = opts || {}; let out; string = string.trim(); if (!string) out = fallbackEmpty; else { const num = UiUtil._parseStrAsNumber(string, isInt); out = isNaN(num) || !isFinite(num) ? opts.fallbackOnNaN !== undefined ? opts.fallbackOnNaN : 0 : num; } if (opts.max != null) out = Math.min(out, opts.max); if (opts.min != null) out = Math.max(out, opts.min); return out; } /** * @param string String to parse. * @param [fallbackEmpty] Fallback value if string is empty. * @param [opts] Options Object. * @param [opts.fallbackOnNaB] Return value if not a boolean. */ static strToBool (string, fallbackEmpty = null, opts) { opts = opts || {}; if (!string) return fallbackEmpty; string = string.trim().toLowerCase(); if (!string) return fallbackEmpty; return string === "true" ? true : string === "false" ? false : opts.fallbackOnNaB; } static intToBonus (int, {isPretty = false} = {}) { return `${int >= 0 ? "+" : int < 0 ? (isPretty ? "\u2012" : "-") : ""}${Math.abs(int)}`; } static getEntriesAsText (entryArray) { if (!entryArray || !entryArray.length) return ""; if (!(entryArray instanceof Array)) return UiUtil.getEntriesAsText([entryArray]); return entryArray .map(it => { if (typeof it === "string" || typeof it === "number") return it; return JSON.stringify(it, null, 2) .split("\n") .map(it => ` ${it}`) // Indent non-string content ; }) .flat() .join("\n"); } static getTextAsEntries (text) { try { const lines = text .split("\n") .filter(it => it.trim()) .map(it => { if (/^\s/.exec(it)) return it; // keep indented lines as-is return `"${it.replace(/"/g, `\\"`)}",`; // wrap strings }) .map(it => { if (/[}\]]$/.test(it.trim())) return `${it},`; // Add trailing commas to closing `}`/`]` return it; }); const json = `[\n${lines.join("")}\n]` // remove trailing commas .replace(/(.*?)(,)(:?\s*]|\s*})/g, "$1$3"); return JSON.parse(json); } catch (e) { const lines = text.split("\n").filter(it => it.trim()); const slice = lines.join(" \\ ").substring(0, 30); JqueryUtil.doToast({ content: `Could not parse entries! Error was: ${e.message}
Text was: ${slice}${slice.length === 30 ? "..." : ""}`, type: "danger", }); return lines; } } /** * @param {Object} [opts] Options object. * @param {string} [opts.title] Modal title. * * @param {string} [opts.title] Modal title. * * @param {string} [opts.window] Browser window. * * @param [opts.isUncappedHeight] {boolean} * @param [opts.isUncappedWidth] {boolean} * @param [opts.isHeight100] {boolean} * @param [opts.isWidth100] {boolean} * @param [opts.isMinHeight0] {boolean} * @param [opts.isMinWidth0] {boolean} * @param [opts.isMaxWidth640p] {boolean} * @param [opts.isFullscreenModal] {boolean} An alternate mode. * @param [opts.isHeaderBorder] {boolean} * * @param {function} [opts.cbClose] Callback run when the modal is closed. * @param {jQuery} [opts.$titleSplit] Element to have split alongside the title. * @param {int} [opts.zIndex] Z-index of the modal. * @param {number} [opts.overlayColor] Overlay color. * @param {boolean} [opts.isPermanent] If the modal should be impossible to close. * @param {boolean} [opts.isIndestructible] If the modal elements should be detached, not removed. * @param {boolean} [opts.isClosed] If the modal should start off closed. * @param {boolean} [opts.isEmpty] If the modal should contain no content. * @param {boolean} [opts.hasFooter] If the modal has a footer. * @returns {object} */ static getShowModal (opts) { opts = opts || {}; const doc = (opts.window || window).document; UiUtil._initModalEscapeHandler({doc}); UiUtil._initModalMouseupHandlers({doc}); if (doc.activeElement) doc.activeElement.blur(); // blur any active element as it will be behind the modal let resolveModal; const pResolveModal = new Promise(resolve => { resolveModal = resolve; }); // if the user closed the modal by clicking the "cancel" background, isDataEntered is false const pHandleCloseClick = async (isDataEntered, ...args) => { if (opts.cbClose) await opts.cbClose(isDataEntered, ...args); resolveModal([isDataEntered, ...args]); if (opts.isIndestructible) wrpOverlay.detach(); else wrpOverlay.remove(); ContextUtil.closeAllMenus(); doTeardown(); }; const doTeardown = () => { UiUtil._popFromModalStack(modalStackMeta); if (!UiUtil._MODAL_STACK.length) doc.body.classList.remove(`ui-modal__body-active`); }; const doOpen = () => { wrpOverlay.appendTo(doc.body); doc.body.classList.add(`ui-modal__body-active`); }; const wrpOverlay = e_({tag: "div", clazz: "ui-modal__overlay"}); if (opts.zIndex != null) wrpOverlay.style.zIndex = `${opts.zIndex}`; if (opts.overlayColor != null) wrpOverlay.style.backgroundColor = `${opts.overlayColor}`; // In "fullscreen" mode, blank out the modal background const overlayBlind = opts.isFullscreenModal ? e_({ tag: "div", clazz: `ui-modal__overlay-blind w-100 h-100 ve-flex-col`, }).appendTo(wrpOverlay) : null; const wrpScroller = e_({ tag: "div", clazz: `ui-modal__scroller ve-flex-col`, }); const modalWindowClasses = [ opts.isWidth100 ? `w-100` : "", opts.isHeight100 ? "h-100" : "", opts.isUncappedHeight ? "ui-modal__inner--uncap-height" : "", opts.isUncappedWidth ? "ui-modal__inner--uncap-width" : "", opts.isMinHeight0 ? `ui-modal__inner--no-min-height` : "", opts.isMinWidth0 ? `ui-modal__inner--no-min-width` : "", opts.isMaxWidth640p ? `ui-modal__inner--max-width-640p` : "", opts.isFullscreenModal ? `ui-modal__inner--mode-fullscreen my-0 pt-0` : "", opts.hasFooter ? `pb-0` : "", ].filter(Boolean); const btnCloseModal = opts.isFullscreenModal ? e_({ tag: "button", clazz: `btn btn-danger btn-xs`, html: ` { if (evt.target !== wrpOverlay) return; if (evt.target !== UiUtil._MODAL_LAST_MOUSEDOWN) return; if (opts.isPermanent) return; evt.stopPropagation(); evt.preventDefault(); return pHandleCloseClick(false); }); if (!opts.isClosed) doOpen(); const modalStackMeta = { isPermanent: opts.isPermanent, pHandleCloseClick, doTeardown, }; if (!opts.isClosed) UiUtil._pushToModalStack(modalStackMeta); const out = { $modal: $(modal), $modalInner: $(wrpScroller), $modalFooter: $(modalFooter), doClose: pHandleCloseClick, doTeardown, pGetResolved: () => pResolveModal, }; if (opts.isIndestructible || opts.isClosed) { out.doOpen = () => { UiUtil._pushToModalStack(modalStackMeta); doOpen(); }; } return out; } /** * Async to support external overrides; should be used in common applications. */ static async pGetShowModal (opts) { return UiUtil.getShowModal(opts); } static _pushToModalStack (modalStackMeta) { if (!UiUtil._MODAL_STACK.includes(modalStackMeta)) { UiUtil._MODAL_STACK.push(modalStackMeta); } } static _popFromModalStack (modalStackMeta) { const ixStack = UiUtil._MODAL_STACK.indexOf(modalStackMeta); if (~ixStack) UiUtil._MODAL_STACK.splice(ixStack, 1); } static _initModalEscapeHandler ({doc}) { if (UiUtil._MODAL_STACK) return; UiUtil._MODAL_STACK = []; doc.addEventListener("keydown", evt => { if (evt.which !== 27) return; if (!UiUtil._MODAL_STACK.length) return; if (EventUtil.isInInput(evt)) return; const outerModalMeta = UiUtil._MODAL_STACK.last(); if (!outerModalMeta) return; evt.stopPropagation(); if (!outerModalMeta.isPermanent) return outerModalMeta.pHandleCloseClick(false); }); } static _initModalMouseupHandlers ({doc}) { doc.addEventListener("mousedown", evt => { UiUtil._MODAL_LAST_MOUSEDOWN = evt.target; }); } static isAnyModalOpen () { return !!UiUtil._MODAL_STACK?.length; } static addModalSep ($modalInner) { $modalInner.append(`
`); } static $getAddModalRow ($modalInner, tag = "div") { return $(`<${tag} class="ui-modal__row">`).appendTo($modalInner); } /** * @param $modalInner Element this row should be added to. * @param headerText Header text. * @param [opts] Options object. * @param [opts.helpText] Help text (title) of select dropdown. * @param [opts.$eleRhs] Element to attach to the right-hand side of the header. */ static $getAddModalRowHeader ($modalInner, headerText, opts) { opts = opts || {}; const $row = UiUtil.$getAddModalRow($modalInner, "h5").addClass("bold"); if (opts.$eleRhs) $$`
${headerText}${opts.$eleRhs}
`.appendTo($row); else $row.text(headerText); if (opts.helpText) $row.title(opts.helpText); return $row; } static $getAddModalRowCb ($modalInner, labelText, objectWithProp, propName, helpText) { const $row = UiUtil.$getAddModalRow($modalInner, "label").addClass(`ui-modal__row--cb`); if (helpText) $row.title(helpText); $row.append(`${labelText}`); const $cb = $(``).appendTo($row) .keydown(evt => { if (evt.key === "Escape") $cb.blur(); }) .prop("checked", objectWithProp[propName]) .on("change", () => objectWithProp[propName] = $cb.prop("checked")); return $cb; } /** * * @param $wrp * @param comp * @param prop * @param text * @param {?string} title * @return {jQuery} */ static $getAddModalRowCb2 ({$wrp, comp, prop, text, title = null }) { const $cb = ComponentUiUtil.$getCbBool(comp, prop); const $row = $$`` .appendTo($wrp); if (title) $row.title(title); return $cb; } /** * * @param $modalInner Element this row should be added to. * @param labelText Row label. * @param objectWithProp Object to mutate when changing select values. * @param propName Property to set in `objectWithProp`. * @param values Values to display in select dropdown. * @param [opts] Options object. * @param [opts.helpText] Help text (title) of select dropdown. * @param [opts.fnDisplay] Function used to map values to displayable versions. */ static $getAddModalRowSel ($modalInner, labelText, objectWithProp, propName, values, opts) { opts = opts || {}; const $row = UiUtil.$getAddModalRow($modalInner, "label").addClass(`ui-modal__row--sel`); if (opts.helpText) $row.title(opts.helpText); $row.append(`${labelText}`); const $sel = $(` ${Object.keys(this._indexes).sort().filter(it => it !== "ALL").map(it => ``).join("")} `) .appendTo($wrpControls).toggle(Object.keys(this._indexes).length !== 1) .on("change", () => { this._cat = this._$selCat.val(); this.__doSearch(); }); this._$iptSearch = $(``).appendTo($wrpControls); this._$wrpResults = $(`
`).appendTo(this._$rendered); let lastSearchTerm = ""; SearchWidget.bindAutoSearch(this._$iptSearch, { flags: this._flags, fnSearch: this.__doSearch.bind(this), fnShowWait: this.__showMsgWait.bind(this), $ptrRows: this._$ptrRows, }); // On the first keypress, switch to loading dots this._$iptSearch.keydown(evt => { if (evt.key === "Escape") this._$iptSearch.blur(); if (!this._$iptSearch.val().trim().length) return; if (evt.which !== 13) { if (lastSearchTerm === "") this.__showMsgWait(); lastSearchTerm = this._$iptSearch.val(); } }); this.__doSearch(); } } doFocus () { this._$iptSearch.focus(); } static async pAddToIndexes (prop, entry) { const nextId = Object.values(SearchWidget.CONTENT_INDICES.ALL.documentStore.docs).length; const indexer = new Omnidexer(nextId); const toIndex = {[prop]: [entry]}; const toIndexMultiPart = Omnidexer.TO_INDEX__FROM_INDEX_JSON.filter(it => it.listProp === prop); for (const it of toIndexMultiPart) await indexer.pAddToIndex(it, toIndex); const toIndexSinglePart = Omnidexer.TO_INDEX.filter(it => it.listProp === prop); for (const it of toIndexSinglePart) await indexer.pAddToIndex(it, toIndex); const toAdd = Omnidexer.decompressIndex(indexer.getIndex()); toAdd.forEach(d => { d.cf = d.c === Parser.CAT_ID_CREATURE ? "Creature" : Parser.pageCategoryToFull(d.c); SearchWidget.CONTENT_INDICES.ALL.addDoc(d); SearchWidget.CONTENT_INDICES[d.cf].addDoc(d); }); } // region entity searches static async pGetUserSpellSearch (opts) { opts = opts || {}; await SearchWidget.P_LOADING_CONTENT; const nxtOpts = { fnTransform: doc => { const cpy = MiscUtil.copyFast(doc); Object.assign(cpy, SearchWidget.docToPageSourceHash(cpy)); const hashName = UrlUtil.decodeHash(cpy.u)[0].toTitleCase(); const isRename = hashName.toLowerCase() !== cpy.n.toLowerCase(); const pts = [ isRename ? hashName : cpy.n.toSpellCase(), doc.s !== Parser.SRC_PHB ? doc.s : "", isRename ? cpy.n.toSpellCase() : "", ]; while (pts.at(-1) === "") pts.pop(); cpy.tag = `{@spell ${pts.join("|")}}`; return cpy; }, }; if (opts.level != null) nxtOpts.fnFilterResults = result => result.lvl === opts.level; const title = opts.level === 0 ? "Select Cantrip" : "Select Spell"; return SearchWidget.pGetUserEntitySearch( title, "alt_Spell", nxtOpts, ); } static async pGetUserLegendaryGroupSearch () { await SearchWidget.pLoadCustomIndex({ contentIndexName: "entity_LegendaryGroups", errorName: "legendary groups", customIndexSubSpecs: [ new SearchWidget.CustomIndexSubSpec({ dataSource: `${Renderer.get().baseUrl}data/bestiary/legendarygroups.json`, prop: "legendaryGroup", catId: Parser.CAT_ID_LEGENDARY_GROUP, page: "legendaryGroup", }), ], }); return SearchWidget.pGetUserEntitySearch( "Select Legendary Group", "entity_LegendaryGroups", { fnTransform: doc => { const cpy = MiscUtil.copyFast(doc); Object.assign(cpy, SearchWidget.docToPageSourceHash(cpy)); cpy.page = "legendaryGroup"; return cpy; }, }, ); } static async pGetUserFeatSearch () { // FIXME convert to be more like spell/creature search instead of running custom indexes await SearchWidget.pLoadCustomIndex({ contentIndexName: "entity_Feats", errorName: "feats", customIndexSubSpecs: [ new SearchWidget.CustomIndexSubSpec({ dataSource: `${Renderer.get().baseUrl}data/feats.json`, prop: "feat", catId: Parser.CAT_ID_FEAT, page: UrlUtil.PG_FEATS, }), ], }); return SearchWidget.pGetUserEntitySearch( "Select Feat", "entity_Feats", { fnTransform: doc => { const cpy = MiscUtil.copyFast(doc); Object.assign(cpy, SearchWidget.docToPageSourceHash(cpy)); cpy.tag = `{@feat ${doc.n}${doc.s !== Parser.SRC_PHB ? `|${doc.s}` : ""}}`; return cpy; }, }, ); } static async pGetUserBackgroundSearch () { // FIXME convert to be more like spell/creature search instead of running custom indexes await SearchWidget.pLoadCustomIndex({ contentIndexName: "entity_Backgrounds", errorName: "backgrounds", customIndexSubSpecs: [ new SearchWidget.CustomIndexSubSpec({ dataSource: `${Renderer.get().baseUrl}data/backgrounds.json`, prop: "background", catId: Parser.CAT_ID_BACKGROUND, page: UrlUtil.PG_BACKGROUNDS, }), ], }); return SearchWidget.pGetUserEntitySearch( "Select Background", "entity_Backgrounds", { fnTransform: doc => { const cpy = MiscUtil.copyFast(doc); Object.assign(cpy, SearchWidget.docToPageSourceHash(cpy)); cpy.tag = `{@background ${doc.n}${doc.s !== Parser.SRC_PHB ? `|${doc.s}` : ""}}`; return cpy; }, }, ); } static async pGetUserRaceSearch () { // FIXME convert to be more like spell/creature search instead of running custom indexes const dataSource = () => { return DataUtil.race.loadJSON(); }; await SearchWidget.pLoadCustomIndex({ contentIndexName: "entity_Races", errorName: "races", customIndexSubSpecs: [ new SearchWidget.CustomIndexSubSpec({ dataSource, prop: "race", catId: Parser.CAT_ID_RACE, page: UrlUtil.PG_RACES, }), ], }); return SearchWidget.pGetUserEntitySearch( "Select Race", "entity_Races", { fnTransform: doc => { const cpy = MiscUtil.copyFast(doc); Object.assign(cpy, SearchWidget.docToPageSourceHash(cpy)); cpy.tag = `{@race ${doc.n}${doc.s !== Parser.SRC_PHB ? `|${doc.s}` : ""}}`; return cpy; }, }, ); } static async pGetUserOptionalFeatureSearch () { // FIXME convert to be more like spell/creature search instead of running custom indexes await SearchWidget.pLoadCustomIndex({ contentIndexName: "entity_OptionalFeatures", errorName: "optional features", customIndexSubSpecs: [ new SearchWidget.CustomIndexSubSpec({ dataSource: `${Renderer.get().baseUrl}data/optionalfeatures.json`, prop: "optionalfeature", catId: Parser.CAT_ID_OPTIONAL_FEATURE_OTHER, page: UrlUtil.PG_OPT_FEATURES, }), ], }); return SearchWidget.pGetUserEntitySearch( "Select Optional Feature", "entity_OptionalFeatures", { fnTransform: doc => { const cpy = MiscUtil.copyFast(doc); Object.assign(cpy, SearchWidget.docToPageSourceHash(cpy)); cpy.tag = `{@optfeature ${doc.n}${doc.s !== Parser.SRC_PHB ? `|${doc.s}` : ""}}`; return cpy; }, }, ); } static async pGetUserAdventureSearch (opts) { await SearchWidget.pLoadCustomIndex({ contentIndexName: "entity_Adventures", errorName: "adventures", customIndexSubSpecs: [ new SearchWidget.CustomIndexSubSpec({ dataSource: `${Renderer.get().baseUrl}data/adventures.json`, prop: "adventure", catId: Parser.CAT_ID_ADVENTURE, page: UrlUtil.PG_ADVENTURE, }), ], }); return SearchWidget.pGetUserEntitySearch("Select Adventure", "entity_Adventures", opts); } static async pGetUserBookSearch (opts) { await SearchWidget.pLoadCustomIndex({ contentIndexName: "entity_Books", errorName: "books", customIndexSubSpecs: [ new SearchWidget.CustomIndexSubSpec({ dataSource: `${Renderer.get().baseUrl}data/books.json`, prop: "book", catId: Parser.CAT_ID_BOOK, page: UrlUtil.PG_BOOK, }), ], }); return SearchWidget.pGetUserEntitySearch("Select Book", "entity_Books", opts); } static async pGetUserAdventureBookSearch (opts) { const contentIndexName = opts.contentIndexName || "entity_AdventuresBooks"; await SearchWidget.pLoadCustomIndex({ contentIndexName, errorName: "adventures/books", customIndexSubSpecs: [ new SearchWidget.CustomIndexSubSpec({ dataSource: `${Renderer.get().baseUrl}data/adventures.json`, prop: "adventure", catId: Parser.CAT_ID_ADVENTURE, page: UrlUtil.PG_ADVENTURE, pFnGetDocExtras: opts.pFnGetDocExtras, }), new SearchWidget.CustomIndexSubSpec({ dataSource: `${Renderer.get().baseUrl}data/books.json`, prop: "book", catId: Parser.CAT_ID_BOOK, page: UrlUtil.PG_BOOK, pFnGetDocExtras: opts.pFnGetDocExtras, }), ], }); return SearchWidget.pGetUserEntitySearch("Select Adventure or Book", contentIndexName, opts); } static async pGetUserCreatureSearch () { await SearchWidget.P_LOADING_CONTENT; const nxtOpts = { fnTransform: doc => { const cpy = MiscUtil.copyFast(doc); Object.assign(cpy, SearchWidget.docToPageSourceHash(cpy)); cpy.tag = `{@creature ${doc.n}${doc.s !== Parser.SRC_MM ? `|${doc.s}` : ""}}`; return cpy; }, }; return SearchWidget.pGetUserEntitySearch( "Select Creature", "Creature", nxtOpts, ); } static async __pLoadItemIndex (isBasicIndex) { const dataSource = async () => { const allItems = (await Renderer.item.pBuildList()).filter(it => !it._isItemGroup); return { item: allItems.filter(it => { if (it.type === "GV") return false; if (isBasicIndex == null) return true; const isBasic = it.rarity === "none" || it.rarity === "unknown" || it._category === "basic"; return isBasicIndex ? isBasic : !isBasic; }), }; }; const indexName = isBasicIndex == null ? "entity_Items" : isBasicIndex ? "entity_ItemsBasic" : "entity_ItemsMagic"; return SearchWidget.pLoadCustomIndex({ contentIndexName: indexName, errorName: "items", customIndexSubSpecs: [ new SearchWidget.CustomIndexSubSpec({ dataSource, prop: "item", catId: Parser.CAT_ID_ITEM, page: UrlUtil.PG_ITEMS, }), ], }); } static async __pGetUserItemSearch (isBasicIndex) { const indexName = isBasicIndex == null ? "entity_Items" : isBasicIndex ? "entity_ItemsBasic" : "entity_ItemsMagic"; return SearchWidget.pGetUserEntitySearch( "Select Item", indexName, { fnTransform: doc => { const cpy = MiscUtil.copyFast(doc); Object.assign(cpy, SearchWidget.docToPageSourceHash(cpy)); cpy.tag = `{@item ${doc.n}${doc.s !== Parser.SRC_DMG ? `|${doc.s}` : ""}}`; return cpy; }, }, ); } static async pGetUserBasicItemSearch () { await SearchWidget.__pLoadItemIndex(true); return SearchWidget.__pGetUserItemSearch(true); } static async pGetUserMagicItemSearch () { await SearchWidget.__pLoadItemIndex(false); return SearchWidget.__pGetUserItemSearch(false); } static async pGetUserItemSearch () { await SearchWidget.__pLoadItemIndex(); return SearchWidget.__pGetUserItemSearch(); } // endregion /** * * @param title * @param indexName * @param [opts] * @param [opts.fnFilterResults] * @param [opts.fnTransform] */ static async pGetUserEntitySearch (title, indexName, opts) { opts = opts || {}; return new Promise(resolve => { const searchOpts = {defaultCategory: indexName}; if (opts.fnFilterResults) searchOpts.fnFilterResults = opts.fnFilterResults; if (opts.fnTransform) searchOpts.fnTransform = opts.fnTransform; const searchWidget = new SearchWidget( {[indexName]: SearchWidget.CONTENT_INDICES[indexName]}, (docOrTransformed) => { doClose(false); // "cancel" close resolve(docOrTransformed); }, searchOpts, ); const {$modalInner, doClose} = UiUtil.getShowModal({ title, cbClose: (doResolve) => { searchWidget.$wrpSearch.detach(); if (doResolve) resolve(null); // ensure resolution }, }); $modalInner.append(searchWidget.$wrpSearch); searchWidget.doFocus(); }); } // region custom search indexes static CustomIndexSubSpec = class { constructor ({dataSource, prop, catId, page, pFnGetDocExtras}) { this.dataSource = dataSource; this.prop = prop; this.catId = catId; this.page = page; this.pFnGetDocExtras = pFnGetDocExtras; } }; static async pLoadCustomIndex ({contentIndexName, customIndexSubSpecs, errorName}) { if (SearchWidget.P_LOADING_INDICES[contentIndexName]) await SearchWidget.P_LOADING_INDICES[contentIndexName]; else { const doClose = SearchWidget._showLoadingModal(); try { SearchWidget.P_LOADING_INDICES[contentIndexName] = (SearchWidget.CONTENT_INDICES[contentIndexName] = await SearchWidget._pGetIndex(customIndexSubSpecs)); SearchWidget.P_LOADING_INDICES[contentIndexName] = null; } catch (e) { JqueryUtil.doToast({type: "danger", content: `Could not load ${errorName}! ${VeCt.STR_SEE_CONSOLE}`}); throw e; } finally { doClose(); } } } static async _pGetIndex (customIndexSubSpecs) { const index = elasticlunr(function () { this.addField("n"); this.addField("s"); this.setRef("id"); }); let id = 0; for (const subSpec of customIndexSubSpecs) { const [json, prerelease, brew] = await Promise.all([ typeof subSpec.dataSource === "string" ? DataUtil.loadJSON(subSpec.dataSource) : subSpec.dataSource(), PrereleaseUtil.pGetBrewProcessed(), BrewUtil2.pGetBrewProcessed(), ]); await [ ...json[subSpec.prop], ...(prerelease[subSpec.prop] || []), ...(brew[subSpec.prop] || []), ] .pSerialAwaitMap(async ent => { const doc = { id: id++, c: subSpec.catId, cf: Parser.pageCategoryToFull(subSpec.catId), h: 1, n: ent.name, q: subSpec.page, s: ent.source, u: UrlUtil.URL_TO_HASH_BUILDER[subSpec.page](ent), }; if (subSpec.pFnGetDocExtras) Object.assign(doc, await subSpec.pFnGetDocExtras({ent, doc, subSpec})); index.addDoc(doc); }); } return index; } static _showLoadingModal () { const {$modalInner, doClose} = UiUtil.getShowModal({isPermanent: true}); $(`
Loading...
`).appendTo($modalInner); return doClose; } // endregion } SearchWidget.P_LOADING_CONTENT = null; SearchWidget.CONTENT_INDICES = {}; SearchWidget.P_LOADING_INDICES = {}; class InputUiUtil { static async _pGetShowModal (getShowModalOpts) { return UiUtil.getShowModal(getShowModalOpts); } static _$getBtnOk ({comp = null, opts, doClose}) { return $(``) .click(evt => { evt.stopPropagation(); if (comp && !comp._state.isValid) return JqueryUtil.doToast({content: `Please enter valid input!`, type: "warning"}); doClose(true); }); } static _$getBtnCancel ({comp = null, opts, doClose}) { return $(``) .click(evt => { evt.stopPropagation(); doClose(false); }); } static _$getBtnSkip ({comp = null, opts, doClose}) { return !opts.isSkippable ? null : $(``) .click(evt => { evt.stopPropagation(); doClose(VeCt.SYM_UI_SKIP); }); } /* -------------------------------------------- */ static GenericButtonInfo = class { constructor ( { text, clazzIcon, isPrimary, isSmall, isRemember, value, }, ) { this._text = text; this._clazzIcon = clazzIcon; this._isPrimary = isPrimary; this._isSmall = isSmall; this._isRemember = isRemember; this._value = value; } get isPrimary () { return !!this._isPrimary; } $getBtn ({doClose, fnRemember, isGlobal, storageKey}) { if (this._isRemember && !storageKey && !fnRemember) throw new Error(`No "storageKey" or "fnRemember" provided for button with saveable value!`); return $(``) .on("click", evt => { evt.stopPropagation(); doClose(true, this._value); if (!this._isRemember) return; if (fnRemember) { fnRemember(this._value); } else { isGlobal ? StorageUtil.pSet(storageKey, true) : StorageUtil.pSetForPage(storageKey, true); } }); } }; static async pGetUserGenericButton ( { title, buttons, textSkip, htmlDescription, $eleDescription, storageKey, isGlobal, fnRemember, isSkippable, isIgnoreRemembered, }, ) { if (storageKey && !isIgnoreRemembered) { const prev = await (isGlobal ? StorageUtil.pGet(storageKey) : StorageUtil.pGetForPage(storageKey)); if (prev != null) return prev; } const {$modalInner, doClose, pGetResolved, doAutoResize: doAutoResizeModal} = await InputUiUtil._pGetShowModal({ title: title || "Choose", isMinHeight0: true, }); const $btns = buttons.map(btnInfo => btnInfo.$getBtn({doClose, fnRemember, isGlobal, storageKey})); const $btnSkip = !isSkippable ? null : $(``) .click(evt => { evt.stopPropagation(); doClose(VeCt.SYM_UI_SKIP); }); if ($eleDescription?.length) $$`
${$eleDescription}
`.appendTo($modalInner); else if (htmlDescription && htmlDescription.trim()) $$`
${htmlDescription}
`.appendTo($modalInner); $$`
${$btns}${$btnSkip}
`.appendTo($modalInner); if (doAutoResizeModal) doAutoResizeModal(); const ixPrimary = buttons.findIndex(btn => btn.isPrimary); if (~ixPrimary) { $btns[ixPrimary].focus(); $btns[ixPrimary].select(); } // region Output const [isDataEntered, out] = await pGetResolved(); if (typeof isDataEntered === "symbol") return isDataEntered; if (!isDataEntered) return null; if (out == null) throw new Error(`Callback must receive a value!`); // sense check return out; // endregion } /** * @param [title] Prompt title. * @param [textYesRemember] Text for "yes, and remember" button. * @param [textYes] Text for "yes" button. * @param [textNo] Text for "no" button. * @param [textSkip] Text for "skip" button. * @param [htmlDescription] Description HTML for the modal. * @param [$eleDescription] Description element for the modal. * @param [storageKey] Storage key to use when "remember" options are passed. * @param [isGlobal] If the stored setting is global when "remember" options are passed. * @param [fnRemember] Custom function to run when saving the "yes and remember" option. * @param [isSkippable] If the prompt is skippable. * @param [isAlert] If this prompt is just a notification/alert. * @param [isIgnoreRemembered] If the remembered value should be ignored, in favour of re-prompting the user. * @return {Promise} A promise which resolves to true/false if the user chose, or null otherwise. */ static async pGetUserBoolean ( { title, textYesRemember, textYes, textNo, textSkip, htmlDescription, $eleDescription, storageKey, isGlobal, fnRemember, isSkippable, isAlert, isIgnoreRemembered, }, ) { const buttons = []; if (textYesRemember) { buttons.push( new this.GenericButtonInfo({ text: textYesRemember, clazzIcon: "glyphicon glyphicon-ok", isRemember: true, isPrimary: true, value: true, }), ); } buttons.push( new this.GenericButtonInfo({ text: textYes || "OK", clazzIcon: "glyphicon glyphicon-ok", isPrimary: true, value: true, }), ); // TODO(Future) migrate usages to `pGetUserGenericButton` (or helper method) if (!isAlert) { buttons.push( new this.GenericButtonInfo({ text: textNo || "Cancel", clazzIcon: "glyphicon glyphicon-remove", isSmall: true, value: false, }), ); } return this.pGetUserGenericButton({ title, buttons, textSkip, htmlDescription, $eleDescription, storageKey, isGlobal, fnRemember, isSkippable, isIgnoreRemembered, }); } /* -------------------------------------------- */ /** * @param opts Options. * @param opts.min Minimum value. * @param opts.max Maximum value. * @param opts.int If the value returned should be an integer. * @param opts.title Prompt title. * @param opts.default Default value. * @param [opts.$elePre] Element to add before the number input. * @param [opts.$elePost] Element to add after the number input. * @param [opts.isPermanent] If the prompt can only be closed by entering a number. * @param [opts.isSkippable] If the prompt is skippable. * @param [opts.storageKey_default] Storage key for a "default" value override using the user's last/previous input. * @param [opts.isGlobal_default] If the "default" storage key is global (rather than page-specific). * @return {Promise} A promise which resolves to the number if the user entered one, or null otherwise. */ static async pGetUserNumber (opts) { opts = opts || {}; let defaultVal = opts.default !== undefined ? opts.default : null; if (opts.storageKey_default) { const prev = await (opts.isGlobal_default ? StorageUtil.pGet(opts.storageKey_default) : StorageUtil.pGetForPage(opts.storageKey_default)); if (prev != null) defaultVal = prev; } const $iptNumber = $(``) .keydown(evt => { if (evt.key === "Escape") { $iptNumber.blur(); return; } evt.stopPropagation(); if (evt.key === "Enter") { evt.preventDefault(); doClose(true); } }); if (defaultVal !== undefined) $iptNumber.val(defaultVal); const {$modalInner, doClose, pGetResolved, doAutoResize: doAutoResizeModal} = await InputUiUtil._pGetShowModal({ title: opts.title || "Enter a Number", isMinHeight0: true, }); const $btnOk = this._$getBtnOk({opts, doClose}); const $btnCancel = this._$getBtnCancel({opts, doClose}); const $btnSkip = this._$getBtnSkip({opts, doClose}); if (opts.$elePre) opts.$elePre.appendTo($modalInner); $iptNumber.appendTo($modalInner); if (opts.$elePost) opts.$elePost.appendTo($modalInner); $$`
${$btnOk}${$btnCancel}${$btnSkip}
`.appendTo($modalInner); if (doAutoResizeModal) doAutoResizeModal(); $iptNumber.focus(); $iptNumber.select(); // region Output const [isDataEntered] = await pGetResolved(); if (typeof isDataEntered === "symbol") return isDataEntered; if (!isDataEntered) return null; const outRaw = $iptNumber.val(); if (!outRaw.trim()) return null; let out = UiUtil.strToInt(outRaw); if (opts.min) out = Math.max(opts.min, out); if (opts.max) out = Math.min(opts.max, out); if (opts.int) out = Math.round(out); if (opts.storageKey_default) { opts.isGlobal_default ? StorageUtil.pSet(opts.storageKey_default, out).then(null) : StorageUtil.pSetForPage(opts.storageKey_default, out).then(null); } return out; // endregion } /** * @param opts Options. * @param opts.values Array of values. * @param [opts.placeholder] Placeholder text. * @param [opts.title] Prompt title. * @param [opts.default] Default selected index. * @param [opts.fnDisplay] Function which takes a value and returns display text. * @param [opts.isResolveItem] True if the promise should resolve the item instead of the index. * @param [opts.$elePost] Element to add below the select box. * @param [opts.fnGetExtraState] Function which returns additional state from, generally, other elements in the modal. * @param [opts.isAllowNull] If an empty input should be treated as null. * @param [opts.isSkippable] If the prompt is skippable. * @return {Promise} A promise which resolves to the index of the item the user selected (or an object if fnGetExtraState is passed), or null otherwise. */ static async pGetUserEnum (opts) { opts = opts || {}; const $selEnum = $(``) .keydown(async evt => { evt.stopPropagation(); if (evt.key === "Enter") { evt.preventDefault(); doClose(true); } }); if (opts.isAllowNull) $(``).text(opts.fnDisplay ? opts.fnDisplay(null, -1) : "(None)").appendTo($selEnum); opts.values.forEach((v, i) => $(``).text(opts.fnDisplay ? opts.fnDisplay(v, i) : v).appendTo($selEnum)); if (opts.default != null) $selEnum.val(opts.default); else $selEnum[0].selectedIndex = 0; const {$modalInner, doClose, pGetResolved, doAutoResize: doAutoResizeModal} = await InputUiUtil._pGetShowModal({ title: opts.title || "Select an Option", isMinHeight0: true, }); const $btnOk = this._$getBtnOk({opts, doClose}); const $btnCancel = this._$getBtnCancel({opts, doClose}); const $btnSkip = this._$getBtnSkip({opts, doClose}); $selEnum.appendTo($modalInner); if (opts.$elePost) opts.$elePost.appendTo($modalInner); $$`
${$btnOk}${$btnCancel}${$btnSkip}
`.appendTo($modalInner); if (doAutoResizeModal) doAutoResizeModal(); $selEnum.focus(); // region Output const [isDataEntered] = await pGetResolved(); if (typeof isDataEntered === "symbol") return isDataEntered; if (!isDataEntered) return null; const ix = Number($selEnum.val()); if (!~ix) return null; if (opts.fnGetExtraState) { const out = {extraState: opts.fnGetExtraState()}; if (opts.isResolveItem) out.item = opts.values[ix]; else out.ix = ix; return out; } return opts.isResolveItem ? opts.values[ix] : ix; // endregion } /** * @param opts Options. * @param [opts.values] Array of values. Mutually incompatible with "valueGroups". * @param [opts.valueGroups] Array of value groups (of the form `{name: "Group Name", values: [...]}`). Mutually incompatible with "values". * @param [opts.title] Prompt title. * @param [opts.htmlDescription] Description HTML for the modal. * @param [opts.count] Number of choices the user can make (cannot be used with min/max). * @param [opts.min] Minimum number of choices the user can make (cannot be used with count). * @param [opts.max] Maximum number of choices the user can make (cannot be used with count). * @param [opts.defaults] Array of default-selected indices. * @param [opts.required] Array of always-selected indices. * @param [opts.isResolveItems] True if the promise should resolve to an array of the items instead of the indices. * @param [opts.fnDisplay] Function which takes a value and returns display text. * @param [opts.modalOpts] Options to pass through to the underlying modal class. * @param [opts.isSkippable] If the prompt is skippable. * @param [opts.isSearchable] If a search input should be created. * @param [opts.fnGetSearchText] Function which takes a value and returns search text. * @return {Promise} A promise which resolves to the indices of the items the user selected, or null otherwise. */ static async pGetUserMultipleChoice (opts) { const prop = "formData"; const initialState = {}; if (opts.defaults) opts.defaults.forEach(ix => initialState[ComponentUiUtil.getMetaWrpMultipleChoice_getPropIsActive(prop, ix)] = true); if (opts.required) { opts.required.forEach(ix => { initialState[ComponentUiUtil.getMetaWrpMultipleChoice_getPropIsActive(prop, ix)] = true; // "requires" implies "default" initialState[ComponentUiUtil.getMetaWrpMultipleChoice_getPropIsRequired(prop, ix)] = true; }); } const comp = BaseComponent.fromObject(initialState); let title = opts.title; if (!title) { if (opts.count != null) title = `Choose ${Parser.numberToText(opts.count).uppercaseFirst()}`; else if (opts.min != null && opts.max != null) title = `Choose Between ${Parser.numberToText(opts.min).uppercaseFirst()} and ${Parser.numberToText(opts.max).uppercaseFirst()} Options`; else if (opts.min != null) title = `Choose At Least ${Parser.numberToText(opts.min).uppercaseFirst()}`; else title = `Choose At Most ${Parser.numberToText(opts.max).uppercaseFirst()}`; } const {$ele: $wrpList, $iptSearch, propIsAcceptable} = ComponentUiUtil.getMetaWrpMultipleChoice(comp, prop, opts); $wrpList.addClass(`mb-1`); const {$modalInner, doClose, pGetResolved, doAutoResize: doAutoResizeModal} = await InputUiUtil._pGetShowModal({ ...(opts.modalOpts || {}), title, isMinHeight0: true, isUncappedHeight: true, }); const $btnOk = this._$getBtnOk({opts, doClose}); const $btnCancel = this._$getBtnCancel({opts, doClose}); const $btnSkip = this._$getBtnSkip({opts, doClose}); const hkIsAcceptable = () => $btnOk.attr("disabled", !comp._state[propIsAcceptable]); comp._addHookBase(propIsAcceptable, hkIsAcceptable); hkIsAcceptable(); if (opts.htmlDescription) $modalInner.append(opts.htmlDescription); if ($iptSearch) { $$``.appendTo($modalInner); } $wrpList.appendTo($modalInner); $$`
${$btnOk}${$btnCancel}${$btnSkip}
`.appendTo($modalInner); if (doAutoResizeModal) doAutoResizeModal(); $wrpList.focus(); // region Output const [isDataEntered] = await pGetResolved(); if (typeof isDataEntered === "symbol") return isDataEntered; if (!isDataEntered) return null; const ixs = ComponentUiUtil.getMetaWrpMultipleChoice_getSelectedIxs(comp, prop); if (!opts.isResolveItems) return ixs; if (opts.values) return ixs.map(ix => opts.values[ix]); if (opts.valueGroups) { const allValues = opts.valueGroups.map(it => it.values).flat(); return ixs.map(ix => allValues[ix]); } throw new Error(`Should never occur!`); // endregion } /** * NOTE: designed to work with FontAwesome. * * @param opts Options. * @param opts.values Array of icon metadata. Items should be of the form: `{name: "", iconClass: "", buttonClass: "", buttonClassActive: ""}` * @param opts.title Prompt title. * @param opts.default Default selected index. * @param [opts.isSkippable] If the prompt is skippable. * @return {Promise} A promise which resolves to the index of the item the user selected, or null otherwise. */ static async pGetUserIcon (opts) { opts = opts || {}; let lastIx = opts.default != null ? opts.default : -1; const onclicks = []; const {$modalInner, doClose, pGetResolved, doAutoResize: doAutoResizeModal} = await InputUiUtil._pGetShowModal({ title: opts.title || "Select an Option", isMinHeight0: true, }); $$`
${opts.values.map((v, i) => { const $btn = $$`
${v.iconClass ? `
` : ""} ${v.iconContent ? v.iconContent : ""}
${v.name}
` .click(() => { lastIx = i; onclicks.forEach(it => it()); }) .toggleClass(v.buttonClassActive || "active", opts.default === i); if (v.buttonClassActive && opts.default === i) { $btn.removeClass("btn-default").addClass(v.buttonClassActive); } onclicks.push(() => { $btn.toggleClass(v.buttonClassActive || "active", lastIx === i); if (v.buttonClassActive) $btn.toggleClass("btn-default", lastIx !== i); }); return $btn; })}
`.appendTo($modalInner); const $btnOk = this._$getBtnOk({opts, doClose}); const $btnCancel = this._$getBtnCancel({opts, doClose}); const $btnSkip = this._$getBtnSkip({opts, doClose}); $$`
${$btnOk}${$btnCancel}${$btnSkip}
`.appendTo($modalInner); // region Output const [isDataEntered] = await pGetResolved(); if (typeof isDataEntered === "symbol") return isDataEntered; if (!isDataEntered) return null; return ~lastIx ? lastIx : null; // endregion } /** * @param [opts] Options. * @param [opts.title] Prompt title. * @param [opts.htmlDescription] Description HTML for the modal. * @param [opts.$eleDescription] Description element for the modal. * @param [opts.default] Default value. * @param [opts.autocomplete] Array of autocomplete strings. REQUIRES INCLUSION OF THE TYPEAHEAD LIBRARY. * @param [opts.isCode] If the text is code. * @param [opts.isSkippable] If the prompt is skippable. * @param [opts.fnIsValid] A function which checks if the current input is valid, and prevents the user from * submitting the value if it is. * @param [opts.$elePre] Element to add before the input. * @param [opts.$elePost] Element to add after the input. * @param [opts.cbPostRender] Callback to call after rendering the modal * @return {Promise} A promise which resolves to the string if the user entered one, or null otherwise. */ static async pGetUserString (opts) { opts = opts || {}; const propValue = "text"; const comp = BaseComponent.fromObject({ [propValue]: opts.default || "", isValid: true, }); const $iptStr = ComponentUiUtil.$getIptStr( comp, propValue, { html: ``, autocomplete: opts.autocomplete, }, ) .keydown(async evt => { if (evt.key === "Escape") return; // Already handled if (opts.autocomplete) { // prevent double-binding the return key if we have autocomplete enabled await MiscUtil.pDelay(17); // arbitrary delay to allow dropdown to render (~1000/60, i.e. 1 60 FPS frame) if ($modalInner.find(`.typeahead.dropdown-menu`).is(":visible")) return; } evt.stopPropagation(); if (evt.key === "Enter") { evt.preventDefault(); doClose(true); } }); if (opts.isCode) $iptStr.addClass("code"); if (opts.fnIsValid) { const hkText = () => comp._state.isValid = !comp._state.text.trim() || !!opts.fnIsValid(comp._state.text); comp._addHookBase(propValue, hkText); hkText(); const hkIsValid = () => $iptStr.toggleClass("form-control--error", !comp._state.isValid); comp._addHookBase("isValid", hkIsValid); hkIsValid(); } const {$modalInner, doClose, pGetResolved, doAutoResize: doAutoResizeModal} = await InputUiUtil._pGetShowModal({ title: opts.title || "Enter Text", isMinHeight0: true, isWidth100: true, }); const $btnOk = this._$getBtnOk({comp, opts, doClose}); const $btnCancel = this._$getBtnCancel({comp, opts, doClose}); const $btnSkip = this._$getBtnSkip({comp, opts, doClose}); if (opts.$elePre) opts.$elePre.appendTo($modalInner); if (opts.$eleDescription?.length) $$`
${opts.$eleDescription}
`.appendTo($modalInner); else if (opts.htmlDescription && opts.htmlDescription.trim()) $$`
${opts.htmlDescription}
`.appendTo($modalInner); $iptStr.appendTo($modalInner); if (opts.$elePost) opts.$elePost.appendTo($modalInner); $$`
${$btnOk}${$btnCancel}${$btnSkip}
`.appendTo($modalInner); if (doAutoResizeModal) doAutoResizeModal(); $iptStr.focus(); $iptStr.select(); if (opts.cbPostRender) { opts.cbPostRender({ comp, $iptStr, propValue, }); } // region Output const [isDataEntered] = await pGetResolved(); if (typeof isDataEntered === "symbol") return isDataEntered; if (!isDataEntered) return null; const raw = $iptStr.val(); return raw; // endregion } /** * @param [opts] Options. * @param [opts.title] Prompt title. * @param [opts.buttonText] Prompt title. * @param [opts.default] Default value. * @param [opts.disabled] If the text area is disabled. * @param [opts.isCode] If the text is code. * @param [opts.isSkippable] If the prompt is skippable. * @return {Promise} A promise which resolves to the string if the user entered one, or null otherwise. */ static async pGetUserText (opts) { opts = opts || {}; const $iptStr = $(``) .val(opts.default); if (opts.isCode) $iptStr.addClass("code"); const {$modalInner, doClose, pGetResolved, doAutoResize: doAutoResizeModal} = await InputUiUtil._pGetShowModal({ title: opts.title || "Enter Text", isMinHeight0: true, }); const $btnOk = this._$getBtnOk({opts, doClose}); const $btnCancel = this._$getBtnCancel({opts, doClose}); const $btnSkip = this._$getBtnSkip({opts, doClose}); $iptStr.appendTo($modalInner); $$`
${$btnOk}${$btnCancel}${$btnSkip}
`.appendTo($modalInner); if (doAutoResizeModal) doAutoResizeModal(); $iptStr.focus(); $iptStr.select(); // region Output const [isDataEntered] = await pGetResolved(); if (typeof isDataEntered === "symbol") return isDataEntered; if (!isDataEntered) return null; const raw = $iptStr.val(); if (!raw.trim()) return null; else return raw; // endregion } /** * @param opts Options. * @param opts.title Prompt title. * @param opts.default Default value. * @param [opts.isSkippable] If the prompt is skippable. * @return {Promise} A promise which resolves to the color if the user entered one, or null otherwise. */ static async pGetUserColor (opts) { opts = opts || {}; const $iptRgb = $(``); const {$modalInner, doClose, pGetResolved, doAutoResize: doAutoResizeModal} = await InputUiUtil._pGetShowModal({ title: opts.title || "Choose Color", isMinHeight0: true, }); const $btnOk = this._$getBtnOk({opts, doClose}); const $btnCancel = this._$getBtnCancel({opts, doClose}); const $btnSkip = this._$getBtnSkip({opts, doClose}); $iptRgb.appendTo($modalInner); $$`
${$btnOk}${$btnCancel}${$btnSkip}
`.appendTo($modalInner); if (doAutoResizeModal) doAutoResizeModal(); $iptRgb.focus(); $iptRgb.select(); // region Output const [isDataEntered] = await pGetResolved(); if (typeof isDataEntered === "symbol") return isDataEntered; if (!isDataEntered) return null; const raw = $iptRgb.val(); if (!raw.trim()) return null; else return raw; // endregion } /** * * @param [opts] Options object. * @param [opts.title] Modal title. * @param [opts.default] Default angle. * @param [opts.stepButtons] Array of labels for quick-set buttons, which will be evenly spread around the clock. * @param [opts.step] Number of steps in the gauge (default 360; would be e.g. 12 for a "clock"). * @param [opts.isSkippable] If the prompt is skippable. * @returns {Promise} A promise which resolves to the number of degrees if the user pressed "Enter," or null otherwise. */ static async pGetUserDirection (opts) { const X = 0; const Y = 1; const DEG_CIRCLE = 360; opts = opts || {}; const step = Math.max(2, Math.min(DEG_CIRCLE, opts.step || DEG_CIRCLE)); const stepDeg = DEG_CIRCLE / step; function getAngle (p1, p2) { return Math.atan2(p2[Y] - p1[Y], p2[X] - p1[X]) * 180 / Math.PI; } let active = false; let curAngle = Math.min(DEG_CIRCLE, opts.default) || 0; const $arm = $(`
`); const handleAngle = () => $arm.css({transform: `rotate(${curAngle + 180}deg)`}); handleAngle(); const $pad = $$`
${$arm}
`.on("mousedown touchstart", evt => { active = true; handleEvent(evt); }); const $document = $(document); const evtId = `ui_user_dir_${CryptUtil.uid()}`; $document.on(`mousemove.${evtId} touchmove${evtId}`, evt => { handleEvent(evt); }).on(`mouseup.${evtId} touchend${evtId} touchcancel${evtId}`, evt => { evt.preventDefault(); evt.stopPropagation(); active = false; }); const handleEvent = (evt) => { if (!active) return; const coords = [EventUtil.getClientX(evt), EventUtil.getClientY(evt)]; const {top, left} = $pad.offset(); const center = [left + ($pad.width() / 2), top + ($pad.height() / 2)]; curAngle = getAngle(center, coords) + 90; if (step !== DEG_CIRCLE) curAngle = Math.round(curAngle / stepDeg) * stepDeg; else curAngle = Math.round(curAngle); handleAngle(); }; const BTN_STEP_SIZE = 26; const BORDER_PAD = 16; const CONTROLS_RADIUS = (92 + BTN_STEP_SIZE + BORDER_PAD) / 2; const $padOuter = opts.stepButtons ? (() => { const steps = opts.stepButtons; const SEG_ANGLE = 360 / steps.length; const $btns = []; for (let i = 0; i < steps.length; ++i) { const theta = (SEG_ANGLE * i * (Math.PI / 180)) - (1.5708); // offset by -90 degrees const x = CONTROLS_RADIUS * Math.cos(theta); const y = CONTROLS_RADIUS * Math.sin(theta); $btns.push( $(``) .css({ top: y + CONTROLS_RADIUS - (BTN_STEP_SIZE / 2), left: x + CONTROLS_RADIUS - (BTN_STEP_SIZE / 2), width: BTN_STEP_SIZE, height: BTN_STEP_SIZE, zIndex: 1002, }) .click(() => { curAngle = SEG_ANGLE * i; handleAngle(); }), ); } const $wrpInner = $$`
${$btns}${$pad}
` .css({ width: CONTROLS_RADIUS * 2, height: CONTROLS_RADIUS * 2, }); return $$`
${$wrpInner}
` .css({ width: (CONTROLS_RADIUS * 2) + BTN_STEP_SIZE + BORDER_PAD, height: (CONTROLS_RADIUS * 2) + BTN_STEP_SIZE + BORDER_PAD, }); })() : null; const {$modalInner, doClose, pGetResolved, doAutoResize: doAutoResizeModal} = await InputUiUtil._pGetShowModal({ title: opts.title || "Select Direction", isMinHeight0: true, }); const $btnOk = this._$getBtnOk({opts, doClose}); const $btnCancel = this._$getBtnCancel({opts, doClose}); const $btnSkip = this._$getBtnSkip({opts, doClose}); $$`
${$padOuter || $pad}
`.appendTo($modalInner); $$`
${$btnOk}${$btnCancel}${$btnSkip}
`.appendTo($modalInner); if (doAutoResizeModal) doAutoResizeModal(); // region Output const [isDataEntered] = await pGetResolved(); if (typeof isDataEntered === "symbol") return isDataEntered; $document.off(`mousemove.${evtId} touchmove${evtId} mouseup.${evtId} touchend${evtId} touchcancel${evtId}`); if (!isDataEntered) return null; if (curAngle < 0) curAngle += 360; return curAngle; // TODO returning the step number is more useful if step is specified? // endregion } /** * @param [opts] Options. * @param [opts.title] Prompt title. * @param [opts.default] Default values. Should be an object of the form `{num, faces, bonus}`. * @param [opts.isSkippable] If the prompt is skippable. * @return {Promise} A promise which resolves to a dice string if the user entered values, or null otherwise. */ static async pGetUserDice (opts) { opts = opts || {}; const comp = BaseComponent.fromObject({ num: (opts.default && opts.default.num) || 1, faces: (opts.default && opts.default.faces) || 6, bonus: (opts.default && opts.default.bonus) || null, }); comp.render = function ($parent) { $parent.empty(); const $iptNum = ComponentUiUtil.$getIptInt(this, "num", 0, {$ele: $(``)}) .appendTo($parent) .keydown(evt => { if (evt.key === "Escape") { $iptNum.blur(); return; } // return key if (evt.which === 13) doClose(true); evt.stopPropagation(); }); const $selFaces = ComponentUiUtil.$getSelEnum(this, "faces", {values: Renderer.dice.DICE}) .addClass("mr-2").addClass("ve-text-center").css("textAlignLast", "center"); const $iptBonus = $(``) .change(() => this._state.bonus = UiUtil.strToInt($iptBonus.val(), null, {fallbackOnNaN: null})) .keydown(evt => { if (evt.key === "Escape") { $iptBonus.blur(); return; } // return key if (evt.which === 13) doClose(true); evt.stopPropagation(); }); const hook = () => $iptBonus.val(this._state.bonus != null ? UiUtil.intToBonus(this._state.bonus) : this._state.bonus); comp._addHookBase("bonus", hook); hook(); $$`
${$iptNum}
d
${$selFaces}${$iptBonus}
`.appendTo($parent); }; comp.getAsString = function () { return `${this._state.num}d${this._state.faces}${this._state.bonus ? UiUtil.intToBonus(this._state.bonus) : ""}`; }; const {$modalInner, doClose, pGetResolved, doAutoResize: doAutoResizeModal} = await InputUiUtil._pGetShowModal({ title: opts.title || "Enter Dice", isMinHeight0: true, }); const $btnOk = this._$getBtnOk({opts, doClose}); const $btnCancel = this._$getBtnCancel({opts, doClose}); const $btnSkip = this._$getBtnSkip({opts, doClose}); comp.render($modalInner); $$`
${$btnOk}${$btnCancel}${$btnSkip}
`.appendTo($modalInner); if (doAutoResizeModal) doAutoResizeModal(); // region Output const [isDataEntered] = await pGetResolved(); if (typeof isDataEntered === "symbol") return isDataEntered; if (!isDataEntered) return null; return comp.getAsString(); // endregion } /** * @param [opts] Options. * @param [opts.title] Prompt title. * @param [opts.buttonText] Prompt title. * @param [opts.default] Default value. * @param [opts.disabled] If the text area is disabled. * @param [opts.isSkippable] If the prompt is skippable. * @return {Promise} A promise which resolves to the CR string if the user entered one, or null otherwise. */ static async pGetUserScaleCr (opts = {}) { const crDefault = opts.default || "1"; let slider; const {$modalInner, doClose, pGetResolved, doAutoResize: doAutoResizeModal} = await InputUiUtil._pGetShowModal({ title: opts.title || "Select Challenge Rating", isMinHeight0: true, cbClose: () => { slider.destroy(); }, }); const cur = Parser.CRS.indexOf(crDefault); if (!~cur) throw new Error(`Initial CR ${crDefault} was not valid!`); const comp = BaseComponent.fromObject({ min: 0, max: Parser.CRS.length - 1, cur, }); slider = new ComponentUiUtil.RangeSlider({ comp, propMin: "min", propMax: "max", propCurMin: "cur", fnDisplay: ix => Parser.CRS[ix], }); $$`
${slider.$get()}
`.appendTo($modalInner); const $btnOk = this._$getBtnOk({opts, doClose}); const $btnCancel = this._$getBtnCancel({opts, doClose}); const $btnSkip = this._$getBtnSkip({opts, doClose}); $$`
${$btnOk}${$btnCancel}${$btnSkip}
`.appendTo($modalInner); if (doAutoResizeModal) doAutoResizeModal(); // region Output const [isDataEntered] = await pGetResolved(); if (typeof isDataEntered === "symbol") return isDataEntered; if (!isDataEntered) return null; return Parser.CRS[comp._state.cur]; // endregion } } class DragReorderUiUtil { /** * Create a draggable pad capable of re-ordering rendered components. This requires to components to have: * - an `id` getter * - a `pos` getter and setter * - a `height` getter * * @param opts Options object. * @param opts.$parent The parent element containing the rendered components. * @param opts.componentsParent The object which has the array of components as a property. * @param opts.componentsProp The property name of the components array. * @param opts.componentId This component ID. * @param [opts.marginSide] The margin side; "r" or "l" (defaults to "l"). */ static $getDragPad (opts) { opts = opts || {}; const getComponentById = (id) => opts.componentsParent[opts.componentsProp].find(it => it.id === id); const dragMeta = {}; const doDragCleanup = () => { dragMeta.on = false; dragMeta.$wrap.remove(); dragMeta.$dummies.forEach($d => $d.remove()); $(document.body).off(`mouseup.drag__stop`); }; const doDragRender = () => { if (dragMeta.on) doDragCleanup(); $(document.body).on(`mouseup.drag__stop`, () => { if (dragMeta.on) doDragCleanup(); }); dragMeta.on = true; dragMeta.$wrap = $(`
`).appendTo(opts.$parent); dragMeta.$dummies = []; const ids = opts.componentsParent[opts.componentsProp].map(it => it.id); ids.forEach(id => { const $dummy = $(`
`) .height(getComponentById(id).height) .mouseup(() => { if (dragMeta.on) doDragCleanup(); }) .appendTo(dragMeta.$wrap); dragMeta.$dummies.push($dummy); if (id !== opts.componentId) { // on entering other areas, swap positions $dummy.mouseenter(() => { const cachedPos = getComponentById(id).pos; getComponentById(id).pos = getComponentById(opts.componentId).pos; getComponentById(opts.componentId).pos = cachedPos; doDragRender(); }); } }); }; return $(`
`).mousedown(() => doDragRender()); } /** * @param $fnGetRow Function which returns a $row element. Is a function instead of a value so it can be lazy-loaded later. * @param opts Options object. * @param opts.$parent * @param opts.swapRowPositions * @param [opts.$children] An array of row elements. * @param [opts.$getChildren] Should return an array as described in the "$children" option. * @param [opts.fnOnDragComplete] A function to run when dragging is completed. */ static $getDragPadOpts ($fnGetRow, opts) { if (!opts.$parent || !opts.swapRowPositions || (!opts.$children && !opts.$getChildren)) throw new Error("Missing required option(s)!"); const dragMeta = {}; const doDragCleanup = () => { dragMeta.on = false; dragMeta.$wrap.remove(); dragMeta.$dummies.forEach($d => $d.remove()); $(document.body).off(`mouseup.drag__stop`); if (opts.fnOnDragComplete) opts.fnOnDragComplete(); }; const doDragRender = () => { if (dragMeta.on) doDragCleanup(); $(document.body).on(`mouseup.drag__stop`, () => { if (dragMeta.on) doDragCleanup(); }); dragMeta.on = true; dragMeta.$wrap = $(`
`).appendTo(opts.$parent); dragMeta.$dummies = []; const $children = opts.$getChildren ? opts.$getChildren() : opts.$children; const ixRow = $children.indexOf($fnGetRow()); $children.forEach(($child, i) => { const dimensions = {w: $child.outerWidth(true), h: $child.outerHeight(true)}; const $dummy = $(`
`) .width(dimensions.w).height(dimensions.h) .mouseup(() => { if (dragMeta.on) doDragCleanup(); }) .appendTo(dragMeta.$wrap); dragMeta.$dummies.push($dummy); if (i !== ixRow) { // on entering other areas, swap positions $dummy.mouseenter(() => { opts.swapRowPositions(i, ixRow); doDragRender(); }); } }); }; return $(`
`).mousedown(() => doDragRender()); } /** * @param $fnGetRow Function which returns a $row element. Is a function instead of a value so it can be lazy-loaded later. * @param $parent Parent elements to attach row elements to. Should have (e.g.) "relative" CSS positioning. * @param parent Parent component which has a pod decomposable as {swapRowPositions, <$children|$getChildren>}. * @return jQuery */ static $getDragPad2 ($fnGetRow, $parent, parent) { const {swapRowPositions, $children, $getChildren} = parent; const nxtOpts = {$parent, swapRowPositions, $children, $getChildren}; return this.$getDragPadOpts($fnGetRow, nxtOpts); } } class SourceUiUtil { static _getValidOptions (options) { if (!options) throw new Error(`No options were specified!`); if (!options.$parent || !options.cbConfirm || !options.cbConfirmExisting || !options.cbCancel) throw new Error(`Missing options!`); options.mode = options.mode || "add"; return options; } /** * @param options Options object. * @param options.$parent Parent element. * @param options.cbConfirm Confirmation callback for inputting new sources. * @param options.cbConfirmExisting Confirmation callback for selecting existing sources. * @param options.cbCancel Cancellation callback. * @param options.mode (Optional) Mode to build in, "select", "edit" or "add". Defaults to "select". * @param options.source (Optional) Homebrew source object. * @param options.isRequired (Optional) True if a source must be selected. */ static render (options) { options = SourceUiUtil._getValidOptions(options); options.$parent.empty(); options.mode = options.mode || "select"; const isEditMode = options.mode === "edit"; let jsonDirty = false; const $iptName = $(``) .keydown(evt => { if (evt.key === "Escape") $iptName.blur(); }) .change(() => { if (!jsonDirty && !isEditMode) $iptJson.val($iptName.val().replace(/[^-0-9a-zA-Z]/g, "")); $iptName.removeClass("form-control--error"); }); if (options.source) $iptName.val(options.source.full); const $iptAbv = $(``) .keydown(evt => { if (evt.key === "Escape") $iptAbv.blur(); }) .change(() => { $iptAbv.removeClass("form-control--error"); }); if (options.source) $iptAbv.val(options.source.abbreviation); const $iptJson = $(``) .keydown(evt => { if (evt.key === "Escape") $iptJson.blur(); }) .change(() => { jsonDirty = true; $iptJson.removeClass("form-control--error"); }); if (options.source) $iptJson.val(options.source.json); let hasColor = false; const $iptColor = $(``) .keydown(evt => { if (evt.key === "Escape") $iptColor.blur(); }) .change(() => hasColor = true); if (options.source?.color != null) { hasColor = true; $iptColor.val(options.source.color); } const $iptUrl = $(``) .keydown(evt => { if (evt.key === "Escape") $iptUrl.blur(); }); if (options.source) $iptUrl.val(options.source.url); const $iptAuthors = $(``) .keydown(evt => { if (evt.key === "Escape") $iptAuthors.blur(); }); if (options.source) $iptAuthors.val((options.source.authors || []).join(", ")); const $iptConverters = $(``) .keydown(evt => { if (evt.key === "Escape") $iptConverters.blur(); }); if (options.source) $iptConverters.val((options.source.convertedBy || []).join(", ")); const $btnOk = $(``) .click(async () => { let incomplete = false; [$iptName, $iptAbv, $iptJson].forEach($ipt => { const val = $ipt.val(); if (!val || !val.trim()) { incomplete = true; $ipt.addClass("form-control--error"); } }); if (incomplete) return; const jsonVal = $iptJson.val().trim(); if (!isEditMode && BrewUtil2.hasSourceJson(jsonVal)) { $iptJson.addClass("form-control--error"); JqueryUtil.doToast({content: `The JSON identifier "${jsonVal}" already exists!`, type: "danger"}); return; } const source = { json: jsonVal, abbreviation: $iptAbv.val().trim(), full: $iptName.val().trim(), url: $iptUrl.val().trim(), authors: $iptAuthors.val().trim().split(",").map(it => it.trim()).filter(Boolean), convertedBy: $iptConverters.val().trim().split(",").map(it => it.trim()).filter(Boolean), }; if (hasColor) source.color = $iptColor.val().trim(); await options.cbConfirm(source, options.mode !== "edit"); }); const $btnCancel = options.isRequired && !isEditMode ? null : $(``).click(() => options.cbCancel()); const $btnUseExisting = $(``) .click(() => { $stageInitial.hideVe(); $stageExisting.showVe(); // cleanup [$iptName, $iptAbv, $iptJson].forEach($ipt => $ipt.removeClass("form-control--error")); }); const $stageInitial = $$`

${isEditMode ? "Edit Homebrew Source" : "Add a Homebrew Source"}

Title ${$iptName}
Abbreviation ${$iptAbv}
JSON Identifier ${$iptJson}
Color ${$iptColor}
Source URL ${$iptUrl}
Author(s) ${$iptAuthors}
Converted By ${$iptConverters}
${$btnOk}${$btnCancel}
${!isEditMode && BrewUtil2.getMetaLookup("sources")?.length ? $$`
or
${$btnUseExisting}
` : ""}
`.appendTo(options.$parent); const $selExisting = $$``.change(() => $selExisting.removeClass("form-control--error")); $selExisting[0].selectedIndex = 0; const $btnConfirmExisting = $(``) .click(async () => { if ($selExisting[0].selectedIndex === 0) { $selExisting.addClass("form-control--error"); return; } const sourceJson = $selExisting.val(); const source = BrewUtil2.sourceJsonToSource(sourceJson); await options.cbConfirmExisting(source); // cleanup $selExisting[0].selectedIndex = 0; $stageExisting.hideVe(); $stageInitial.showVe(); }); const $btnBackExisting = $(``) .click(() => { $selExisting[0].selectedIndex = 0; $stageExisting.hideVe(); $stageInitial.showVe(); }); const $stageExisting = $$`

Select a Homebrew Source

${$selExisting}
${$btnBackExisting}${$btnConfirmExisting}
`.appendTo(options.$parent); } } /** * @mixin * @param {typeof ProxyBase} Cls */ function MixinBaseComponent (Cls) { class MixedBaseComponent extends Cls { constructor (...args) { super(...args); this.__locks = {}; this.__rendered = {}; // state this.__state = {...this._getDefaultState()}; this._state = this._getProxy("state", this.__state); } _addHookBase (prop, hook) { return this._addHook("state", prop, hook); } _removeHookBase (prop, hook) { return this._removeHook("state", prop, hook); } _removeHooksBase (prop) { return this._removeHooks("state", prop); } _addHookAllBase (hook) { return this._addHookAll("state", hook); } _removeHookAllBase (hook) { return this._removeHookAll("state", hook); } _setState (toState) { this._proxyAssign("state", "_state", "__state", toState, true); } _setStateValue (prop, value, {isForceTriggerHooks = true} = {}) { if (this._state[prop] === value && !isForceTriggerHooks) return value; // If the value is new, hooks will be run automatically if (this._state[prop] !== value) return this._state[prop] = value; this._doFireHooksAll("state", prop, value, value); this._doFireHooks("state", prop, value, value); return value; } _getState () { return MiscUtil.copyFast(this.__state); } getPod () { this.__pod = this.__pod || { get: (prop) => this._state[prop], set: (prop, val) => this._state[prop] = val, delete: (prop) => delete this._state[prop], addHook: (prop, hook) => this._addHookBase(prop, hook), addHookAll: (hook) => this._addHookAllBase(hook), removeHook: (prop, hook) => this._removeHookBase(prop, hook), removeHookAll: (hook) => this._removeHookAllBase(hook), triggerCollectionUpdate: (prop) => this._triggerCollectionUpdate(prop), setState: (state) => this._setState(state), getState: () => this._getState(), assign: (toObj, isOverwrite) => this._proxyAssign("state", "_state", "__state", toObj, isOverwrite), pLock: lockName => this._pLock(lockName), unlock: lockName => this._unlock(lockName), component: this, }; return this.__pod; } // to be overridden as required _getDefaultState () { return {}; } getBaseSaveableState () { return { state: MiscUtil.copyFast(this.__state), }; } setBaseSaveableStateFrom (toLoad, isOverwrite = false) { toLoad?.state && this._proxyAssignSimple("state", toLoad.state, isOverwrite); } /** * @param opts Options object. * @param opts.prop The state property. * @param [opts.namespace] The render namespace. */ _getRenderedCollection (opts) { opts = opts || {}; const renderedLookupProp = opts.namespace ? `${opts.namespace}.${opts.prop}` : opts.prop; return (this.__rendered[renderedLookupProp] = this.__rendered[renderedLookupProp] || {}); } /** * Asynchronous version available below. * @param opts Options object. * @param opts.prop The state property. * @param [opts.fnDeleteExisting] Function to run on deleted render meta. Arguments are `rendered, item`. * @param [opts.fnReorderExisting] Function to run on all meta, as a final step. Useful for re-ordering elements. * @param opts.fnUpdateExisting Function to run on existing render meta. Arguments are `rendered, item`. * @param opts.fnGetNew Function to run which generates existing render meta. Arguments are `item`. * @param [opts.isDiffMode] If a diff of the state should be taken/checked before updating renders. * @param [opts.namespace] A namespace to store these renders under. Useful if multiple renders are being made from * the same collection. */ _renderCollection (opts) { opts = opts || {}; const rendered = this._getRenderedCollection(opts); const entities = this._state[opts.prop] || []; return this._renderCollection_doRender(rendered, entities, opts); } _renderCollection_doRender (rendered, entities, opts) { opts = opts || {}; const toDelete = new Set(Object.keys(rendered)); for (let i = 0; i < entities.length; ++i) { const it = entities[i]; if (it.id == null) throw new Error(`Collection item did not have an ID!`); // N.B.: Meta can be an array, if one item maps to multiple renders (e.g. the same is shown in two places) const meta = rendered[it.id]; toDelete.delete(it.id); if (meta) { if (opts.isDiffMode) { const nxtHash = this._getCollectionEntityHash(it); if (nxtHash !== meta.__hash) meta.__hash = nxtHash; else continue; } meta.data = it; // update any existing pointers opts.fnUpdateExisting(meta, it, i); } else { const meta = opts.fnGetNew(it, i); // If the "get new" function returns null, skip rendering this entity if (meta == null) continue; meta.data = it; // update any existing pointers if (!meta.$wrpRow && !meta.fnRemoveEles) throw new Error(`A "$wrpRow" or a "fnRemoveEles" property is required for deletes!`); if (opts.isDiffMode) meta.__hash = this._getCollectionEntityHash(it); rendered[it.id] = meta; } } const doRemoveElements = meta => { if (meta.$wrpRow) meta.$wrpRow.remove(); if (meta.fnRemoveEles) meta.fnRemoveEles(); }; toDelete.forEach(id => { const meta = rendered[id]; doRemoveElements(meta); delete rendered[id]; if (opts.fnDeleteExisting) opts.fnDeleteExisting(meta); }); if (opts.fnReorderExisting) { entities.forEach((it, i) => { const meta = rendered[it.id]; opts.fnReorderExisting(meta, it, i); }); } } /** * Synchronous version available above. * @param [opts] Options object. * @param opts.prop The state property. * @param [opts.pFnDeleteExisting] Function to run on deleted render meta. Arguments are `rendered, item`. * @param opts.pFnUpdateExisting Function to run on existing render meta. Arguments are `rendered, item`. * @param opts.pFnGetNew Function to run which generates existing render meta. Arguments are `item`. * @param [opts.isDiffMode] If updates should be run in "diff" mode (i.e. no update is run if nothing has changed). * @param [opts.isMultiRender] If multiple renders will be produced. * @param [opts.additionalCaches] Additional cache objects to be cleared on entity delete. Should be objects with * entity IDs as keys. * @param [opts.namespace] A namespace to store these renders under. Useful if multiple renders are being made from * the same collection. */ async _pRenderCollection (opts) { opts = opts || {}; const rendered = this._getRenderedCollection(opts); const entities = this._state[opts.prop] || []; return this._pRenderCollection_doRender(rendered, entities, opts); } async _pRenderCollection_doRender (rendered, entities, opts) { opts = opts || {}; const toDelete = new Set(Object.keys(rendered)); // Run the external functions in serial, to prevent element re-ordering for (let i = 0; i < entities.length; ++i) { const it = entities[i]; if (!it.id) throw new Error(`Collection item did not have an ID!`); // N.B.: Meta can be an array, if one item maps to multiple renders (e.g. the same is shown in two places) const meta = rendered[it.id]; toDelete.delete(it.id); if (meta) { if (opts.isDiffMode) { const nxtHash = this._getCollectionEntityHash(it); if (nxtHash !== meta.__hash) meta.__hash = nxtHash; else continue; } const nxtMeta = await opts.pFnUpdateExisting(meta, it); // Overwrite the existing renders in multi-render mode // Otherwise, just ignore the result--single renders never modify their render if (opts.isMultiRender) rendered[it.id] = nxtMeta; } else { const meta = await opts.pFnGetNew(it); // If the "get new" function returns null, skip rendering this entity if (meta == null) continue; if (!opts.isMultiRender && !meta.$wrpRow && !meta.fnRemoveEles) throw new Error(`A "$wrpRow" or a "fnRemoveEles" property is required for deletes!`); if (opts.isMultiRender && meta.some(it => !it.$wrpRow && !it.fnRemoveEles)) throw new Error(`A "$wrpRow" or a "fnRemoveEles" property is required for deletes!`); if (opts.isDiffMode) meta.__hash = this._getCollectionEntityHash(it); rendered[it.id] = meta; } } const doRemoveElements = meta => { if (meta.$wrpRow) meta.$wrpRow.remove(); if (meta.fnRemoveEles) meta.fnRemoveEles(); }; for (const id of toDelete) { const meta = rendered[id]; if (opts.isMultiRender) meta.forEach(it => doRemoveElements(it)); else doRemoveElements(meta); if (opts.additionalCaches) opts.additionalCaches.forEach(it => delete it[id]); delete rendered[id]; if (opts.pFnDeleteExisting) await opts.pFnDeleteExisting(meta); } if (opts.pFnReorderExisting) { await entities.pSerialAwaitMap(async (it, i) => { const meta = rendered[it.id]; await opts.pFnReorderExisting(meta, it, i); }); } } /** * Detach (and thus preserve) rendered collection elements so they can be re-used later. * @param prop The state property. * @param [namespace] A namespace to store these renders under. Useful if multiple renders are being made from * the same collection. */ _detachCollection (prop, namespace = null) { const renderedLookupProp = namespace ? `${namespace}.${prop}` : prop; const rendered = (this.__rendered[renderedLookupProp] = this.__rendered[renderedLookupProp] || {}); Object.values(rendered).forEach(it => it.$wrpRow.detach()); } /** * Wipe any rendered collection elements, and reset the render cache. * @param prop The state property. * @param [namespace] A namespace to store these renders under. Useful if multiple renders are being made from * the same collection. */ _resetCollectionRenders (prop, namespace = null) { const renderedLookupProp = namespace ? `${namespace}.${prop}` : prop; const rendered = (this.__rendered[renderedLookupProp] = this.__rendered[renderedLookupProp] || {}); Object.values(rendered).forEach(it => it.$wrpRow.remove()); delete this.__rendered[renderedLookupProp]; } _getCollectionEntityHash (ent) { // Hashing the stringified JSON relies on the property order remaining consistent, but this is fine return CryptUtil.md5(JSON.stringify(ent)); } render () { throw new Error("Unimplemented!"); } // to be overridden as required getSaveableState () { return {...this.getBaseSaveableState()}; } setStateFrom (toLoad, isOverwrite = false) { this.setBaseSaveableStateFrom(toLoad, isOverwrite); } async _pLock (lockName) { while (this.__locks[lockName]) await this.__locks[lockName].lock; let unlock = null; const lock = new Promise(resolve => unlock = resolve); this.__locks[lockName] = { lock, unlock, }; } async _pGate (lockName) { while (this.__locks[lockName]) await this.__locks[lockName].lock; } _unlock (lockName) { const lockMeta = this.__locks[lockName]; if (lockMeta) { delete this.__locks[lockName]; lockMeta.unlock(); } } async _pDoProxySetBase (prop, value) { return this._pDoProxySet("state", this.__state, prop, value); } _triggerCollectionUpdate (prop) { if (!this._state[prop]) return; this._state[prop] = [...this._state[prop]]; } static _toCollection (array) { if (array) return array.map(it => ({id: CryptUtil.uid(), entity: it})); } static _fromCollection (array) { if (array) return array.map(it => it.entity); } static fromObject (obj, ...noModCollections) { const comp = new this(); Object.entries(MiscUtil.copyFast(obj)).forEach(([k, v]) => { if (v == null) comp.__state[k] = v; else if (noModCollections.includes(k) || noModCollections.includes("*")) comp.__state[k] = v; else if (typeof v === "object" && v instanceof Array) comp.__state[k] = BaseComponent._toCollection(v); else comp.__state[k] = v; }); return comp; } static fromObjectNoMod (obj) { return this.fromObject(obj, "*"); } toObject (...noModCollections) { const cpy = MiscUtil.copyFast(this.__state); Object.entries(cpy).forEach(([k, v]) => { if (v == null) return; if (noModCollections.includes(k) || noModCollections.includes("*")) cpy[k] = v; else if (v instanceof Array && v.every(it => it && it.id)) cpy[k] = BaseComponent._fromCollection(v); }); return cpy; } toObjectNoMod () { return this.toObject("*"); } } return MixedBaseComponent; } class BaseComponent extends MixinBaseComponent(ProxyBase) {} globalThis.BaseComponent = BaseComponent; /** @abstract */ class RenderableCollectionBase { /** * @param comp * @param prop * @param [opts] * @param [opts.namespace] * @param [opts.isDiffMode] */ constructor (comp, prop, opts) { opts = opts || {}; this._comp = comp; this._prop = prop; this._namespace = opts.namespace; this._isDiffMode = opts.isDiffMode; } /** @abstract */ getNewRender (entity, i) { throw new Error(`Unimplemented!`); } /** @abstract */ doUpdateExistingRender (renderedMeta, entity, i) { throw new Error(`Unimplemented!`); } doDeleteExistingRender (renderedMeta) { // No-op } doReorderExistingComponent (renderedMeta, entity, i) { // No-op } _getCollectionItem (id) { return this._comp._state[this._prop].find(it => it.id === id); } /** * @param [opts] Temporary override options. * @param [opts.isDiffMode] */ render (opts) { opts = opts || {}; this._comp._renderCollection({ prop: this._prop, fnUpdateExisting: (rendered, ent, i) => this.doUpdateExistingRender(rendered, ent, i), fnGetNew: (entity, i) => this.getNewRender(entity, i), fnDeleteExisting: (rendered) => this.doDeleteExistingRender(rendered), fnReorderExisting: (rendered, ent, i) => this.doReorderExistingComponent(rendered, ent, i), namespace: this._namespace, isDiffMode: opts.isDiffMode != null ? opts.isDiffMode : this._isDiffMode, }); } } globalThis.RenderableCollectionBase = RenderableCollectionBase; class _RenderableCollectionGenericRowsSyncAsyncUtils { constructor ({comp, prop, $wrpRows, namespace}) { this._comp = comp; this._prop = prop; this._$wrpRows = $wrpRows; this._namespace = namespace; } /* -------------------------------------------- */ _getCollectionItem (id) { return this._comp._state[this._prop].find(it => it.id === id); } getNewRenderComp (entity, i) { const comp = BaseComponent.fromObject(entity.entity, "*"); comp._addHookAll("state", () => { this._getCollectionItem(entity.id).entity = comp.toObject("*"); this._comp._triggerCollectionUpdate(this._prop); }); return comp; } doUpdateExistingRender (renderedMeta, entity, i) { renderedMeta.comp._proxyAssignSimple("state", entity.entity, true); if (!renderedMeta.$wrpRow.parent().is(this._$wrpRows)) renderedMeta.$wrpRow.appendTo(this._$wrpRows); } static _doSwapJqueryElements ($eles, ixA, ixB) { if (ixA > ixB) [ixA, ixB] = [ixB, ixA]; const eleA = $eles.get(ixA); const eleB = $eles.get(ixB); const eleActive = document.activeElement; $(eleA).insertAfter(eleB); $(eleB).insertBefore($eles.get(ixA + 1)); if (eleActive) eleActive.focus(); } doReorderExistingComponent (renderedMeta, entity, i) { const ix = this._comp._state[this._prop].map(it => it.id).indexOf(entity.id); const $rows = this._$wrpRows.find(`> *`); const curIx = $rows.index(renderedMeta.$wrpRow); const isMove = !this._$wrpRows.length || curIx !== ix; if (!isMove) return; this.constructor._doSwapJqueryElements($rows, curIx, ix); } /* -------------------------------------------- */ $getBtnDelete ({entity, title = "Delete"}) { return $(``) .click(() => this.doDelete({entity})); } doDelete ({entity}) { this._comp._state[this._prop] = this._comp._state[this._prop].filter(it => it?.id !== entity.id); } doDeleteMultiple ({entities}) { const ids = new Set(entities.map(it => it.id)); this._comp._state[this._prop] = this._comp._state[this._prop].filter(it => !ids.has(it?.id)); } /* -------------------------------------------- */ $getPadDrag ({$wrpRow}) { return DragReorderUiUtil.$getDragPadOpts( () => $wrpRow, { swapRowPositions: (ixA, ixB) => { [this._comp._state[this._prop][ixA], this._comp._state[this._prop][ixB]] = [this._comp._state[this._prop][ixB], this._comp._state[this._prop][ixA]]; this._comp._triggerCollectionUpdate(this._prop); }, $getChildren: () => { const rendered = this._comp._getRenderedCollection({prop: this._prop, namespace: this._namespace}); return this._comp._state[this._prop] .map(it => rendered[it.id].$wrpRow); }, $parent: this._$wrpRows, }, ); } } class RenderableCollectionGenericRows extends RenderableCollectionBase { /** * @param comp * @param prop * @param $wrpRows * @param [opts] * @param [opts.namespace] * @param [opts.isDiffMode] */ constructor (comp, prop, $wrpRows, opts) { super(comp, prop, opts); this._$wrpRows = $wrpRows; this._utils = new _RenderableCollectionGenericRowsSyncAsyncUtils({ comp, prop, $wrpRows, namespace: opts?.namespace, }); } doUpdateExistingRender (renderedMeta, entity, i) { return this._utils.doUpdateExistingRender(renderedMeta, entity, i); } doReorderExistingComponent (renderedMeta, entity, i) { return this._utils.doReorderExistingComponent(renderedMeta, entity, i); } getNewRender (entity, i) { const comp = this._utils.getNewRenderComp(entity, i); const $wrpRow = this._$getWrpRow() .appendTo(this._$wrpRows); const renderAdditional = this._populateRow({comp, $wrpRow, entity}); return { ...(renderAdditional || {}), id: entity.id, comp, $wrpRow, }; } _$getWrpRow () { return $(`
`); } /** * @return {?object} */ _populateRow ({comp, $wrpRow, entity}) { throw new Error(`Unimplemented!`); } } globalThis.RenderableCollectionGenericRows = RenderableCollectionGenericRows; /** @abstract */ class RenderableCollectionAsyncBase { /** * @param comp * @param prop * @param [opts] * @param [opts.namespace] * @param [opts.isDiffMode] * @param [opts.isMultiRender] * @param [opts.additionalCaches] */ constructor (comp, prop, opts) { opts = opts || {}; this._comp = comp; this._prop = prop; this._namespace = opts.namespace; this._isDiffMode = opts.isDiffMode; this._isMultiRender = opts.isMultiRender; this._additionalCaches = opts.additionalCaches; } /** @abstract */ async pGetNewRender (entity, i) { throw new Error(`Unimplemented!`); } /** @abstract */ async pDoUpdateExistingRender (renderedMeta, entity, i) { throw new Error(`Unimplemented!`); } async pDoDeleteExistingRender (renderedMeta) { // No-op } async pDoReorderExistingComponent (renderedMeta, entity, i) { // No-op } /** * @param [opts] Temporary override options. * @param [opts.isDiffMode] */ async pRender (opts) { opts = opts || {}; return this._comp._pRenderCollection({ prop: this._prop, pFnUpdateExisting: (rendered, source, i) => this.pDoUpdateExistingRender(rendered, source, i), pFnGetNew: (entity, i) => this.pGetNewRender(entity, i), pFnDeleteExisting: (rendered) => this.pDoDeleteExistingRender(rendered), pFnReorderExisting: (rendered, ent, i) => this.pDoReorderExistingComponent(rendered, ent, i), namespace: this._namespace, isDiffMode: opts.isDiffMode != null ? opts.isDiffMode : this._isDiffMode, isMultiRender: this._isMultiRender, additionalCaches: this._additionalCaches, }); } } globalThis.RenderableCollectionAsyncBase = RenderableCollectionAsyncBase; class RenderableCollectionAsyncGenericRows extends RenderableCollectionAsyncBase { /** * @param comp * @param prop * @param $wrpRows * @param [opts] * @param [opts.namespace] * @param [opts.isDiffMode] */ constructor (comp, prop, $wrpRows, opts) { super(comp, prop, opts); this._$wrpRows = $wrpRows; this._utils = new _RenderableCollectionGenericRowsSyncAsyncUtils({ comp, prop, $wrpRows, namespace: opts?.namespace, }); } pDoUpdateExistingRender (renderedMeta, entity, i) { return this._utils.doUpdateExistingRender(renderedMeta, entity, i); } pDoReorderExistingComponent (renderedMeta, entity, i) { return this._utils.doReorderExistingComponent(renderedMeta, entity, i); } async pGetNewRender (entity, i) { const comp = this._utils.getNewRenderComp(entity, i); const $wrpRow = this._$getWrpRow() .appendTo(this._$wrpRows); const renderAdditional = await this._pPopulateRow({comp, $wrpRow, entity}); return { ...(renderAdditional || {}), id: entity.id, comp, $wrpRow, }; } _$getWrpRow () { return $(`
`); } /** * @return {?object} */ async _pPopulateRow ({comp, $wrpRow, entity}) { throw new Error(`Unimplemented!`); } } class BaseLayeredComponent extends BaseComponent { constructor () { super(); // layers this._layers = []; this.__layerMeta = {}; this._layerMeta = this._getProxy("layerMeta", this.__layerMeta); } _addHookDeep (prop, hook) { this._addHookBase(prop, hook); this._addHook("layerMeta", prop, hook); } _removeHookDeep (prop, hook) { this._removeHookBase(prop, hook); this._removeHook("layerMeta", prop, hook); } _getBase (prop) { return this._state[prop]; } _get (prop) { if (this._layerMeta[prop]) { for (let i = this._layers.length - 1; i >= 0; --i) { const val = this._layers[i].data[prop]; if (val != null) return val; } // this should never fall through, but if it does, returning the base value is fine } return this._state[prop]; } _addLayer (layer) { this._layers.push(layer); this._addLayer_addLayerMeta(layer); } _addLayer_addLayerMeta (layer) { Object.entries(layer.data).forEach(([k, v]) => this._layerMeta[k] = v != null); } _removeLayer (layer) { const ix = this._layers.indexOf(layer); if (~ix) { this._layers.splice(ix, 1); // regenerate layer meta Object.keys(this._layerMeta).forEach(k => delete this._layerMeta[k]); this._layers.forEach(l => this._addLayer_addLayerMeta(l)); } } updateLayersActive (prop) { // this uses the fact that updating a proxy value to the same value still triggers hooks // anything listening to changes in this flag will be forced to recalculate from base + all layers this._layerMeta[prop] = this._layers.some(l => l.data[prop] != null); } getBaseSaveableState () { return { state: MiscUtil.copyFast(this.__state), layers: MiscUtil.copyFast(this._layers.map(l => l.getSaveableState())), }; } setBaseSaveableStateFrom (toLoad) { toLoad.state && Object.assign(this._state, toLoad.state); if (toLoad.layers) toLoad.layers.forEach(l => this._addLayer(CompLayer.fromSavedState(this, l))); } getPod () { this.__pod = this.__pod || { ...super.getPod(), addHookDeep: (prop, hook) => this._addHookDeep(prop, hook), removeHookDeep: (prop, hook) => this._removeHookDeep(prop, hook), addHookAll: (hook) => this._addHookAll("state", hook), getBase: (prop) => this._getBase(prop), get: (prop) => this._get(prop), addLayer: (name, data) => { // FIXME const l = new CompLayer(this, name, data); this._addLayer(l); return l; }, removeLayer: (layer) => this._removeLayer(layer), layers: this._layers, // FIXME avoid passing this directly to the child }; return this.__pod; } } /** * A "layer" of state which is applied over the base state. * This allows e.g. a temporary stat reduction to modify a statblock, without actually * modifying the underlying component. */ class CompLayer extends ProxyBase { constructor (component, layerName, data) { super(); this._name = layerName; this.__data = data; this.data = this._getProxy("data", this.__data); this._addHookAll("data", prop => component.updateLayersActive(prop)); } getSaveableState () { return { name: this._name, data: MiscUtil.copyFast(this.__data), }; } static fromSavedState (component, savedState) { return new CompLayer(component, savedState.name, savedState.data); } } function MixinComponentHistory (Cls) { class MixedComponentHistory extends Cls { constructor () { super(...arguments); this._histStackUndo = []; this._histStackRedo = []; this._isHistDisabled = true; this._histPropBlocklist = new Set(); this._histPropAllowlist = null; this._histInitialState = null; } set isHistDisabled (val) { this._isHistDisabled = val; } addBlocklistProps (...props) { props.forEach(p => this._histPropBlocklist.add(p)); } addAllowlistProps (...props) { this._histPropAllowlist = this._histPropAllowlist || new Set(); props.forEach(p => this._histPropAllowlist.add(p)); } /** * This should be initialised after all other hooks have been added */ initHistory () { // Track the initial state, and watch for further modifications this._histInitialState = MiscUtil.copyFast(this._state); this._isHistDisabled = false; this._addHookAll("state", prop => { if (this._isHistDisabled) return; if (this._histPropBlocklist.has(prop)) return; if (this._histPropAllowlist && !this._histPropAllowlist.has(prop)) return; this.recordHistory(); }); } recordHistory () { const stateCopy = MiscUtil.copyFast(this._state); // remove any un-tracked properties this._histPropBlocklist.forEach(prop => delete stateCopy[prop]); if (this._histPropAllowlist) Object.keys(stateCopy).filter(k => !this._histPropAllowlist.has(k)).forEach(k => delete stateCopy[k]); this._histStackUndo.push(stateCopy); this._histStackRedo = []; } _histAddExcludedProperties (stateCopy) { Object.entries(this._state).forEach(([k, v]) => { if (this._histPropBlocklist.has(k)) return stateCopy[k] = v; if (this._histPropAllowlist && !this._histPropAllowlist.has(k)) stateCopy[k] = v; }); } undo () { if (this._histStackUndo.length) { const lastHistDisabled = this._isHistDisabled; this._isHistDisabled = true; const curState = this._histStackUndo.pop(); this._histStackRedo.push(curState); const toApply = MiscUtil.copyFast(this._histStackUndo.last() || this._histInitialState); this._histAddExcludedProperties(toApply); this._setState(toApply); this._isHistDisabled = lastHistDisabled; } else { const lastHistDisabled = this._isHistDisabled; this._isHistDisabled = true; const toApply = MiscUtil.copyFast(this._histInitialState); this._histAddExcludedProperties(toApply); this._setState(toApply); this._isHistDisabled = lastHistDisabled; } } redo () { if (!this._histStackRedo.length) return; const lastHistDisabled = this._isHistDisabled; this._isHistDisabled = true; const toApplyRaw = this._histStackRedo.pop(); this._histStackUndo.push(toApplyRaw); const toApply = MiscUtil.copyFast(toApplyRaw); this._histAddExcludedProperties(toApply); this._setState(toApply); this._isHistDisabled = lastHistDisabled; } } return MixedComponentHistory; } // region Globally-linked state components function MixinComponentGlobalState (Cls) { class MixedComponentGlobalState extends Cls { constructor (...args) { super(...args); // Point our proxy at the singleton `__stateGlobal` object this._stateGlobal = this._getProxy("stateGlobal", MixinComponentGlobalState._Singleton.__stateGlobal); // Load the singleton's state, then fire all our hooks once it's ready MixinComponentGlobalState._Singleton._pLoadState() .then(() => { this._doFireHooksAll("stateGlobal"); this._doFireAllHooks("stateGlobal"); this._addHookAll("stateGlobal", MixinComponentGlobalState._Singleton._pSaveStateDebounced); }); } get __stateGlobal () { return MixinComponentGlobalState._Singleton.__stateGlobal; } _addHookGlobal (prop, hook) { return this._addHook("stateGlobal", prop, hook); } } return MixedComponentGlobalState; } MixinComponentGlobalState._Singleton = class { static async _pSaveState () { return StorageUtil.pSet(VeCt.STORAGE_GLOBAL_COMPONENT_STATE, MiscUtil.copyFast(MixinComponentGlobalState._Singleton.__stateGlobal)); } static async _pLoadState () { if (MixinComponentGlobalState._Singleton._pLoadingState) return MixinComponentGlobalState._Singleton._pLoadingState; return MixinComponentGlobalState._Singleton._pLoadingState = MixinComponentGlobalState._Singleton._pLoadState_(); } static async _pLoadState_ () { Object.assign(MixinComponentGlobalState._Singleton.__stateGlobal, (await StorageUtil.pGet(VeCt.STORAGE_GLOBAL_COMPONENT_STATE)) || {}); } static _getDefaultStateGlobal () { return { isUseSpellPoints: false, }; } }; MixinComponentGlobalState._Singleton.__stateGlobal = {...MixinComponentGlobalState._Singleton._getDefaultStateGlobal()}; MixinComponentGlobalState._Singleton._pSaveStateDebounced = MiscUtil.debounce(MixinComponentGlobalState._Singleton._pSaveState.bind(MixinComponentGlobalState._Singleton), 100); MixinComponentGlobalState._Singleton._pLoadingState = null; // endregion class ComponentUiUtil { static trackHook (hooks, prop, hook) { hooks[prop] = hooks[prop] || []; hooks[prop].push(hook); } static $getDisp (comp, prop, {html, $ele, fnGetText} = {}) { $ele = ($ele || $(html || `
`)); const hk = () => $ele.text(fnGetText ? fnGetText(comp._state[prop]) : comp._state[prop]); comp._addHookBase(prop, hk); hk(); return $ele; } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param [fallbackEmpty] Fallback number if string is empty. * @param [opts] Options Object. * @param [opts.$ele] Element to use. * @param [opts.html] HTML to convert to element to use. * @param [opts.max] Max allowed return value. * @param [opts.min] Min allowed return value. * @param [opts.offset] Offset to add to value displayed. * @param [opts.padLength] Number of digits to pad the number to. * @param [opts.fallbackOnNaN] Return value if not a number. * @param [opts.isAllowNull] If an empty input should be treated as null. * @param [opts.asMeta] If a meta-object should be returned containing the hook and the checkbox. * @param [opts.hookTracker] Object in which to track hook. * @param [opts.decorationLeft] Decoration to be added to the left-hand-side of the input. Can be `"ticker"` or `"clear"`. REQUIRES `asMeta` TO BE SET. * @param [opts.decorationRight] Decoration to be added to the right-hand-side of the input. Can be `"ticker"` or `"clear"`. REQUIRES `asMeta` TO BE SET. * @return {jQuery} */ static $getIptInt (component, prop, fallbackEmpty = 0, opts) { return ComponentUiUtil._$getIptNumeric(component, prop, UiUtil.strToInt, fallbackEmpty, opts); } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param [fallbackEmpty] Fallback number if string is empty. * @param [opts] Options Object. * @param [opts.$ele] Element to use. * @param [opts.html] HTML to convert to element to use. * @param [opts.max] Max allowed return value. * @param [opts.min] Min allowed return value. * @param [opts.offset] Offset to add to value displayed. * @param [opts.padLength] Number of digits to pad the number to. * @param [opts.fallbackOnNaN] Return value if not a number. * @param [opts.isAllowNull] If an empty input should be treated as null. * @param [opts.asMeta] If a meta-object should be returned containing the hook and the checkbox. * @param [opts.decorationLeft] Decoration to be added to the left-hand-side of the input. Can be `"ticker"` or `"clear"`. REQUIRES `asMeta` TO BE SET. * @param [opts.decorationRight] Decoration to be added to the right-hand-side of the input. Can be `"ticker"` or `"clear"`. REQUIRES `asMeta` TO BE SET. * @return {jQuery} */ static $getIptNumber (component, prop, fallbackEmpty = 0, opts) { return ComponentUiUtil._$getIptNumeric(component, prop, UiUtil.strToNumber, fallbackEmpty, opts); } static _$getIptNumeric (component, prop, fnConvert, fallbackEmpty = 0, opts) { opts = opts || {}; opts.offset = opts.offset || 0; const setIptVal = () => { if (opts.isAllowNull && component._state[prop] == null) { return $ipt.val(null); } const num = (component._state[prop] || 0) + opts.offset; const val = opts.padLength ? `${num}`.padStart(opts.padLength, "0") : num; $ipt.val(val); }; const $ipt = (opts.$ele || $(opts.html || ``)).disableSpellcheck() .keydown(evt => { if (evt.key === "Escape") $ipt.blur(); }) .change(() => { const raw = $ipt.val().trim(); const cur = component._state[prop]; if (opts.isAllowNull && !raw) return component._state[prop] = null; if (raw.startsWith("=")) { // if it starts with "=", force-set to the value provided component._state[prop] = fnConvert(raw.slice(1), fallbackEmpty, opts) - opts.offset; } else { // otherwise, try to modify the previous value const mUnary = prevValue != null && prevValue < 0 ? /^[+/*^]/.exec(raw) // If the previous value was `-X`, then treat minuses as normal values : /^[-+/*^]/.exec(raw); if (mUnary) { let proc = raw; proc = proc.slice(1).trim(); const mod = fnConvert(proc, fallbackEmpty, opts); const full = `${cur}${mUnary[0]}${mod}`; component._state[prop] = fnConvert(full, fallbackEmpty, opts) - opts.offset; } else { component._state[prop] = fnConvert(raw, fallbackEmpty, opts) - opts.offset; } } // Ensure the input visually reflects the state if (cur === component._state[prop]) setIptVal(); }); let prevValue; const hook = () => { prevValue = component._state[prop]; setIptVal(); }; if (opts.hookTracker) ComponentUiUtil.trackHook(opts.hookTracker, prop, hook); component._addHookBase(prop, hook); hook(); if (opts.asMeta) return this._getIptDecoratedMeta(component, prop, $ipt, hook, opts); else return $ipt; } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param [opts] Options Object. * @param [opts.$ele] Element to use. * @param [opts.html] HTML to convert to element to use. * @param [opts.isNoTrim] If the text should not be trimmed. * @param [opts.isAllowNull] If null should be allowed (and preferred) for empty inputs * @param [opts.asMeta] If a meta-object should be returned containing the hook and the checkbox. * @param [opts.autocomplete] Array of autocomplete strings. REQUIRES INCLUSION OF THE TYPEAHEAD LIBRARY. * @param [opts.decorationLeft] Decoration to be added to the left-hand-side of the input. Can be `"search"` or `"clear"`. REQUIRES `asMeta` TO BE SET. * @param [opts.decorationRight] Decoration to be added to the right-hand-side of the input. Can be `"search"` or `"clear"`. REQUIRES `asMeta` TO BE SET. * @param [opts.placeholder] Placeholder for the input. */ static $getIptStr (component, prop, opts) { opts = opts || {}; // Validate options if ((opts.decorationLeft || opts.decorationRight) && !opts.asMeta) throw new Error(`Input must be created with "asMeta" option`); const $ipt = (opts.$ele || $(opts.html || ``)) .keydown(evt => { if (evt.key === "Escape") $ipt.blur(); }) .disableSpellcheck(); UiUtil.bindTypingEnd({ $ipt, fnKeyup: () => { const nxtVal = opts.isNoTrim ? $ipt.val() : $ipt.val().trim(); component._state[prop] = opts.isAllowNull && !nxtVal ? null : nxtVal; }, }); if (opts.placeholder) $ipt.attr("placeholder", opts.placeholder); if (opts.autocomplete && opts.autocomplete.length) $ipt.typeahead({source: opts.autocomplete}); const hook = () => { if (component._state[prop] == null) $ipt.val(null); else { // If the only difference is start/end whitespace, leave it; otherwise, adding spaces is frustrating if ($ipt.val().trim() !== component._state[prop]) $ipt.val(component._state[prop]); } }; component._addHookBase(prop, hook); hook(); if (opts.asMeta) return this._getIptDecoratedMeta(component, prop, $ipt, hook, opts); else return $ipt; } static _getIptDecoratedMeta (component, prop, $ipt, hook, opts) { const out = {$ipt, unhook: () => component._removeHookBase(prop, hook)}; if (opts.decorationLeft || opts.decorationRight) { let $decorLeft; let $decorRight; if (opts.decorationLeft) { $ipt.addClass(`ui-ideco__ipt ui-ideco__ipt--left`); $decorLeft = ComponentUiUtil._$getDecor(component, prop, $ipt, opts.decorationLeft, "left", opts); } if (opts.decorationRight) { $ipt.addClass(`ui-ideco__ipt ui-ideco__ipt--right`); $decorRight = ComponentUiUtil._$getDecor(component, prop, $ipt, opts.decorationRight, "right", opts); } out.$wrp = $$`
${$ipt}${$decorLeft}${$decorRight}
`; } return out; } static _$getDecor (component, prop, $ipt, decorType, side, opts) { switch (decorType) { case "search": { return $(`
`); } case "clear": { return $(`
`) .click(() => $ipt.val("").change().keydown().keyup()); } case "ticker": { const isValidValue = val => { if (opts.max != null && val > opts.max) return false; if (opts.min != null && val < opts.min) return false; return true; }; const handleClick = (delta) => { // TODO(future) this should be run first to evaluate any lingering expressions in the input, but it // breaks when the number is negative, as we need to add a "=" to the front of the input before // evaluating // $ipt.change(); const nxt = component._state[prop] + delta; if (!isValidValue(nxt)) return; component._state[prop] = nxt; $ipt.focus(); }; const $btnUp = $(``) .click(() => handleClick(1)); const $btnDown = $(``) .click(() => handleClick(-1)); return $$`
${$btnUp} ${$btnDown}
`; } case "spacer": { return ""; } default: throw new Error(`Unimplemented!`); } } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param [opts] Options Object. * @param [opts.$ele] Element to use. * @return {$} */ static $getIptEntries (component, prop, opts) { opts = opts || {}; const $ipt = (opts.$ele || $(``)) .keydown(evt => { if (evt.key === "Escape") $ipt.blur(); }) .change(() => component._state[prop] = UiUtil.getTextAsEntries($ipt.val().trim())); const hook = () => $ipt.val(UiUtil.getEntriesAsText(component._state[prop])); component._addHookBase(prop, hook); hook(); return $ipt; } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param [opts] Options Object. * @param [opts.$ele] Element to use. * @param [opts.html] HTML to convert to element to use. * @return {jQuery} */ static $getIptColor (component, prop, opts) { opts = opts || {}; const $ipt = (opts.$ele || $(opts.html || ``)) .change(() => component._state[prop] = $ipt.val()); const hook = () => $ipt.val(component._state[prop]); component._addHookBase(prop, hook); hook(); return $ipt; } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param [opts] Options Object. * @param [opts.ele] Element to use. * @param [opts.html] HTML to convert to element to use. * @param [opts.text] Button text, if element is not specified. * @param [opts.fnHookPost] Function to run after primary hook. * @param [opts.stateName] State name. * @param [opts.stateProp] State prop. * @param [opts.isInverted] If the toggle display should be inverted. * @param [opts.activeClass] CSS class to use when setting the button as "active." * @param [opts.title] * @param [opts.activeTitle] Title to use when setting the button as "active." * @param [opts.inactiveTitle] Title to use when setting the button as "active." * @return * */ static getBtnBool (component, prop, opts) { opts = opts || {}; let ele = opts.ele; if (opts.html) ele = e_({outer: opts.html}); const activeClass = opts.activeClass || "active"; const stateName = opts.stateName || "state"; const stateProp = opts.stateProp || `_${stateName}`; const btn = (ele ? e_({ele}) : e_({ ele: ele, tag: "button", clazz: "btn btn-xs btn-default", text: opts.text || "Toggle", })) .onClick(() => component[stateProp][prop] = !component[stateProp][prop]) .onContextmenu(evt => { evt.preventDefault(); component[stateProp][prop] = !component[stateProp][prop]; }); const hk = () => { btn.toggleClass(activeClass, opts.isInverted ? !component[stateProp][prop] : !!component[stateProp][prop]); if (opts.activeTitle || opts.inactiveTitle) btn.title(component[stateProp][prop] ? (opts.activeTitle || opts.title || "") : (opts.inactiveTitle || opts.title || "")); if (opts.fnHookPost) opts.fnHookPost(component[stateProp][prop]); }; component._addHook(stateName, prop, hk); hk(); return btn; } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param [opts] Options Object. * @param [opts.$ele] Element to use. * @param [opts.html] HTML to convert to element to use. * @param [opts.text] Button text, if element is not specified. * @param [opts.fnHookPost] Function to run after primary hook. * @param [opts.stateName] State name. * @param [opts.stateProp] State prop. * @param [opts.isInverted] If the toggle display should be inverted. * @param [opts.activeClass] CSS class to use when setting the button as "active." * @param [opts.title] * @param [opts.activeTitle] Title to use when setting the button as "active." * @param [opts.inactiveTitle] Title to use when setting the button as "active." * @return {jQuery} */ static $getBtnBool (component, prop, opts) { const nxtOpts = {...opts}; if (nxtOpts.$ele) { nxtOpts.ele = nxtOpts.$ele[0]; delete nxtOpts.$ele; } return $(this.getBtnBool(component, prop, nxtOpts)); } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param [opts] Options Object. * @param [opts.$ele] Element to use. * @param [opts.asMeta] If a meta-object should be returned containing the hook and the input. * @param [opts.isDisplayNullAsIndeterminate] * @param [opts.isTreatIndeterminateNullAsPositive] * @param [opts.stateName] State name. * @param [opts.stateProp] State prop. * @return {jQuery} */ static $getCbBool (component, prop, opts) { opts = opts || {}; const stateName = opts.stateName || "state"; const stateProp = opts.stateProp || `_${stateName}`; const cb = e_({ tag: "input", type: "checkbox", keydown: evt => { if (evt.key === "Escape") cb.blur(); }, change: () => { if (opts.isTreatIndeterminateNullAsPositive && component[stateProp][prop] == null) { component[stateProp][prop] = false; return; } component[stateProp][prop] = cb.checked; }, }); const hook = () => { cb.checked = !!component[stateProp][prop]; if (opts.isDisplayNullAsIndeterminate) cb.indeterminate = component[stateProp][prop] == null; }; component._addHook(stateName, prop, hook); hook(); const $cb = $(cb); return opts.asMeta ? ({$cb, unhook: () => component._removeHook(stateName, prop, hook)}) : $cb; } /** * A select2-style dropdown. * @param comp An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param opts Options Object. * @param opts.values Values to display. * @param [opts.isHiddenPerValue] * @param [opts.$ele] Element to use. * @param [opts.html] HTML to convert to element to use. * @param [opts.isAllowNull] If null is allowed. * @param [opts.fnDisplay] Value display function. * @param [opts.displayNullAs] If null values are allowed, display them as this string. * @param [opts.fnGetAdditionalStyleClasses] Function which converts an item into CSS classes. * @param [opts.asMeta] If a meta-object should be returned containing the hook and the select. * @param [opts.isDisabled] If the selector should be display-only * @return {jQuery} */ static $getSelSearchable (comp, prop, opts) { opts = opts || {}; const $iptDisplay = (opts.$ele || $(opts.html || ``)) .addClass("ui-sel2__ipt-display") .attr("tabindex", "-1") .click(() => { if (opts.isDisabled) return; $iptSearch.focus().select(); }) .prop("disabled", !!opts.isDisabled) .disableSpellcheck(); const handleSearchChange = () => { const cleanTerm = this._$getSelSearchable_getSearchString($iptSearch.val()); metaOptions.forEach(it => { it.isVisible = it.searchTerm.includes(cleanTerm); it.$ele.toggleVe(it.isVisible && !it.isForceHidden); }); }; const handleSearchChangeDebounced = MiscUtil.debounce(handleSearchChange, 30); const $iptSearch = (opts.$ele || $(opts.html || ``)) .addClass("absolute ui-sel2__ipt-search") .keydown(evt => { if (opts.isDisabled) return; switch (evt.key) { case "Escape": evt.stopPropagation(); return $iptSearch.blur(); case "ArrowDown": { evt.preventDefault(); const visibleMetaOptions = metaOptions.filter(it => it.isVisible && !it.isForceHidden); if (!visibleMetaOptions.length) return; visibleMetaOptions[0].$ele.focus(); break; } case "Enter": case "Tab": { const visibleMetaOptions = metaOptions.filter(it => it.isVisible && !it.isForceHidden); if (!visibleMetaOptions.length) return; comp._state[prop] = visibleMetaOptions[0].value; $iptSearch.blur(); break; } default: handleSearchChangeDebounced(); } }) .change(() => handleSearchChangeDebounced()) .click(() => { if (opts.isDisabled) return; $iptSearch.focus().select(); }) .prop("disabled", !!opts.isDisabled) .disableSpellcheck(); const $wrpChoices = $(`
`); const $wrp = $$`
${$iptDisplay} ${$iptSearch} ${$wrpChoices}
`; const procValues = opts.isAllowNull ? [null, ...opts.values] : opts.values; const metaOptions = procValues.map((v, i) => { const display = v == null ? (opts.displayNullAs || "\u2014") : opts.fnDisplay ? opts.fnDisplay(v) : v; const additionalStyleClasses = opts.fnGetAdditionalStyleClasses ? opts.fnGetAdditionalStyleClasses(v) : null; const $ele = $(`
${display}
`) .click(() => { if (opts.isDisabled) return; comp._state[prop] = v; $(document.activeElement).blur(); // Temporarily remove pointer events from the dropdown, so it collapses thanks to its :hover CSS $wrp.addClass("no-events"); setTimeout(() => $wrp.removeClass("no-events"), 50); }) .keydown(evt => { if (opts.isDisabled) return; switch (evt.key) { case "Escape": evt.stopPropagation(); return $ele.blur(); case "ArrowDown": { evt.preventDefault(); const visibleMetaOptions = metaOptions.filter(it => it.isVisible && !it.isForceHidden); if (!visibleMetaOptions.length) return; const ixCur = visibleMetaOptions.indexOf(out); const nxt = visibleMetaOptions[ixCur + 1]; if (nxt) nxt.$ele.focus(); break; } case "ArrowUp": { evt.preventDefault(); const visibleMetaOptions = metaOptions.filter(it => it.isVisible && !it.isForceHidden); if (!visibleMetaOptions.length) return; const ixCur = visibleMetaOptions.indexOf(out); const prev = visibleMetaOptions[ixCur - 1]; if (prev) return prev.$ele.focus(); $iptSearch.focus(); break; } case "Enter": { comp._state[prop] = v; $ele.blur(); break; } } }) .appendTo($wrpChoices); const isForceHidden = opts.isHiddenPerValue && !!(opts.isAllowNull ? opts.isHiddenPerValue[i - 1] : opts.isHiddenPerValue[i]); if (isForceHidden) $ele.hideVe(); const out = { value: v, isVisible: true, isForceHidden, searchTerm: this._$getSelSearchable_getSearchString(display), $ele, }; return out; }); const fnUpdateHidden = (isHiddenPerValue, isHideNull = false) => { let metaOptions_ = metaOptions; if (opts.isAllowNull) { metaOptions_[0].isForceHidden = isHideNull; metaOptions_ = metaOptions_.slice(1); } metaOptions_.forEach((it, i) => it.isForceHidden = !!isHiddenPerValue[i]); handleSearchChange(); }; const hk = () => { if (comp._state[prop] == null) $iptDisplay.addClass("italic").addClass("ve-muted").val(opts.displayNullAs || "\u2014"); else $iptDisplay.removeClass("italic").removeClass("ve-muted").val(opts.fnDisplay ? opts.fnDisplay(comp._state[prop]) : comp._state[prop]); metaOptions.forEach(it => it.$ele.removeClass("active")); const metaActive = metaOptions.find(it => it.value == null ? comp._state[prop] == null : it.value === comp._state[prop]); if (metaActive) metaActive.$ele.addClass("active"); }; comp._addHookBase(prop, hk); hk(); return opts.asMeta ? ({ $wrp, unhook: () => comp._removeHookBase(prop, hk), $iptDisplay, $iptSearch, fnUpdateHidden, }) : $wrp; } static _$getSelSearchable_getSearchString (str) { if (str == null) return ""; return CleanUtil.getCleanString(str.trim().toLowerCase().replace(/\s+/g, " ")); } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param opts Options Object. * @param opts.values Values to display. * @param [opts.$ele] Element to use. * @param [opts.html] HTML to convert to element to use. * @param [opts.isAllowNull] If null is allowed. * @param [opts.fnDisplay] Value display function. * @param [opts.displayNullAs] If null values are allowed, display them as this string. * @param [opts.asMeta] If a meta-object should be returned containing the hook and the select. * @param [opts.propProxy] Proxy prop. * @param [opts.isSetIndexes] If the index of the selected item should be set as state, rather than the item itself. */ static $getSelEnum (component, prop, {values, $ele, html, isAllowNull, fnDisplay, displayNullAs, asMeta, propProxy = "state", isSetIndexes = false} = {}) { const _propProxy = `_${propProxy}`; let values_; let $sel = $ele || (html ? $(html) : null); // Use native API, if we can, for performance if (!$sel) { const sel = document.createElement("select"); sel.className = "form-control input-xs"; $sel = $(sel); } $sel.change(() => { const ix = Number($sel.val()); if (~ix) return void (component[_propProxy][prop] = isSetIndexes ? ix : values_[ix]); if (isAllowNull) return void (component[_propProxy][prop] = null); component[_propProxy][prop] = isSetIndexes ? 0 : values_[0]; }); // If the new value list doesn't contain our current value, reset our current value const setValues_handleResetOnMissing = ({isResetOnMissing, nxtValues}) => { if (!isResetOnMissing) return; if (component[_propProxy][prop] == null) return; if (isSetIndexes) { if (component[_propProxy][prop] >= 0 && component[_propProxy][prop] < nxtValues.length) { if (isAllowNull) return component[_propProxy][prop] = null; return component[_propProxy][prop] = 0; } return; } if (!nxtValues.includes(component[_propProxy][prop])) { if (isAllowNull) return component[_propProxy][prop] = null; return component[_propProxy][prop] = nxtValues[0]; } }; const setValues = (nxtValues, {isResetOnMissing = false, isForce = false} = {}) => { if (!isForce && CollectionUtil.deepEquals(values_, nxtValues)) return; values_ = nxtValues; $sel.empty(); // Use native API for performance if (isAllowNull) { const opt = document.createElement("option"); opt.value = "-1"; opt.text = displayNullAs || "\u2014"; $sel.append(opt); } values_.forEach((it, i) => { const opt = document.createElement("option"); opt.value = `${i}`; opt.text = fnDisplay ? fnDisplay(it) : it; $sel.append(opt); }); setValues_handleResetOnMissing({isResetOnMissing, nxtValues}); hook(); }; const hook = () => { if (isSetIndexes) { const ix = component[_propProxy][prop] == null ? -1 : component[_propProxy][prop]; $sel.val(`${ix}`); return; } const searchFor = component[_propProxy][prop] === undefined ? null : component[_propProxy][prop]; // Null handling is done in change handler const ix = values_.indexOf(searchFor); $sel.val(`${ix}`); }; component._addHookBase(prop, hook); setValues(values); if (!asMeta) return $sel; return { $sel, unhook: () => component._removeHookBase(prop, hook), setValues, }; } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param opts Options Object. * @param opts.values Values to display. * @param [opts.fnDisplay] Value display function. */ static $getPickEnum (component, prop, opts) { return this._$getPickEnumOrString(component, prop, opts); } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param [opts] Options Object. * @param [opts.values] Values to display. * @param [opts.isCaseInsensitive] If the values should be case insensitive. */ static $getPickString (component, prop, opts) { return this._$getPickEnumOrString(component, prop, {...opts, isFreeText: true}); } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param opts Options Object. * @param [opts.values] Values to display. * @param [opts.fnDisplay] Value display function. * @param [opts.isFreeText] If the picker should accept free text. * @param [opts.isCaseInsensitive] If the picker should accept free text. */ static _$getPickEnumOrString (component, prop, opts) { opts = opts || {}; const getSubcompValues = () => { const initialValuesArray = (opts.values || []).concat(opts.isFreeText ? MiscUtil.copyFast((component._state[prop] || [])) : []); const initialValsCompWith = opts.isCaseInsensitive ? component._state[prop].map(it => it.toLowerCase()) : component._state[prop]; return initialValuesArray .map(v => opts.isCaseInsensitive ? v.toLowerCase() : v) .mergeMap(v => ({[v]: component._state[prop] && initialValsCompWith.includes(v)})); }; const initialVals = getSubcompValues(); let $btnAdd; if (opts.isFreeText) { $btnAdd = $(``) .click(async () => { const input = await InputUiUtil.pGetUserString(); if (input == null || input === VeCt.SYM_UI_SKIP) return; const inputClean = opts.isCaseInsensitive ? input.trim().toLowerCase() : input.trim(); pickComp.getPod().set(inputClean, true); }); } else { const menu = ContextUtil.getMenu(opts.values.map(it => new ContextUtil.Action( opts.fnDisplay ? opts.fnDisplay(it) : it, () => pickComp.getPod().set(it, true), ))); $btnAdd = $(``) .click(evt => ContextUtil.pOpenMenu(evt, menu)); } const pickComp = BaseComponent.fromObject(initialVals); pickComp.render = function ($parent) { $parent.empty(); Object.entries(this._state).forEach(([k, v]) => { if (v === false) return; const $btnRemove = $(``) .click(() => this._state[k] = false); const txt = `${opts.fnDisplay ? opts.fnDisplay(k) : k}`; $$`
${txt}
${$btnRemove}
`.appendTo($parent); }); }; const $wrpPills = $(`
`); const $wrp = $$`
${$btnAdd}${$wrpPills}
`; pickComp._addHookAll("state", () => { component._state[prop] = Object.keys(pickComp._state).filter(k => pickComp._state[k]); pickComp.render($wrpPills); }); pickComp.render($wrpPills); const hkParent = () => pickComp._proxyAssignSimple("state", getSubcompValues(), true); component._addHookBase(prop, hkParent); return $wrp; } /** * @param component An instance of a class which extends BaseComponent. * @param prop Component to hook on. * @param opts Options Object. * @param opts.values Values to display. * @param [opts.fnDisplay] Value display function. * @param [opts.isDisallowNull] True if null is not an allowed value. * @param [opts.asMeta] If a meta-object should be returned containing the hook and the wrapper. * @param [opts.isIndent] If the checkboxes should be indented. * @return {jQuery} */ static $getCbsEnum (component, prop, opts) { opts = opts || {}; const $wrp = $(`
`); const metas = opts.values.map(it => { const $cb = $(``) .keydown(evt => { if (evt.key === "Escape") $cb.blur(); }) .change(() => { let didUpdate = false; const ix = (component._state[prop] || []).indexOf(it); if (~ix) component._state[prop].splice(ix, 1); else { if (component._state[prop]) component._state[prop].push(it); else { didUpdate = true; component._state[prop] = [it]; } } if (!didUpdate) component._state[prop] = [...component._state[prop]]; }); $$``.appendTo($wrp); return {$cb, value: it}; }); const hook = () => metas.forEach(meta => meta.$cb.prop("checked", component._state[prop] && component._state[prop].includes(meta.value))); component._addHookBase(prop, hook); hook(); return opts.asMeta ? {$wrp, unhook: () => component._removeHookBase(prop, hook)} : $wrp; } // region Multi Choice /** * @param comp * @param prop Base prop. This will be expanded with `__...`-suffixed sub-props as required. * @param opts Options. * @param [opts.values] Array of values. Mutually incompatible with "valueGroups". * @param [opts.valueGroups] Array of value groups (of the form * `{name: "Group Name", text: "Optional group hint text", values: [...]}` ). * Mutually incompatible with "values". * @param [opts.valueGroupSplitControlsLookup] A lookup of ` -> header controls` to embed in the UI. * @param [opts.count] Number of choices the user can make (cannot be used with min/max). * @param [opts.min] Minimum number of choices the user can make (cannot be used with count). * @param [opts.max] Maximum number of choices the user can make (cannot be used with count). * @param [opts.isResolveItems] True if the promise should resolve to an array of the items instead of the indices. // TODO maybe remove? * @param [opts.fnDisplay] Function which takes a value and returns display text. * @param [opts.required] Values which are required. * @param [opts.ixsRequired] Indexes of values which are required. * @param [opts.isSearchable] If a search input should be created. * @param [opts.fnGetSearchText] Function which takes a value and returns search text. */ static getMetaWrpMultipleChoice (comp, prop, opts) { opts = opts || {}; this._getMetaWrpMultipleChoice_doValidateOptions(opts); const rowMetas = []; const $eles = []; const ixsSelectionOrder = []; const $elesSearchable = {}; const propIsAcceptable = this.getMetaWrpMultipleChoice_getPropIsAcceptable(prop); const propPulse = this.getMetaWrpMultipleChoice_getPropPulse(prop); const propIxMax = this._getMetaWrpMultipleChoice_getPropValuesLength(prop); const cntRequired = ((opts.required || []).length) + ((opts.ixsRequired || []).length); const count = opts.count != null ? opts.count - cntRequired : null; const countIncludingRequired = opts.count != null ? count + cntRequired : null; const min = opts.min != null ? opts.min - cntRequired : null; const max = opts.max != null ? opts.max - cntRequired : null; const valueGroups = opts.valueGroups || [{values: opts.values}]; let ixValue = 0; valueGroups.forEach((group, i) => { if (i !== 0) $eles.push($(`
`)); if (group.name) { const $wrpName = $$`
${group.name}
${opts.valueGroupSplitControlsLookup?.[group.name]}
`; $eles.push($wrpName); } if (group.text) $eles.push($(`
${group.text}
`)); group.values.forEach(v => { const ixValueFrozen = ixValue; const propIsActive = this.getMetaWrpMultipleChoice_getPropIsActive(prop, ixValueFrozen); const propIsRequired = this.getMetaWrpMultipleChoice_getPropIsRequired(prop, ixValueFrozen); const isHardRequired = (opts.required && opts.required.includes(v)) || (opts.ixsRequired && opts.ixsRequired.includes(ixValueFrozen)); const isRequired = isHardRequired || comp._state[propIsRequired]; // In the case of pre-existing selections, add these to our selection order tracking as they appear if (comp._state[propIsActive] && !comp._state[propIsRequired]) ixsSelectionOrder.push(ixValueFrozen); let hk; const $cb = isRequired ? $(``) : ComponentUiUtil.$getCbBool(comp, propIsActive); if (isRequired) comp._state[propIsActive] = true; if (!isRequired) { hk = () => { // region Selection order const ixIx = ixsSelectionOrder.findIndex(it => it === ixValueFrozen); if (~ixIx) ixsSelectionOrder.splice(ixIx, 1); if (comp._state[propIsActive]) ixsSelectionOrder.push(ixValueFrozen); // endregion // region Enable/disable const activeRows = rowMetas.filter(it => comp._state[it.propIsActive]); if (count != null) { // If we're above the max allowed count, deselect a checkbox in FIFO order if (activeRows.length > countIncludingRequired) { // FIFO (`.shift`) makes logical sense, but FILO (`.splice` second-from-last) _feels_ better const ixFirstSelected = ixsSelectionOrder.splice(ixsSelectionOrder.length - 2, 1)[0]; if (ixFirstSelected != null) { const propIsActiveOther = this.getMetaWrpMultipleChoice_getPropIsActive(prop, ixFirstSelected); comp._state[propIsActiveOther] = false; comp._state[propPulse] = !comp._state[propPulse]; } return; } } let isAcceptable = false; if (count != null) { if (activeRows.length === countIncludingRequired) isAcceptable = true; } else { if (activeRows.length >= (min || 0) && activeRows.length <= (max || Number.MAX_SAFE_INTEGER)) isAcceptable = true; } // Save this to a flag in the state object that external code can read comp._state[propIsAcceptable] = isAcceptable; // endregion comp._state[propPulse] = !comp._state[propPulse]; }; comp._addHookBase(propIsActive, hk); hk(); } const displayValue = opts.fnDisplay ? opts.fnDisplay(v, ixValueFrozen) : v; rowMetas.push({ $cb, displayValue, value: v, propIsActive, unhook: () => { if (hk) comp._removeHookBase(propIsActive, hk); }, }); const $ele = $$``; $eles.push($ele); if (opts.isSearchable) { const searchText = `${opts.fnGetSearchText ? opts.fnGetSearchText(v, ixValueFrozen) : v}`.toLowerCase().trim(); ($elesSearchable[searchText] = $elesSearchable[searchText] || []).push($ele); } ixValue++; }); }); // Sort the initial selection order (i.e. that from defaults) by lowest to highest, such that new clicks // will remove from the first element in visual order ixsSelectionOrder.sort((a, b) => SortUtil.ascSort(a, b)); comp.__state[propIxMax] = ixValue; let $iptSearch; if (opts.isSearchable) { const compSub = BaseComponent.fromObject({search: ""}); $iptSearch = ComponentUiUtil.$getIptStr(compSub, "search"); const hkSearch = () => { const cleanSearch = compSub._state.search.trim().toLowerCase(); if (!cleanSearch) { Object.values($elesSearchable).forEach($eles => $eles.forEach($ele => $ele.removeClass("ve-hidden"))); return; } Object.entries($elesSearchable) .forEach(([searchText, $eles]) => $eles.forEach($ele => $ele.toggleVe(searchText.includes(cleanSearch)))); }; compSub._addHookBase("search", hkSearch); hkSearch(); } // Always return this as a "meta" object const unhook = () => rowMetas.forEach(it => it.unhook()); return { $ele: $$`
${$eles}
`, $iptSearch, rowMetas, // Return this to allow for creating custom UI propIsAcceptable, propPulse, unhook, cleanup: () => { unhook(); // This will trigger a final "pulse" Object.keys(comp._state) .filter(it => it.startsWith(`${prop}__`)) .forEach(it => delete comp._state[it]); }, }; } static getMetaWrpMultipleChoice_getPropIsAcceptable (prop) { return `${prop}__isAcceptable`; } static getMetaWrpMultipleChoice_getPropPulse (prop) { return `${prop}__pulse`; } static _getMetaWrpMultipleChoice_getPropValuesLength (prop) { return `${prop}__length`; } static getMetaWrpMultipleChoice_getPropIsActive (prop, ixValue) { return `${prop}__isActive_${ixValue}`; } static getMetaWrpMultipleChoice_getPropIsRequired (prop, ixValue) { return `${prop}__isRequired_${ixValue}`; } static getMetaWrpMultipleChoice_getSelectedIxs (comp, prop) { const out = []; const len = comp._state[this._getMetaWrpMultipleChoice_getPropValuesLength(prop)] || 0; for (let i = 0; i < len; ++i) { if (comp._state[this.getMetaWrpMultipleChoice_getPropIsActive(prop, i)]) out.push(i); } return out; } static getMetaWrpMultipleChoice_getSelectedValues (comp, prop, {values, valueGroups}) { const selectedIxs = this.getMetaWrpMultipleChoice_getSelectedIxs(comp, prop); if (values) return selectedIxs.map(ix => values[ix]); const selectedIxsSet = new Set(selectedIxs); const out = []; let ixValue = 0; valueGroups.forEach(group => { group.values.forEach(v => { if (selectedIxsSet.has(ixValue)) out.push(v); ixValue++; }); }); return out; } static _getMetaWrpMultipleChoice_doValidateOptions (opts) { if ((Number(!!opts.values) + Number(!!opts.valueGroups)) !== 1) throw new Error(`Exactly one of "values" and "valueGroups" must be specified!`); if (opts.count != null && (opts.min != null || opts.max != null)) throw new Error(`Chooser must be either in "count" mode or "min/max" mode!`); // If no mode is specified, default to a "count 1" chooser if (opts.count == null && opts.min == null && opts.max == null) opts.count = 1; } // endregion /** * @param comp An instance of a class which extends BaseComponent. * @param opts Options Object. * @param opts.propMin * @param opts.propMax * @param opts.propCurMin * @param [opts.propCurMax] * @param [opts.fnDisplay] Value display function. * @param [opts.fnDisplayTooltip] * @param [opts.sparseValues] */ static $getSliderRange (comp, opts) { opts = opts || {}; const slider = new ComponentUiUtil.RangeSlider({comp, ...opts}); return slider.$get(); } static $getSliderNumber ( comp, prop, { min, max, step, $ele, asMeta, } = {}, ) { const $slider = ($ele || $(``)) .change(() => comp._state[prop] = Number($slider.val())); if (min != null) $slider.attr("min", min); if (max != null) $slider.attr("max", max); if (step != null) $slider.attr("step", step); const hk = () => $slider.val(comp._state[prop]); comp._addHookBase(prop, hk); hk(); return asMeta ? ({$slider, unhook: () => comp._removeHookBase(prop, hk)}) : $slider; } } ComponentUiUtil.RangeSlider = class { constructor ( { comp, propMin, propMax, propCurMin, propCurMax, fnDisplay, fnDisplayTooltip, sparseValues, }, ) { this._comp = comp; this._propMin = propMin; this._propMax = propMax; this._propCurMin = propCurMin; this._propCurMax = propCurMax; this._fnDisplay = fnDisplay; this._fnDisplayTooltip = fnDisplayTooltip; this._sparseValues = sparseValues; this._isSingle = !this._propCurMax; // region Make a copy of the interesting bits of the parent component, so we can freely change them without // outside performance implications const compCpyState = { [this._propMin]: this._comp._state[this._propMin], [this._propCurMin]: this._comp._state[this._propCurMin], [this._propMax]: this._comp._state[this._propMax], }; if (!this._isSingle) compCpyState[this._propCurMax] = this._comp._state[this._propCurMax]; this._compCpy = BaseComponent.fromObject(compCpyState); // Sync parent changes to our state this._comp._addHook("state", this._propMin, () => this._compCpy._state[this._propMin] = this._comp._state[this._propMin]); this._comp._addHook("state", this._propCurMin, () => this._compCpy._state[this._propCurMin] = this._comp._state[this._propCurMin]); this._comp._addHook("state", this._propMax, () => this._compCpy._state[this._propMax] = this._comp._state[this._propMax]); if (!this._isSingle) this._comp._addHook("state", this._propCurMax, () => this._compCpy._state[this._propCurMax] = this._comp._state[this._propCurMax]); // endregion this._cacheRendered = null; this._dispTrackOuter = null; this._dispTrackInner = null; this._thumbLow = null; this._thumbHigh = null; this._dragMeta = null; } $get () { const out = this.get(); return $(out); } get () { this.constructor._init(); this.constructor._ALL_SLIDERS.add(this); if (this._cacheRendered) return this._cacheRendered; // region Top part const dispValueLeft = this._isSingle ? this._getSpcSingleValue() : this._getDispValue({isVisible: true, side: "left"}); const dispValueRight = this._getDispValue({isVisible: true, side: "right"}); this._dispTrackInner = this._isSingle ? null : e_({ tag: "div", clazz: "ui-slidr__track-inner h-100 absolute", }); this._thumbLow = this._getThumb(); this._thumbHigh = this._isSingle ? null : this._getThumb(); this._dispTrackOuter = e_({ tag: "div", clazz: `relative w-100 ui-slidr__track-outer`, children: [ this._dispTrackInner, this._thumbLow, this._thumbHigh, ].filter(Boolean), }); const wrpTrack = e_({ tag: "div", clazz: `ve-flex-v-center w-100 h-100 ui-slidr__wrp-track clickable`, mousedown: evt => { const thumb = this._getClosestThumb(evt); this._handleMouseDown(evt, thumb); }, children: [ this._dispTrackOuter, ], }); const wrpTop = e_({ tag: "div", clazz: "ve-flex-v-center w-100 ui-slidr__wrp-top", children: [ dispValueLeft, wrpTrack, dispValueRight, ].filter(Boolean), }); // endregion // region Bottom part const wrpPips = e_({ tag: "div", clazz: `w-100 ve-flex relative clickable h-100 ui-slidr__wrp-pips`, mousedown: evt => { const thumb = this._getClosestThumb(evt); this._handleMouseDown(evt, thumb); }, }); const wrpBottom = e_({ tag: "div", clazz: "w-100 ve-flex-vh-center ui-slidr__wrp-bottom", children: [ this._isSingle ? this._getSpcSingleValue() : this._getDispValue({side: "left"}), // Pad the start wrpPips, this._getDispValue({side: "right"}), // and the end ].filter(Boolean), }); // endregion // region Hooks const hkChangeValue = () => { const curMin = this._compCpy._state[this._propCurMin]; const pctMin = this._getLeftPositionPercentage({value: curMin}); this._thumbLow.style.left = `calc(${pctMin}% - ${this.constructor._W_THUMB_PX / 2}px)`; const toDisplayLeft = this._fnDisplay ? `${this._fnDisplay(curMin)}`.qq() : curMin; const toDisplayLeftTooltip = this._fnDisplayTooltip ? `${this._fnDisplayTooltip(curMin)}`.qq() : null; if (!this._isSingle) { dispValueLeft .html(toDisplayLeft) .tooltip(toDisplayLeftTooltip); } if (!this._isSingle) { this._dispTrackInner.style.left = `${pctMin}%`; const curMax = this._compCpy._state[this._propCurMax]; const pctMax = this._getLeftPositionPercentage({value: curMax}); this._dispTrackInner.style.right = `${100 - pctMax}%`; this._thumbHigh.style.left = `calc(${pctMax}% - ${this.constructor._W_THUMB_PX / 2}px)`; dispValueRight .html(this._fnDisplay ? `${this._fnDisplay(curMax)}`.qq() : curMax) .tooltip(this._fnDisplayTooltip ? `${this._fnDisplayTooltip(curMax)}`.qq() : null); } else { dispValueRight .html(toDisplayLeft) .tooltip(toDisplayLeftTooltip); } }; const hkChangeLimit = () => { const pips = []; if (!this._sparseValues) { const numPips = this._compCpy._state[this._propMax] - this._compCpy._state[this._propMin]; let pipIncrement = 1; // Cap the number of pips if (numPips > ComponentUiUtil.RangeSlider._MAX_PIPS) pipIncrement = Math.ceil(numPips / ComponentUiUtil.RangeSlider._MAX_PIPS); let i, len; for ( i = this._compCpy._state[this._propMin], len = this._compCpy._state[this._propMax] + 1; i < len; i += pipIncrement ) { pips.push(this._getWrpPip({ isMajor: i === this._compCpy._state[this._propMin] || i === (len - 1), value: i, })); } // Ensure the last pip is always rendered, even if we're reducing pips if (i !== this._compCpy._state[this._propMax]) pips.push(this._getWrpPip({isMajor: true, value: this._compCpy._state[this._propMax]})); } else { const len = this._sparseValues.length; this._sparseValues.forEach((val, i) => { pips.push(this._getWrpPip({ isMajor: i === 0 || i === (len - 1), value: val, })); }); } wrpPips.empty(); e_({ ele: wrpPips, children: pips, }); hkChangeValue(); }; this._compCpy._addHook("state", this._propMin, hkChangeLimit); this._compCpy._addHook("state", this._propMax, hkChangeLimit); this._compCpy._addHook("state", this._propCurMin, hkChangeValue); if (!this._isSingle) this._compCpy._addHook("state", this._propCurMax, hkChangeValue); hkChangeLimit(); // endregion const wrp = e_({ tag: "div", clazz: "ve-flex-col w-100 ui-slidr__wrp", children: [ wrpTop, wrpBottom, ], }); return this._cacheRendered = wrp; } destroy () { this.constructor._ALL_SLIDERS.delete(this); if (this._cacheRendered) this._cacheRendered.remove(); } _getDispValue ({isVisible, side}) { return e_({ tag: "div", clazz: `overflow-hidden ui-slidr__disp-value no-shrink no-grow ve-flex-vh-center bold no-select ${isVisible ? `ui-slidr__disp-value--visible` : ""} ui-slidr__disp-value--${side}`, }); } _getSpcSingleValue () { return e_({ tag: "div", clazz: `px-2`, }); } _getThumb () { const thumb = e_({ tag: "div", clazz: "ui-slidr__thumb absolute clickable", mousedown: evt => this._handleMouseDown(evt, thumb), }).attr("draggable", true); return thumb; } _getWrpPip ({isMajor, value} = {}) { const style = this._getWrpPip_getStyle({value}); const pip = e_({ tag: "div", clazz: `ui-slidr__pip ${isMajor ? `ui-slidr__pip--major` : `absolute`}`, }); const dispLabel = e_({ tag: "div", clazz: "absolute ui-slidr__pip-label ve-flex-vh-center ve-small no-wrap", html: isMajor ? this._fnDisplay ? `${this._fnDisplay(value)}`.qq() : value : "", title: isMajor && this._fnDisplayTooltip ? `${this._fnDisplayTooltip(value)}`.qq() : null, }); return e_({ tag: "div", clazz: "ve-flex-col ve-flex-vh-center absolute no-select", children: [ pip, dispLabel, ], style, }); } _getWrpPip_getStyle ({value}) { return `left: ${this._getLeftPositionPercentage({value})}%`; } _getLeftPositionPercentage ({value}) { if (this._sparseValues) { const ix = this._sparseValues.sort(SortUtil.ascSort).indexOf(value); if (!~ix) throw new Error(`Value "${value}" was not in the list of sparse values!`); return (ix / (this._sparseValues.length - 1)) * 100; } const min = this._compCpy._state[this._propMin]; const max = this._compCpy._state[this._propMax]; return ((value - min) / (max - min)) * 100; } /** * Convert pixel-space to track-space. * Example usage: * ``` * click: evt => { * const {x: trackOriginX, width: trackWidth} = this._dispTrackOuter.getBoundingClientRect(); * const value = this._getRelativeValue(evt, {trackOriginX, trackWidth}); * this._handleClick(evt, value); * } * ``` */ _getRelativeValue (evt, {trackOriginX, trackWidth}) { const xEvt = EventUtil.getClientX(evt) - trackOriginX; if (this._sparseValues) { const ixMax = this._sparseValues.length - 1; const rawVal = Math.round((xEvt / trackWidth) * ixMax); return this._sparseValues[Math.min(ixMax, Math.max(0, rawVal))]; } const min = this._compCpy._state[this._propMin]; const max = this._compCpy._state[this._propMax]; const rawVal = min + Math.round( (xEvt / trackWidth) * (max - min), ); return Math.min(max, Math.max(min, rawVal)); // Clamp eet } _getClosestThumb (evt) { if (this._isSingle) return this._thumbLow; const {x: trackOriginX, width: trackWidth} = this._dispTrackOuter.getBoundingClientRect(); const value = this._getRelativeValue(evt, {trackOriginX, trackWidth}); if (value < this._compCpy._state[this._propCurMin]) return this._thumbLow; if (value > this._compCpy._state[this._propCurMax]) return this._thumbHigh; const {distToMin, distToMax} = this._getDistsToCurrentMinAndMax(value); if (distToMax < distToMin) return this._thumbHigh; return this._thumbLow; } _getDistsToCurrentMinAndMax (value) { if (this._isSingle) throw new Error(`Can not get distance to max value for singleton slider!`); // Move the closest slider to this pip's location const distToMin = Math.abs(this._compCpy._state[this._propCurMin] - value); const distToMax = Math.abs(this._compCpy._state[this._propCurMax] - value); return {distToMin, distToMax}; } _handleClick (evt, value) { evt.stopPropagation(); evt.preventDefault(); // If lower than the lowest value, set the low value if (value < this._compCpy._state[this._propCurMin]) this._compCpy._state[this._propCurMin] = value; // If higher than the highest value, set the high value if (value > this._compCpy._state[this._propCurMax]) this._compCpy._state[this._propCurMax] = value; // Move the closest slider to this pip's location const {distToMin, distToMax} = this._getDistsToCurrentMinAndMax(value); if (distToMax < distToMin) this._compCpy._state[this._propCurMax] = value; else this._compCpy._state[this._propCurMin] = value; } _handleMouseDown (evt, thumb) { evt.preventDefault(); evt.stopPropagation(); // region Set drag metadata const {x: trackOriginX, width: trackWidth} = this._dispTrackOuter.getBoundingClientRect(); thumb.addClass(`ui-slidr__thumb--hover`); this._dragMeta = { trackOriginX, trackWidth, thumb, }; // endregion this._handleMouseMove(evt); } _handleMouseUp () { const wasActive = this._doDragCleanup(); // On finishing a slide, push our state to the parent comp if (wasActive) { const nxtState = { [this._propMin]: this._compCpy._state[this._propMin], [this._propMax]: this._compCpy._state[this._propMax], [this._propCurMin]: this._compCpy._state[this._propCurMin], }; if (!this._isSingle) nxtState[this._propCurMax] = this._compCpy._state[this._propCurMax]; this._comp._proxyAssignSimple("state", nxtState); } } _handleMouseMove (evt) { if (!this._dragMeta) return; const val = this._getRelativeValue(evt, this._dragMeta); if (this._dragMeta.thumb === this._thumbLow) { if (val > this._compCpy._state[this._propCurMax]) return; this._compCpy._state[this._propCurMin] = val; } else if (this._dragMeta.thumb === this._thumbHigh) { if (val < this._compCpy._state[this._propCurMin]) return; this._compCpy._state[this._propCurMax] = val; } } _doDragCleanup () { const isActive = this._dragMeta != null; if (this._dragMeta?.thumb) this._dragMeta.thumb.removeClass(`ui-slidr__thumb--hover`); this._dragMeta = null; return isActive; } static _init () { if (this._isInit) return; document.addEventListener("mousemove", evt => { for (const slider of this._ALL_SLIDERS) { slider._handleMouseMove(evt); } }); document.addEventListener("mouseup", evt => { for (const slider of this._ALL_SLIDERS) { slider._handleMouseUp(evt); } }); } }; ComponentUiUtil.RangeSlider._isInit = false; ComponentUiUtil.RangeSlider._ALL_SLIDERS = new Set(); ComponentUiUtil.RangeSlider._W_THUMB_PX = 16; ComponentUiUtil.RangeSlider._W_LABEL_PX = 24; ComponentUiUtil.RangeSlider._MAX_PIPS = 40; class SettingsUtil { static Setting = class { constructor ( { type, name, help, defaultVal, }, ) { this.type = type; this.name = name; this.help = help; this.defaultVal = defaultVal; } }; static EnumSetting = class extends SettingsUtil.Setting { constructor ( { enumVals, ...rest }, ) { super(rest); this.enumVals = enumVals; } }; static getDefaultSettings (settings) { return Object.entries(settings) .mergeMap(([prop, {defaultVal}]) => ({[prop]: defaultVal})); } } globalThis.ProxyBase = ProxyBase; globalThis.UiUtil = UiUtil; globalThis.ListUiUtil = ListUiUtil; globalThis.ProfUiUtil = ProfUiUtil; globalThis.TabUiUtil = TabUiUtil; globalThis.SearchUiUtil = SearchUiUtil; globalThis.SearchWidget = SearchWidget; globalThis.InputUiUtil = InputUiUtil; globalThis.DragReorderUiUtil = DragReorderUiUtil; globalThis.SourceUiUtil = SourceUiUtil; globalThis.BaseComponent = BaseComponent; globalThis.ComponentUiUtil = ComponentUiUtil;