mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
6051 lines
201 KiB
JavaScript
6051 lines
201 KiB
JavaScript
"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}<br>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: `<span class="glyphicon glyphicon-remove></span>`,
|
||
click: pHandleCloseClick(false),
|
||
}) : null;
|
||
|
||
const modalFooter = opts.hasFooter
|
||
? e_({
|
||
tag: "div",
|
||
clazz: `no-shrink w-100 ve-flex-col ui-modal__footer ${opts.isFullscreenModal ? `ui-modal__footer--fullscreen mt-1` : "mt-auto"}`,
|
||
})
|
||
: null;
|
||
|
||
const modal = e_({
|
||
tag: "div",
|
||
clazz: `ui-modal__inner ve-flex-col ${modalWindowClasses.join(" ")}`,
|
||
children: [
|
||
!opts.isEmpty && opts.title
|
||
? e_({
|
||
tag: "div",
|
||
clazz: `split-v-center no-shrink ${opts.isHeaderBorder ? `ui-modal__header--border` : ""} ${opts.isFullscreenModal ? `ui-modal__header--fullscreen mb-1` : ""}`,
|
||
children: [
|
||
opts.title
|
||
? e_({
|
||
tag: "h4",
|
||
clazz: `my-2`,
|
||
html: opts.title.qq(),
|
||
})
|
||
: null,
|
||
|
||
opts.$titleSplit ? opts.$titleSplit[0] : null,
|
||
|
||
btnCloseModal,
|
||
].filter(Boolean),
|
||
})
|
||
: null,
|
||
|
||
!opts.isEmpty ? wrpScroller : null,
|
||
|
||
modalFooter,
|
||
].filter(Boolean),
|
||
}).appendTo(opts.isFullscreenModal ? overlayBlind : wrpOverlay);
|
||
|
||
wrpOverlay
|
||
.addEventListener("mouseup", async evt => {
|
||
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(`<hr class="hr-2">`);
|
||
}
|
||
|
||
static $getAddModalRow ($modalInner, tag = "div") {
|
||
return $(`<${tag} class="ui-modal__row"></${tag}>`).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) $$`<div class="split ve-flex-v-center w-100 pr-1"><span>${headerText}</span>${opts.$eleRhs}</div>`.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(`<span>${labelText}</span>`);
|
||
const $cb = $(`<input type="checkbox">`).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 = $$`<label class="split-v-center py-1 veapp__ele-hoverable">
|
||
<span>${text}</span>
|
||
${$cb}
|
||
</label>`
|
||
.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(`<span>${labelText}</span>`);
|
||
const $sel = $(`<select class="form-control input-xs w-30">`).appendTo($row);
|
||
values.forEach((val, i) => $(`<option value="${i}"></option>`).text(opts.fnDisplay ? opts.fnDisplay(val) : val).appendTo($sel));
|
||
// N.B. this doesn't support null values
|
||
const ix = values.indexOf(objectWithProp[propName]);
|
||
$sel.val(`${~ix ? ix : 0}`)
|
||
.change(() => objectWithProp[propName] = values[$sel.val()]);
|
||
return $sel;
|
||
}
|
||
|
||
static _parseStrAsNumber (str, isInt) {
|
||
const wrpTree = Renderer.dice.lang.getTree3(str);
|
||
if (!wrpTree) return NaN;
|
||
const out = wrpTree.tree.evl({});
|
||
if (!isNaN(out) && isInt) return Math.round(out);
|
||
return out;
|
||
}
|
||
|
||
static bindTypingEnd ({$ipt, fnKeyup, fnKeypress, fnKeydown, fnClick} = {}) {
|
||
let timerTyping;
|
||
$ipt
|
||
.on("keyup search paste", evt => {
|
||
clearTimeout(timerTyping);
|
||
timerTyping = setTimeout(() => { fnKeyup(evt); }, UiUtil.TYPE_TIMEOUT_MS);
|
||
})
|
||
// Trigger on blur, as tabbing out of a field triggers the keyup on the element which was tabbed into. Our
|
||
// intent. however, is to trigger on any keyup which began in this field.
|
||
.on("blur", evt => {
|
||
clearTimeout(timerTyping);
|
||
fnKeyup(evt);
|
||
})
|
||
.on("keypress", evt => {
|
||
if (fnKeypress) fnKeypress(evt);
|
||
})
|
||
.on("keydown", evt => {
|
||
if (fnKeydown) fnKeydown(evt);
|
||
clearTimeout(timerTyping);
|
||
})
|
||
.on("click", () => {
|
||
if (fnClick) fnClick();
|
||
})
|
||
.on("instantKeyup", () => {
|
||
clearTimeout(timerTyping);
|
||
fnKeyup();
|
||
})
|
||
;
|
||
}
|
||
|
||
/** Brute-force select the input, in case something has delayed the rendering (e.g. a VTT application window) */
|
||
static async pDoForceFocus (ele, {timeout = 250} = {}) {
|
||
if (!ele) return;
|
||
ele.focus();
|
||
|
||
const forceFocusStart = Date.now();
|
||
while ((Date.now() < forceFocusStart + timeout) && document.activeElement !== ele) {
|
||
await MiscUtil.pDelay(33);
|
||
ele.focus();
|
||
}
|
||
}
|
||
}
|
||
UiUtil.SEARCH_RESULTS_CAP = 75;
|
||
UiUtil.TYPE_TIMEOUT_MS = 100; // auto-search after 100ms
|
||
UiUtil._MODAL_STACK = null;
|
||
UiUtil._MODAL_LAST_MOUSEDOWN = null;
|
||
|
||
class ListSelectClickHandlerBase {
|
||
static _EVT_PASS_THOUGH_TAGS = new Set(["A", "BUTTON"]);
|
||
|
||
constructor () {
|
||
this._firstSelection = null;
|
||
this._lastSelection = null;
|
||
|
||
this._selectionInitialValue = null;
|
||
}
|
||
|
||
/**
|
||
* @abstract
|
||
* @return {Array}
|
||
*/
|
||
get _visibleItems () { throw new Error("Unimplemented!"); }
|
||
|
||
/**
|
||
* @abstract
|
||
* @return {Array}
|
||
*/
|
||
get _allItems () { throw new Error("Unimplemented!"); }
|
||
|
||
/** @abstract */
|
||
_getCb (item, opts) { throw new Error("Unimplemented!"); }
|
||
|
||
/** @abstract */
|
||
_setCheckbox (item, opts) { throw new Error("Unimplemented!"); }
|
||
|
||
/** @abstract */
|
||
_setHighlighted (item, opts) { throw new Error("Unimplemented!"); }
|
||
|
||
/**
|
||
* (Public method for Plutonium use)
|
||
* Handle doing a checkbox-based selection toggle on a list.
|
||
* @param item List item.
|
||
* @param evt Click event.
|
||
* @param [opts] Options object.
|
||
* @param [opts.isNoHighlightSelection] If highlighting selected rows should be skipped.
|
||
* @param [opts.fnOnSelectionChange] Function to call when selection status of an item changes.
|
||
* @param [opts.fnGetCb] Function which gets the checkbox from a list item.
|
||
* @param [opts.isPassThroughEvents] If e.g. click events to links/buttons in the list item should be allowed/ignored.
|
||
*/
|
||
handleSelectClick (item, evt, opts) {
|
||
opts = opts || {};
|
||
|
||
if (opts.isPassThroughEvents) {
|
||
const evtPath = evt.composedPath();
|
||
const subEles = evtPath.slice(0, evtPath.indexOf(evt.currentTarget));
|
||
if (subEles.some(ele => this.constructor._EVT_PASS_THOUGH_TAGS.has(ele?.tagName))) return;
|
||
}
|
||
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
|
||
const cb = this._getCb(item, opts);
|
||
if (cb.disabled) return true;
|
||
|
||
if (evt && evt.shiftKey && this._firstSelection) {
|
||
if (this._lastSelection === item) {
|
||
// on double-tapping the end of the selection, toggle it on/off
|
||
|
||
this._setCheckbox(item, {...opts, toVal: !cb.checked});
|
||
} else if (this._firstSelection === item && this._lastSelection) {
|
||
// If the item matches the last clicked, clear all checkboxes from our last selection
|
||
|
||
const ix1 = this._visibleItems.indexOf(this._firstSelection);
|
||
const ix2 = this._visibleItems.indexOf(this._lastSelection);
|
||
|
||
const [ixStart, ixEnd] = [ix1, ix2].sort(SortUtil.ascSort);
|
||
for (let i = ixStart; i <= ixEnd; ++i) {
|
||
const it = this._visibleItems[i];
|
||
this._setCheckbox(it, {...opts, toVal: false});
|
||
}
|
||
|
||
this._setCheckbox(item, opts);
|
||
} else {
|
||
// on a shift-click, toggle all the checkboxes to the value of the initial item...
|
||
this._selectionInitialValue = this._getCb(this._firstSelection, opts).checked;
|
||
|
||
const ix1 = this._visibleItems.indexOf(this._firstSelection);
|
||
const ix2 = this._visibleItems.indexOf(item);
|
||
const ix2Prev = this._lastSelection ? this._visibleItems.indexOf(this._lastSelection) : null;
|
||
|
||
const [ixStart, ixEnd] = [ix1, ix2].sort(SortUtil.ascSort);
|
||
const nxtOpts = {...opts, toVal: this._selectionInitialValue};
|
||
for (let i = ixStart; i <= ixEnd; ++i) {
|
||
const it = this._visibleItems[i];
|
||
this._setCheckbox(it, nxtOpts);
|
||
}
|
||
|
||
// ...except when selecting; for those between the last selection and this selection, those to unchecked
|
||
if (this._selectionInitialValue && ix2Prev != null) {
|
||
if (ix2Prev > ixEnd) {
|
||
const nxtOpts = {...opts, toVal: !this._selectionInitialValue};
|
||
for (let i = ixEnd + 1; i <= ix2Prev; ++i) {
|
||
const it = this._visibleItems[i];
|
||
this._setCheckbox(it, nxtOpts);
|
||
}
|
||
} else if (ix2Prev < ixStart) {
|
||
const nxtOpts = {...opts, toVal: !this._selectionInitialValue};
|
||
for (let i = ix2Prev; i < ixStart; ++i) {
|
||
const it = this._visibleItems[i];
|
||
this._setCheckbox(it, nxtOpts);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
this._lastSelection = item;
|
||
} else {
|
||
// on a normal click, or if there's been no initial selection, just toggle the checkbox
|
||
|
||
const cbMaster = this._getCb(item, opts);
|
||
if (cbMaster) {
|
||
cbMaster.checked = !cbMaster.checked;
|
||
|
||
if (opts.fnOnSelectionChange) opts.fnOnSelectionChange(item, cbMaster.checked);
|
||
|
||
if (!opts.isNoHighlightSelection) {
|
||
this._setHighlighted(item, cbMaster.checked);
|
||
}
|
||
} else {
|
||
if (!opts.isNoHighlightSelection) {
|
||
this._setHighlighted(item, false);
|
||
}
|
||
}
|
||
|
||
this._firstSelection = item;
|
||
this._lastSelection = null;
|
||
this._selectionInitialValue = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle doing a radio-based selection toggle on a list.
|
||
* @param item List item.
|
||
* @param evt Click event.
|
||
*/
|
||
handleSelectClickRadio (item, evt) {
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
|
||
this._allItems.forEach(itemOther => {
|
||
const cb = this._getCb(itemOther);
|
||
|
||
if (itemOther === item) {
|
||
// Setting this to true *should* cause the browser to update the rest for us, but since list items can
|
||
// be filtered/hidden, the browser won't necessarily update them all. Therefore, forcibly set
|
||
// `checked = false` below.
|
||
cb.checked = true;
|
||
this._setHighlighted(itemOther, true);
|
||
} else {
|
||
cb.checked = false;
|
||
this._setHighlighted(itemOther, false);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
globalThis.ListSelectClickHandlerBase = ListSelectClickHandlerBase;
|
||
|
||
class ListSelectClickHandler extends ListSelectClickHandlerBase {
|
||
constructor ({list}) {
|
||
super();
|
||
this._list = list;
|
||
}
|
||
|
||
get _visibleItems () { return this._list.visibleItems; }
|
||
|
||
get _allItems () { return this._list.items; }
|
||
|
||
_getCb (item, opts = {}) { return opts.fnGetCb ? opts.fnGetCb(item) : item.data.cbSel; }
|
||
|
||
_setCheckbox (item, opts = {}) { return this.setCheckbox(item, opts); }
|
||
|
||
_setHighlighted (item, isHighlighted) {
|
||
if (isHighlighted) item.ele instanceof $ ? item.ele.addClass("list-multi-selected") : item.ele.classList.add("list-multi-selected");
|
||
else item.ele instanceof $ ? item.ele.removeClass("list-multi-selected") : item.ele.classList.remove("list-multi-selected");
|
||
}
|
||
|
||
/* -------------------------------------------- */
|
||
|
||
setCheckbox (item, {fnGetCb, fnOnSelectionChange, isNoHighlightSelection, toVal = true} = {}) {
|
||
const cbSlave = this._getCb(item, {fnGetCb, fnOnSelectionChange, isNoHighlightSelection});
|
||
|
||
if (cbSlave?.disabled) return;
|
||
|
||
if (cbSlave) {
|
||
cbSlave.checked = toVal;
|
||
if (fnOnSelectionChange) fnOnSelectionChange(item, toVal);
|
||
}
|
||
|
||
if (isNoHighlightSelection) return;
|
||
|
||
this._setHighlighted(item, toVal);
|
||
}
|
||
|
||
/**
|
||
* (Public method for Plutonium use)
|
||
*/
|
||
bindSelectAllCheckbox ($cbAll) {
|
||
$cbAll.change(() => {
|
||
const isChecked = $cbAll.prop("checked");
|
||
this.setCheckboxes({isChecked});
|
||
});
|
||
}
|
||
|
||
setCheckboxes ({isChecked, isIncludeHidden}) {
|
||
(isIncludeHidden ? this._list.items : this._list.visibleItems)
|
||
.forEach(item => {
|
||
if (item.data.cbSel?.disabled) return;
|
||
|
||
if (item.data.cbSel) item.data.cbSel.checked = isChecked;
|
||
|
||
this._setHighlighted(item, isChecked);
|
||
});
|
||
}
|
||
}
|
||
|
||
globalThis.ListSelectClickHandler = ListSelectClickHandler;
|
||
|
||
class ListUiUtil {
|
||
static bindPreviewButton (page, allData, item, btnShowHidePreview, {$fnGetPreviewStats} = {}) {
|
||
btnShowHidePreview.addEventListener("click", evt => {
|
||
const entity = allData[item.ix];
|
||
page = page || entity?.__prop;
|
||
|
||
const elePreviewWrp = this.getOrAddListItemPreviewLazy(item);
|
||
|
||
this.handleClickBtnShowHideListPreview(evt, page, entity, btnShowHidePreview, elePreviewWrp, {$fnGetPreviewStats});
|
||
});
|
||
}
|
||
|
||
static handleClickBtnShowHideListPreview (evt, page, entity, btnShowHidePreview, elePreviewWrp, {nxtText = null, $fnGetPreviewStats} = {}) {
|
||
evt.stopPropagation();
|
||
evt.preventDefault();
|
||
|
||
nxtText = nxtText ?? btnShowHidePreview.innerHTML.trim() === this.HTML_GLYPHICON_EXPAND ? this.HTML_GLYPHICON_CONTRACT : this.HTML_GLYPHICON_EXPAND;
|
||
const isHidden = nxtText === this.HTML_GLYPHICON_EXPAND;
|
||
const isFluff = !!evt.shiftKey;
|
||
|
||
elePreviewWrp.classList.toggle("ve-hidden", isHidden);
|
||
btnShowHidePreview.innerHTML = nxtText;
|
||
|
||
const elePreviewWrpInner = elePreviewWrp.lastElementChild;
|
||
|
||
const isForce = (elePreviewWrp.dataset.dataType === "stats" && isFluff) || (elePreviewWrp.dataset.dataType === "fluff" && !isFluff);
|
||
if (!isForce && elePreviewWrpInner.innerHTML) return;
|
||
|
||
$(elePreviewWrpInner).empty().off("click").on("click", evt => { evt.stopPropagation(); });
|
||
|
||
if (isHidden) return;
|
||
|
||
elePreviewWrp.dataset.dataType = isFluff ? "fluff" : "stats";
|
||
|
||
const doAppendStatView = () => ($fnGetPreviewStats || Renderer.hover.$getHoverContent_stats)(page, entity, {isStatic: true}).appendTo(elePreviewWrpInner);
|
||
|
||
if (!evt.shiftKey || !UrlUtil.URL_TO_HASH_BUILDER[page]) {
|
||
doAppendStatView();
|
||
return;
|
||
}
|
||
|
||
Renderer.hover.pGetHoverableFluff(page, entity.source, UrlUtil.URL_TO_HASH_BUILDER[page](entity))
|
||
.then(fluffEntity => {
|
||
// Avoid clobbering existing elements, as other events might have updated the preview area while we were
|
||
// loading the fluff.
|
||
if (elePreviewWrpInner.innerHTML) return;
|
||
|
||
if (!fluffEntity) return doAppendStatView();
|
||
Renderer.hover.$getHoverContent_fluff(page, fluffEntity).appendTo(elePreviewWrpInner);
|
||
});
|
||
}
|
||
|
||
static getOrAddListItemPreviewLazy (item) {
|
||
// We lazily add the preview UI, to mitigate rendering performance issues
|
||
let elePreviewWrp;
|
||
if (item.ele.children.length === 1) {
|
||
elePreviewWrp = e_({
|
||
ag: "div",
|
||
clazz: "ve-hidden ve-flex",
|
||
children: [
|
||
e_({tag: "div", clazz: "col-0-5"}),
|
||
e_({tag: "div", clazz: "col-11-5 ui-list__wrp-preview py-2 pr-2"}),
|
||
],
|
||
}).appendTo(item.ele);
|
||
} else elePreviewWrp = item.ele.lastElementChild;
|
||
return elePreviewWrp;
|
||
}
|
||
|
||
static bindPreviewAllButton ($btnAll, list) {
|
||
$btnAll
|
||
.click(async () => {
|
||
const nxtHtml = $btnAll.html() === ListUiUtil.HTML_GLYPHICON_EXPAND
|
||
? ListUiUtil.HTML_GLYPHICON_CONTRACT
|
||
: ListUiUtil.HTML_GLYPHICON_EXPAND;
|
||
|
||
if (nxtHtml === ListUiUtil.HTML_GLYPHICON_CONTRACT && list.visibleItems.length > 500) {
|
||
const isSure = await InputUiUtil.pGetUserBoolean({
|
||
title: "Are You Sure?",
|
||
htmlDescription: `You are about to expand ${list.visibleItems.length} rows. This may seriously degrade performance.<br>Are you sure you want to continue?`,
|
||
});
|
||
if (!isSure) return;
|
||
}
|
||
|
||
$btnAll.html(nxtHtml);
|
||
|
||
list.visibleItems.forEach(listItem => {
|
||
if (listItem.data.btnShowHidePreview.innerHTML !== nxtHtml) listItem.data.btnShowHidePreview.click();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ==================
|
||
|
||
static ListSyntax = class {
|
||
static _READONLY_WALKER = null;
|
||
|
||
constructor (
|
||
{
|
||
fnGetDataList,
|
||
pFnGetFluff,
|
||
},
|
||
) {
|
||
this._fnGetDataList = fnGetDataList;
|
||
this._pFnGetFluff = pFnGetFluff;
|
||
}
|
||
|
||
get _dataList () { return this._fnGetDataList(); }
|
||
|
||
build () {
|
||
return {
|
||
stats: {
|
||
help: `"stats:<text>" ("/text/" for regex) to search within stat blocks.`,
|
||
fn: (listItem, searchTerm) => {
|
||
if (listItem.data._textCacheStats == null) listItem.data._textCacheStats = this._getSearchCacheStats(this._dataList[listItem.ix]);
|
||
return this._listSyntax_isTextMatch(listItem.data._textCacheStats, searchTerm);
|
||
},
|
||
},
|
||
info: {
|
||
help: `"info:<text>" ("/text/" for regex) to search within info.`,
|
||
fn: async (listItem, searchTerm) => {
|
||
if (listItem.data._textCacheFluff == null) listItem.data._textCacheFluff = await this._pGetSearchCacheFluff(this._dataList[listItem.ix]);
|
||
return this._listSyntax_isTextMatch(listItem.data._textCacheFluff, searchTerm);
|
||
},
|
||
isAsync: true,
|
||
},
|
||
text: {
|
||
help: `"text:<text>" ("/text/" for regex) to search within stat blocks plus info.`,
|
||
fn: async (listItem, searchTerm) => {
|
||
if (listItem.data._textCacheAll == null) {
|
||
const {textCacheStats, textCacheFluff, textCacheAll} = await this._pGetSearchCacheAll(this._dataList[listItem.ix], {textCacheStats: listItem.data._textCacheStats, textCacheFluff: listItem.data._textCacheFluff});
|
||
listItem.data._textCacheStats = listItem.data._textCacheStats || textCacheStats;
|
||
listItem.data._textCacheFluff = listItem.data._textCacheFluff || textCacheFluff;
|
||
listItem.data._textCacheAll = textCacheAll;
|
||
}
|
||
return this._listSyntax_isTextMatch(listItem.data._textCacheAll, searchTerm);
|
||
},
|
||
isAsync: true,
|
||
},
|
||
};
|
||
}
|
||
|
||
_listSyntax_isTextMatch (str, searchTerm) {
|
||
if (!str) return false;
|
||
if (searchTerm instanceof RegExp) return searchTerm.test(str);
|
||
return str.includes(searchTerm);
|
||
}
|
||
|
||
// TODO(Future) the ideal solution to this is to render every entity to plain text (or failing that, Markdown) and
|
||
// indexing that text with e.g. elasticlunr.
|
||
_getSearchCacheStats (entity) {
|
||
return this._getSearchCache_entries(entity);
|
||
}
|
||
|
||
static _INDEXABLE_PROPS_ENTRIES = [
|
||
"entries",
|
||
];
|
||
|
||
_getSearchCache_entries (entity, {indexableProps = null} = {}) {
|
||
if ((indexableProps || this.constructor._INDEXABLE_PROPS_ENTRIES).every(it => !entity[it])) return "";
|
||
const ptrOut = {_: ""};
|
||
(indexableProps || this.constructor._INDEXABLE_PROPS_ENTRIES).forEach(it => this._getSearchCache_handleEntryProp(entity, it, ptrOut));
|
||
return ptrOut._;
|
||
}
|
||
|
||
_getSearchCache_handleEntryProp (entity, prop, ptrOut) {
|
||
if (!entity[prop]) return;
|
||
|
||
this.constructor._READONLY_WALKER = this.constructor._READONLY_WALKER || MiscUtil.getWalker({
|
||
keyBlocklist: new Set(["type", "colStyles", "style"]),
|
||
isNoModification: true,
|
||
});
|
||
|
||
this.constructor._READONLY_WALKER.walk(
|
||
entity[prop],
|
||
{
|
||
string: (str) => this._getSearchCache_handleString(ptrOut, str),
|
||
},
|
||
);
|
||
}
|
||
|
||
_getSearchCache_handleString (ptrOut, str) {
|
||
ptrOut._ += `${Renderer.stripTags(str).toLowerCase()} -- `;
|
||
}
|
||
|
||
async _pGetSearchCacheFluff (entity) {
|
||
const fluff = this._pFnGetFluff ? await this._pFnGetFluff(entity) : null;
|
||
return fluff ? this._getSearchCache_entries(fluff, {indexableProps: ["entries"]}) : "";
|
||
}
|
||
|
||
async _pGetSearchCacheAll (entity, {textCacheStats = null, textCacheFluff = null}) {
|
||
textCacheStats = textCacheStats || this._getSearchCacheStats(entity);
|
||
textCacheFluff = textCacheFluff || await this._pGetSearchCacheFluff(entity);
|
||
return {
|
||
textCacheStats,
|
||
textCacheFluff,
|
||
textCacheAll: [textCacheStats, textCacheFluff].filter(Boolean).join(" -- "),
|
||
};
|
||
}
|
||
};
|
||
|
||
// ==================
|
||
}
|
||
ListUiUtil.HTML_GLYPHICON_EXPAND = `[+]`;
|
||
ListUiUtil.HTML_GLYPHICON_CONTRACT = `[\u2012]`;
|
||
|
||
globalThis.ListUiUtil = ListUiUtil;
|
||
|
||
class ProfUiUtil {
|
||
/**
|
||
* @param state Initial state.
|
||
* @param [opts] Options object.
|
||
* @param [opts.isSimple] If the cycler only has "not proficient" and "proficient" options
|
||
*/
|
||
static getProfCycler (state = 0, opts) {
|
||
opts = opts || {};
|
||
|
||
const STATES = opts.isSimple ? Object.keys(ProfUiUtil.PROF_TO_FULL).slice(0, 2) : Object.keys(ProfUiUtil.PROF_TO_FULL);
|
||
|
||
const NUM_STATES = Object.keys(STATES).length;
|
||
|
||
// validate initial state
|
||
state = Number(state) || 0;
|
||
if (state >= NUM_STATES) state = NUM_STATES - 1;
|
||
else if (state < 0) state = 0;
|
||
|
||
const $btnCycle = $(`<button class="ui-prof__btn-cycle"></button>`)
|
||
.click(() => {
|
||
$btnCycle
|
||
.attr("data-state", ++state >= NUM_STATES ? state = 0 : state)
|
||
.title(ProfUiUtil.PROF_TO_FULL[state].name)
|
||
.trigger("change");
|
||
})
|
||
.contextmenu(evt => {
|
||
evt.preventDefault();
|
||
$btnCycle
|
||
.attr("data-state", --state < 0 ? state = NUM_STATES - 1 : state)
|
||
.title(ProfUiUtil.PROF_TO_FULL[state].name)
|
||
.trigger("change");
|
||
});
|
||
const setState = (nuState) => {
|
||
state = nuState;
|
||
if (state > NUM_STATES) state = 0;
|
||
else if (state < 0) state = NUM_STATES - 1;
|
||
$btnCycle.attr("data-state", state).title(ProfUiUtil.PROF_TO_FULL[state].name);
|
||
};
|
||
return {
|
||
$ele: $btnCycle,
|
||
setState,
|
||
getState: () => state,
|
||
};
|
||
}
|
||
}
|
||
ProfUiUtil.PROF_TO_FULL = {
|
||
"0": {
|
||
name: "No proficiency",
|
||
mult: 0,
|
||
},
|
||
"1": {
|
||
name: "Proficiency",
|
||
mult: 1,
|
||
},
|
||
"2": {
|
||
name: "Expertise",
|
||
mult: 2,
|
||
},
|
||
"3": {
|
||
name: "Half proficiency",
|
||
mult: 0.5,
|
||
},
|
||
};
|
||
|
||
class TabUiUtilBase {
|
||
static decorate (obj, {isInitMeta = false} = {}) {
|
||
if (isInitMeta) {
|
||
obj.__meta = {};
|
||
obj._meta = obj._getProxy("meta", obj.__meta);
|
||
}
|
||
|
||
obj.__tabState = {};
|
||
|
||
obj._getTabProps = function ({propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP} = {}) {
|
||
return {
|
||
propProxy,
|
||
_propProxy: `_${propProxy}`,
|
||
__propProxy: `__${propProxy}`,
|
||
propActive: `ixActiveTab__${tabGroup}`,
|
||
};
|
||
};
|
||
|
||
/** Render a collection of tabs. */
|
||
obj._renderTabs = function (tabMetas, {$parent, propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP, cbTabChange, additionalClassesWrpHeads} = {}) {
|
||
if (!tabMetas.length) throw new Error(`One or more tab meta must be specified!`);
|
||
obj._resetTabs({tabGroup});
|
||
|
||
const isSingleTab = tabMetas.length === 1;
|
||
|
||
const {propActive, _propProxy, __propProxy} = obj._getTabProps({propProxy, tabGroup});
|
||
|
||
this[__propProxy][propActive] = this[__propProxy][propActive] || 0;
|
||
|
||
const $dispTabTitle = obj.__$getDispTabTitle({isSingleTab});
|
||
|
||
const renderTabMetas_standard = (it, i) => {
|
||
const $btnTab = obj.__$getBtnTab({
|
||
isSingleTab,
|
||
tabMeta: it,
|
||
_propProxy,
|
||
propActive,
|
||
ixTab: i,
|
||
});
|
||
|
||
const $wrpTab = obj.__$getWrpTab({tabMeta: it, ixTab: i});
|
||
|
||
return {
|
||
...it,
|
||
ix: i,
|
||
$btnTab,
|
||
$wrpTab,
|
||
};
|
||
};
|
||
|
||
const tabMetasOut = tabMetas.map((it, i) => {
|
||
if (it.type) return obj.__renderTypedTabMeta({tabMeta: it, ixTab: i});
|
||
return renderTabMetas_standard(it, i);
|
||
}).filter(Boolean);
|
||
|
||
if ($parent) obj.__renderTabs_addToParent({$dispTabTitle, $parent, tabMetasOut, additionalClassesWrpHeads});
|
||
|
||
const hkActiveTab = () => {
|
||
tabMetasOut.forEach(it => {
|
||
if (it.type) return; // For specially typed tabs (e.g. buttons), do nothing
|
||
|
||
const isActive = it.ix === this[_propProxy][propActive];
|
||
if (isActive && $dispTabTitle) $dispTabTitle.text(isSingleTab ? "" : it.name);
|
||
if (it.$btnTab) it.$btnTab.toggleClass("active", isActive);
|
||
it.$wrpTab.toggleVe(isActive);
|
||
});
|
||
|
||
if (cbTabChange) cbTabChange();
|
||
};
|
||
this._addHook(propProxy, propActive, hkActiveTab);
|
||
hkActiveTab();
|
||
|
||
obj.__tabState[tabGroup] = {
|
||
fnReset: () => {
|
||
this._removeHook(propProxy, propActive, hkActiveTab);
|
||
},
|
||
tabMetasOut,
|
||
};
|
||
|
||
return tabMetasOut;
|
||
};
|
||
|
||
obj.__renderTabs_addToParent = function ({$dispTabTitle, $parent, tabMetasOut, additionalClassesWrpHeads}) {
|
||
const hasBorder = tabMetasOut.some(it => it.hasBorder);
|
||
$$`<div class="ve-flex-col w-100 h-100">
|
||
${$dispTabTitle}
|
||
<div class="ve-flex-col w-100 h-100 min-h-0">
|
||
<div class="ve-flex ${hasBorder ? `ui-tab__wrp-tab-heads--border` : ""} ${additionalClassesWrpHeads || ""}">${tabMetasOut.map(it => it.$btnTab)}</div>
|
||
<div class="ve-flex w-100 h-100 min-h-0">${tabMetasOut.map(it => it.$wrpTab).filter(Boolean)}</div>
|
||
</div>
|
||
</div>`.appendTo($parent);
|
||
};
|
||
|
||
obj._resetTabs = function ({tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP} = {}) {
|
||
if (!obj.__tabState[tabGroup]) return;
|
||
obj.__tabState[tabGroup].fnReset();
|
||
delete obj.__tabState[tabGroup];
|
||
};
|
||
|
||
obj._hasPrevTab = function ({propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP} = {}) {
|
||
return obj.__hasTab({propProxy, tabGroup, offset: -1});
|
||
};
|
||
obj._hasNextTab = function ({propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP} = {}) {
|
||
return obj.__hasTab({propProxy, tabGroup, offset: 1});
|
||
};
|
||
|
||
obj.__hasTab = function ({propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP, offset}) {
|
||
const {propActive, _propProxy} = obj._getTabProps({propProxy, tabGroup});
|
||
const ixActive = obj[_propProxy][propActive];
|
||
return !!(obj.__tabState[tabGroup]?.tabMetasOut && obj.__tabState[tabGroup]?.tabMetasOut[ixActive + offset]);
|
||
};
|
||
|
||
obj._doSwitchToPrevTab = function ({propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP} = {}) {
|
||
return obj.__doSwitchToTab({propProxy, tabGroup, offset: -1});
|
||
};
|
||
obj._doSwitchToNextTab = function ({propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP} = {}) {
|
||
return obj.__doSwitchToTab({propProxy, tabGroup, offset: 1});
|
||
};
|
||
|
||
obj.__doSwitchToTab = function ({propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP, offset}) {
|
||
if (!obj.__hasTab({propProxy, tabGroup, offset})) return;
|
||
const {propActive, _propProxy} = obj._getTabProps({propProxy, tabGroup});
|
||
obj[_propProxy][propActive] = obj[_propProxy][propActive] + offset;
|
||
};
|
||
|
||
obj._addHookActiveTab = function (hook, {propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP} = {}) {
|
||
const {propActive} = obj._getTabProps({propProxy, tabGroup});
|
||
this._addHook(propProxy, propActive, hook);
|
||
};
|
||
|
||
obj._getIxActiveTab = function ({propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP} = {}) {
|
||
const {propActive, _propProxy} = obj._getTabProps({propProxy, tabGroup});
|
||
return obj[_propProxy][propActive];
|
||
};
|
||
|
||
obj._setIxActiveTab = function ({propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP, ixActiveTab} = {}) {
|
||
const {propActive, _propProxy} = obj._getTabProps({propProxy, tabGroup});
|
||
obj[_propProxy][propActive] = ixActiveTab;
|
||
};
|
||
|
||
obj._getActiveTab = function ({propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP} = {}) {
|
||
const tabState = obj.__tabState[tabGroup];
|
||
const ixActiveTab = obj._getIxActiveTab({propProxy, tabGroup});
|
||
return tabState.tabMetasOut[ixActiveTab];
|
||
};
|
||
|
||
obj._setActiveTab = function ({propProxy = TabUiUtilBase._DEFAULT_PROP_PROXY, tabGroup = TabUiUtilBase._DEFAULT_TAB_GROUP, tab}) {
|
||
const tabState = obj.__tabState[tabGroup];
|
||
const ix = tabState.tabMetasOut.indexOf(tab);
|
||
obj._setIxActiveTab({propProxy, tabGroup, ixActiveTab: ix});
|
||
};
|
||
|
||
obj.__$getBtnTab = function () { throw new Error("Unimplemented!"); };
|
||
obj.__$getWrpTab = function () { throw new Error("Unimplemented!"); };
|
||
obj.__renderTypedTabMeta = function () { throw new Error("Unimplemented!"); };
|
||
obj.__$getDispTabTitle = function () { throw new Error("Unimplemented!"); };
|
||
}
|
||
}
|
||
TabUiUtilBase._DEFAULT_TAB_GROUP = "_default";
|
||
TabUiUtilBase._DEFAULT_PROP_PROXY = "meta";
|
||
|
||
TabUiUtilBase.TabMeta = class {
|
||
constructor ({name, icon = null, type = null, buttons = null} = {}) {
|
||
this.name = name;
|
||
this.icon = icon;
|
||
this.type = type;
|
||
this.buttons = buttons;
|
||
}
|
||
};
|
||
|
||
class TabUiUtil extends TabUiUtilBase {
|
||
static decorate (obj, {isInitMeta = false} = {}) {
|
||
super.decorate(obj, {isInitMeta});
|
||
|
||
obj.__$getBtnTab = function ({tabMeta, _propProxy, propActive, ixTab}) {
|
||
return $(`<button class="btn btn-default ui-tab__btn-tab-head ${tabMeta.isHeadHidden ? "ve-hidden" : ""}">${tabMeta.name.qq()}</button>`)
|
||
.click(() => obj[_propProxy][propActive] = ixTab);
|
||
};
|
||
|
||
obj.__$getWrpTab = function ({tabMeta}) {
|
||
return $(`<div class="ui-tab__wrp-tab-body ve-flex-col ve-hidden ${tabMeta.hasBorder ? "ui-tab__wrp-tab-body--border" : ""} ${tabMeta.hasBackground ? "ui-tab__wrp-tab-body--background" : ""}"></div>`);
|
||
};
|
||
|
||
obj.__renderTypedTabMeta = function ({tabMeta, ixTab}) {
|
||
switch (tabMeta.type) {
|
||
case "buttons": return obj.__renderTypedTabMeta_buttons({tabMeta, ixTab});
|
||
default: throw new Error(`Unhandled tab type "${tabMeta.type}"`);
|
||
}
|
||
};
|
||
|
||
obj.__renderTypedTabMeta_buttons = function ({tabMeta, ixTab}) {
|
||
const $btns = tabMeta.buttons.map((meta, j) => {
|
||
const $btn = $(`<button class="btn ui-tab__btn-tab-head ${meta.type ? `btn-${meta.type}` : "btn-primary"}" ${meta.title ? `title="${meta.title.qq()}"` : ""}>${meta.html}</button>`)
|
||
.click(evt => meta.pFnClick(evt, $btn));
|
||
return $btn;
|
||
});
|
||
|
||
const $btnTab = $$`<div class="btn-group ve-flex-v-right ve-flex-h-right ml-2 w-100">${$btns}</div>`;
|
||
|
||
return {
|
||
...tabMeta,
|
||
ix: ixTab,
|
||
$btns,
|
||
$btnTab,
|
||
};
|
||
};
|
||
|
||
obj.__$getDispTabTitle = function () { return null; };
|
||
}
|
||
}
|
||
|
||
globalThis.TabUiUtil = TabUiUtil;
|
||
|
||
TabUiUtil.TabMeta = class extends TabUiUtilBase.TabMeta {
|
||
constructor (opts) {
|
||
super(opts);
|
||
this.hasBorder = !!opts.hasBorder;
|
||
this.hasBackground = !!opts.hasBackground;
|
||
this.isHeadHidden = !!opts.isHeadHidden;
|
||
this.isNoPadding = !!opts.isNoPadding;
|
||
}
|
||
};
|
||
|
||
class TabUiUtilSide extends TabUiUtilBase {
|
||
static decorate (obj, {isInitMeta = false} = {}) {
|
||
super.decorate(obj, {isInitMeta});
|
||
|
||
obj.__$getBtnTab = function ({isSingleTab, tabMeta, _propProxy, propActive, ixTab}) {
|
||
return isSingleTab ? null : $(`<button class="btn btn-default btn-sm ui-tab-side__btn-tab mb-2 br-0 btr-0 bbr-0 text-left ve-flex-v-center" title="${tabMeta.name.qq()}"><div class="${tabMeta.icon} ui-tab-side__icon-tab mr-2 mobile-ish__mr-0 ve-text-center"></div><div class="mobile-ish__hidden">${tabMeta.name.qq()}</div></button>`)
|
||
.click(() => this[_propProxy][propActive] = ixTab);
|
||
};
|
||
|
||
obj.__$getWrpTab = function ({tabMeta}) {
|
||
return $(`<div class="ve-flex-col w-100 h-100 ui-tab-side__wrp-tab ${tabMeta.isNoPadding ? "" : "px-3 py-2"} overflow-y-auto"></div>`);
|
||
};
|
||
|
||
obj.__renderTabs_addToParent = function ({$dispTabTitle, $parent, tabMetasOut}) {
|
||
$$`<div class="ve-flex-col w-100 h-100">
|
||
${$dispTabTitle}
|
||
<div class="ve-flex w-100 h-100 min-h-0">
|
||
<div class="ve-flex-col h-100 pt-2">${tabMetasOut.map(it => it.$btnTab)}</div>
|
||
<div class="ve-flex-col w-100 h-100 min-w-0">${tabMetasOut.map(it => it.$wrpTab).filter(Boolean)}</div>
|
||
</div>
|
||
</div>`.appendTo($parent);
|
||
};
|
||
|
||
obj.__renderTypedTabMeta = function ({tabMeta, ixTab}) {
|
||
switch (tabMeta.type) {
|
||
case "buttons": return obj.__renderTypedTabMeta_buttons({tabMeta, ixTab});
|
||
default: throw new Error(`Unhandled tab type "${tabMeta.type}"`);
|
||
}
|
||
};
|
||
|
||
obj.__renderTypedTabMeta_buttons = function ({tabMeta, ixTab}) {
|
||
const $btns = tabMeta.buttons.map((meta, j) => {
|
||
const $btn = $(`<button class="btn ${meta.type ? `btn-${meta.type}` : "btn-primary"} btn-sm" ${meta.title ? `title="${meta.title.qq()}"` : ""}>${meta.html}</button>`)
|
||
.click(evt => meta.pFnClick(evt, $btn));
|
||
|
||
if (j === tabMeta.buttons.length - 1) $btn.addClass(`br-0 btr-0 bbr-0`);
|
||
|
||
return $btn;
|
||
});
|
||
|
||
const $btnTab = $$`<div class="btn-group ve-flex-v-center ve-flex-h-right mb-2">${$btns}</div>`;
|
||
|
||
return {
|
||
...tabMeta,
|
||
ix: ixTab,
|
||
$btnTab,
|
||
};
|
||
};
|
||
|
||
obj.__$getDispTabTitle = function ({isSingleTab}) {
|
||
return $(`<div class="ui-tab-side__disp-active-tab-name ${isSingleTab ? `ui-tab-side__disp-active-tab-name--single` : ""} bold"></div>`);
|
||
};
|
||
}
|
||
}
|
||
|
||
globalThis.TabUiUtilSide = TabUiUtilSide;
|
||
|
||
// TODO have this respect the blocklist?
|
||
class SearchUiUtil {
|
||
static async pDoGlobalInit () {
|
||
elasticlunr.clearStopWords();
|
||
await Renderer.item.pPopulatePropertyAndTypeReference();
|
||
}
|
||
|
||
static _isNoHoverCat (cat) {
|
||
return SearchUiUtil.NO_HOVER_CATEGORIES.has(cat);
|
||
}
|
||
|
||
static async pGetContentIndices (options) {
|
||
options = options || {};
|
||
|
||
const availContent = {};
|
||
|
||
const [searchIndexRaw] = await Promise.all([
|
||
DataUtil.loadJSON(`${Renderer.get().baseUrl}search/index.json`),
|
||
ExcludeUtil.pInitialise(),
|
||
]);
|
||
|
||
const data = Omnidexer.decompressIndex(searchIndexRaw);
|
||
|
||
const additionalData = {};
|
||
if (options.additionalIndices) {
|
||
await Promise.all(options.additionalIndices.map(async add => {
|
||
additionalData[add] = Omnidexer.decompressIndex(await DataUtil.loadJSON(`${Renderer.get().baseUrl}search/index-${add}.json`));
|
||
const maxId = additionalData[add].last().id;
|
||
|
||
const prereleaseIndex = await PrereleaseUtil.pGetAdditionalSearchIndices(maxId, add);
|
||
if (prereleaseIndex.length) additionalData[add] = additionalData[add].concat(prereleaseIndex);
|
||
|
||
const brewIndex = await BrewUtil2.pGetAdditionalSearchIndices(maxId, add);
|
||
if (brewIndex.length) additionalData[add] = additionalData[add].concat(brewIndex);
|
||
}));
|
||
}
|
||
|
||
const alternateData = {};
|
||
if (options.alternateIndices) {
|
||
await Promise.all(options.alternateIndices.map(async alt => {
|
||
alternateData[alt] = Omnidexer.decompressIndex(await DataUtil.loadJSON(`${Renderer.get().baseUrl}search/index-alt-${alt}.json`));
|
||
const maxId = alternateData[alt].last().id;
|
||
|
||
const prereleaseIndex = await BrewUtil2.pGetAlternateSearchIndices(maxId, alt);
|
||
if (prereleaseIndex.length) alternateData[alt] = alternateData[alt].concat(prereleaseIndex);
|
||
|
||
const brewIndex = await BrewUtil2.pGetAlternateSearchIndices(maxId, alt);
|
||
if (brewIndex.length) alternateData[alt] = alternateData[alt].concat(brewIndex);
|
||
}));
|
||
}
|
||
|
||
const fromDeepIndex = (d) => d.d; // flag for "deep indexed" content that refers to the same item
|
||
|
||
availContent.ALL = elasticlunr(function () {
|
||
this.addField("n");
|
||
this.addField("s");
|
||
this.setRef("id");
|
||
});
|
||
SearchUtil.removeStemmer(availContent.ALL);
|
||
|
||
// Add main site index
|
||
let ixMax = 0;
|
||
|
||
const initIndexForFullCat = (doc) => {
|
||
if (!availContent[doc.cf]) {
|
||
availContent[doc.cf] = elasticlunr(function () {
|
||
this.addField("n");
|
||
this.addField("s");
|
||
this.setRef("id");
|
||
});
|
||
SearchUtil.removeStemmer(availContent[doc.cf]);
|
||
}
|
||
};
|
||
|
||
const handleDataItem = (d, isAlternate) => {
|
||
if (
|
||
SearchUiUtil._isNoHoverCat(d.c)
|
||
|| fromDeepIndex(d)
|
||
|| ExcludeUtil.isExcluded(d.u, Parser.pageCategoryToProp(d.c), d.s, {isNoCount: true})
|
||
) return;
|
||
d.cf = d.c === Parser.CAT_ID_CREATURE ? "Creature" : Parser.pageCategoryToFull(d.c);
|
||
if (isAlternate) d.cf = `alt_${d.cf}`;
|
||
initIndexForFullCat(d);
|
||
if (!isAlternate) availContent.ALL.addDoc(d);
|
||
availContent[d.cf].addDoc(d);
|
||
ixMax = Math.max(ixMax, d.id);
|
||
};
|
||
|
||
data.forEach(d => handleDataItem(d));
|
||
Object.values(additionalData).forEach(arr => arr.forEach(d => handleDataItem(d)));
|
||
Object.values(alternateData).forEach(arr => arr.forEach(d => handleDataItem(d, true)));
|
||
|
||
const pAddPrereleaseBrewIndex = async ({brewUtil}) => {
|
||
const brewIndex = await brewUtil.pGetSearchIndex({id: availContent.ALL.documentStore.length});
|
||
|
||
brewIndex.forEach(d => {
|
||
if (SearchUiUtil._isNoHoverCat(d.c) || fromDeepIndex(d)) return;
|
||
d.cf = Parser.pageCategoryToFull(d.c);
|
||
d.cf = d.c === Parser.CAT_ID_CREATURE ? "Creature" : Parser.pageCategoryToFull(d.c);
|
||
initIndexForFullCat(d);
|
||
availContent.ALL.addDoc(d);
|
||
availContent[d.cf].addDoc(d);
|
||
});
|
||
};
|
||
|
||
await pAddPrereleaseBrewIndex({brewUtil: PrereleaseUtil});
|
||
await pAddPrereleaseBrewIndex({brewUtil: BrewUtil2});
|
||
|
||
return availContent;
|
||
}
|
||
}
|
||
SearchUiUtil.NO_HOVER_CATEGORIES = new Set([
|
||
Parser.CAT_ID_ADVENTURE,
|
||
Parser.CAT_ID_BOOK,
|
||
Parser.CAT_ID_QUICKREF,
|
||
Parser.CAT_ID_PAGE,
|
||
]);
|
||
|
||
// based on DM screen's AddMenuSearchTab
|
||
class SearchWidget {
|
||
static getSearchNoResults () {
|
||
return `<div class="ui-search__message"><i>No results.</i></div>`;
|
||
}
|
||
|
||
static getSearchLoading () {
|
||
return `<div class="ui-search__message"><i>\u2022\u2022\u2022</i></div>`;
|
||
}
|
||
|
||
static getSearchEnter () {
|
||
return `<div class="ui-search__message"><i>Enter a search.</i></div>`;
|
||
}
|
||
|
||
/**
|
||
* @param $iptSearch input element
|
||
* @param opts Options object.
|
||
* @param opts.fnSearch Function which runs the search.
|
||
* @param opts.fnShowWait Function which displays loading dots
|
||
* @param opts.flags Flags object; modified during user interaction.
|
||
* @param opts.flags.isWait Flag tracking "waiting for user to stop typing"
|
||
* @param opts.flags.doClickFirst Flag tracking "should first result get clicked"
|
||
* @param opts.flags.doClickFirst Flag tracking "should first result get clicked"
|
||
* @param opts.$ptrRows Pointer to array of rows.
|
||
*/
|
||
static bindAutoSearch ($iptSearch, opts) {
|
||
UiUtil.bindTypingEnd({
|
||
$ipt: $iptSearch,
|
||
fnKeyup: evt => {
|
||
if (evt.type === "blur") return;
|
||
|
||
// Handled in `fnKeydown`
|
||
switch (evt.key) {
|
||
case "ArrowDown": {
|
||
evt.preventDefault();
|
||
return;
|
||
}
|
||
case "Enter": return;
|
||
}
|
||
|
||
opts.fnSearch && opts.fnSearch();
|
||
},
|
||
fnKeypress: evt => {
|
||
switch (evt.key) {
|
||
case "ArrowDown": {
|
||
evt.preventDefault();
|
||
return;
|
||
}
|
||
case "Enter": {
|
||
opts.flags.doClickFirst = true;
|
||
opts.fnSearch && opts.fnSearch();
|
||
}
|
||
}
|
||
},
|
||
fnKeydown: evt => {
|
||
if (opts.flags.isWait) {
|
||
opts.flags.isWait = false;
|
||
opts.fnShowWait && opts.fnShowWait();
|
||
return;
|
||
}
|
||
|
||
switch (evt.key) {
|
||
case "ArrowDown": {
|
||
if (opts.$ptrRows && opts.$ptrRows._[0]) {
|
||
evt.stopPropagation();
|
||
evt.preventDefault();
|
||
opts.$ptrRows._[0][0].focus();
|
||
}
|
||
break;
|
||
}
|
||
case "Enter": {
|
||
if (opts.$ptrRows && opts.$ptrRows._[0]) {
|
||
evt.preventDefault();
|
||
opts.$ptrRows._[0].click();
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
},
|
||
fnClick: () => {
|
||
if (opts.fnSearch && $iptSearch.val() && $iptSearch.val().trim().length) opts.fnSearch();
|
||
},
|
||
});
|
||
}
|
||
|
||
static bindRowHandlers ({result, $row, $ptrRows, fnHandleClick, $iptSearch}) {
|
||
return $row
|
||
.keydown(evt => {
|
||
switch (evt.which) {
|
||
case 13: { // enter
|
||
return fnHandleClick(result);
|
||
}
|
||
case 38: { // up
|
||
evt.preventDefault();
|
||
const ixRow = $ptrRows._.indexOf($row);
|
||
const $prev = $ptrRows._[ixRow - 1];
|
||
if ($prev) $prev.focus();
|
||
else $iptSearch.focus();
|
||
break;
|
||
}
|
||
case 40: { // down
|
||
evt.preventDefault();
|
||
const ixRow = $ptrRows._.indexOf($row);
|
||
const $nxt = $ptrRows._[ixRow + 1];
|
||
if ($nxt) $nxt.focus();
|
||
break;
|
||
}
|
||
}
|
||
})
|
||
.click(() => fnHandleClick(result));
|
||
}
|
||
|
||
static docToPageSourceHash (doc) {
|
||
const page = UrlUtil.categoryToHoverPage(doc.c);
|
||
const source = doc.s;
|
||
const hash = doc.u;
|
||
|
||
return {page, source, hash};
|
||
}
|
||
|
||
/**
|
||
* @param indexes An object with index names (categories) as the keys, and indexes as the values.
|
||
* @param cbSearch Callback to run on user clicking a search result.
|
||
* @param options Options object.
|
||
* @param options.defaultCategory Default search category.
|
||
* @param options.fnFilterResults Function which takes a document and returns false if it is to be filtered out of the results.
|
||
* @param options.searchOptions Override for default elasticlunr search options.
|
||
* @param options.fnTransform Function which transforms the document before passing it back to cbSearch.
|
||
*/
|
||
constructor (indexes, cbSearch, options) {
|
||
options = options || {};
|
||
|
||
this._indexes = indexes;
|
||
this._cat = options.defaultCategory || "ALL";
|
||
this._cbSearch = cbSearch;
|
||
this._fnFilterResults = options.fnFilterResults || null;
|
||
this._searchOptions = options.searchOptions || null;
|
||
this._fnTransform = options.fnTransform || null;
|
||
|
||
this._flags = {
|
||
doClickFirst: false,
|
||
isWait: false,
|
||
};
|
||
this._$ptrRows = {_: []};
|
||
|
||
this._$selCat = null;
|
||
this._$iptSearch = null;
|
||
this._$wrpResults = null;
|
||
|
||
this._$rendered = null;
|
||
}
|
||
|
||
static pDoGlobalInit () {
|
||
if (!SearchWidget.P_LOADING_CONTENT) {
|
||
SearchWidget.P_LOADING_CONTENT = (async () => {
|
||
Object.assign(SearchWidget.CONTENT_INDICES, await SearchUiUtil.pGetContentIndices({additionalIndices: ["item"], alternateIndices: ["spell"]}));
|
||
})();
|
||
}
|
||
return SearchWidget.P_LOADING_CONTENT;
|
||
}
|
||
|
||
__getSearchOptions () {
|
||
return this._searchOptions || {
|
||
fields: {
|
||
n: {boost: 5, expand: true},
|
||
s: {expand: true},
|
||
},
|
||
bool: "AND",
|
||
expand: true,
|
||
};
|
||
}
|
||
|
||
__$getRow (r) {
|
||
return $(`<div class="ui-search__row" tabindex="0">
|
||
<span>${r.doc.n}</span>
|
||
<span>${r.doc.s ? `<i title="${Parser.sourceJsonToFull(r.doc.s)}">${Parser.sourceJsonToAbv(r.doc.s)}${r.doc.p ? ` p${r.doc.p}` : ""}</i>` : ""}</span>
|
||
</div>`);
|
||
}
|
||
|
||
static __getAllTitle () {
|
||
return "All Categories";
|
||
}
|
||
|
||
static __getCatOptionText (it) {
|
||
return it;
|
||
}
|
||
|
||
get $wrpSearch () {
|
||
if (!this._$rendered) this._render();
|
||
return this._$rendered;
|
||
}
|
||
|
||
__showMsgInputRequired () {
|
||
this._flags.isWait = true;
|
||
this._$wrpResults.empty().append(SearchWidget.getSearchEnter());
|
||
}
|
||
|
||
__showMsgWait () {
|
||
this._$wrpResults.empty().append(SearchWidget.getSearchLoading());
|
||
}
|
||
|
||
__showMsgNoResults () {
|
||
this._flags.isWait = true;
|
||
this._$wrpResults.empty().append(SearchWidget.getSearchNoResults());
|
||
}
|
||
|
||
__doSearch () {
|
||
const searchInput = this._$iptSearch.val().trim();
|
||
|
||
const index = this._indexes[this._cat];
|
||
const results = index.search(searchInput, this.__getSearchOptions());
|
||
|
||
const {toProcess, resultCount} = (() => {
|
||
if (results.length) {
|
||
if (this._fnFilterResults) {
|
||
const filtered = results.filter(it => this._fnFilterResults(it.doc));
|
||
return {
|
||
toProcess: filtered.slice(0, UiUtil.SEARCH_RESULTS_CAP),
|
||
resultCount: filtered.length,
|
||
};
|
||
} else {
|
||
return {
|
||
toProcess: results.slice(0, UiUtil.SEARCH_RESULTS_CAP),
|
||
resultCount: results.length,
|
||
};
|
||
}
|
||
} else {
|
||
// If the user has entered a search and we found nothing, return no results
|
||
if (searchInput.trim()) {
|
||
return {
|
||
toProcess: [],
|
||
resultCount: 0,
|
||
};
|
||
}
|
||
|
||
// Otherwise, we have no search term, so show a default list of results
|
||
if (this._fnFilterResults) {
|
||
const filtered = Object.values(index.documentStore.docs).filter(it => this._fnFilterResults(it)).map(it => ({doc: it}));
|
||
return {
|
||
toProcess: filtered.slice(0, UiUtil.SEARCH_RESULTS_CAP),
|
||
resultCount: filtered.length,
|
||
};
|
||
} else {
|
||
return {
|
||
toProcess: Object.values(index.documentStore.docs).slice(0, UiUtil.SEARCH_RESULTS_CAP).map(it => ({doc: it})),
|
||
resultCount: Object.values(index.documentStore.docs).length,
|
||
};
|
||
}
|
||
}
|
||
})();
|
||
|
||
this._$wrpResults.empty();
|
||
this._$ptrRows._ = [];
|
||
|
||
if (resultCount) {
|
||
const handleClick = (r) => {
|
||
if (this._fnTransform) this._cbSearch(this._fnTransform(r.doc));
|
||
else this._cbSearch(r.doc);
|
||
};
|
||
|
||
if (this._flags.doClickFirst) {
|
||
handleClick(toProcess[0]);
|
||
this._flags.doClickFirst = false;
|
||
return;
|
||
}
|
||
|
||
const res = toProcess.slice(0, UiUtil.SEARCH_RESULTS_CAP);
|
||
|
||
res.forEach(r => {
|
||
const $row = this.__$getRow(r).appendTo(this._$wrpResults);
|
||
SearchWidget.bindRowHandlers({result: r, $row, $ptrRows: this._$ptrRows, fnHandleClick: handleClick, $iptSearch: this._$iptSearch});
|
||
this._$ptrRows._.push($row);
|
||
});
|
||
|
||
if (resultCount > UiUtil.SEARCH_RESULTS_CAP) {
|
||
const diff = resultCount - UiUtil.SEARCH_RESULTS_CAP;
|
||
this._$wrpResults.append(`<div class="ui-search__row ui-search__row--readonly">...${diff} more result${diff === 1 ? " was" : "s were"} hidden. Refine your search!</div>`);
|
||
}
|
||
} else {
|
||
if (!searchInput.trim()) this.__showMsgInputRequired();
|
||
else this.__showMsgNoResults();
|
||
}
|
||
}
|
||
|
||
_render () {
|
||
if (!this._$rendered) {
|
||
this._$rendered = $(`<div class="ui-search__wrp-output"></div>`);
|
||
const $wrpControls = $(`<div class="ui-search__wrp-controls"></div>`).appendTo(this._$rendered);
|
||
|
||
this._$selCat = $(`<select class="form-control ui-search__sel-category">
|
||
<option value="ALL">${SearchWidget.__getAllTitle()}</option>
|
||
${Object.keys(this._indexes).sort().filter(it => it !== "ALL").map(it => `<option value="${it}">${SearchWidget.__getCatOptionText(it)}</option>`).join("")}
|
||
</select>`)
|
||
.appendTo($wrpControls).toggle(Object.keys(this._indexes).length !== 1)
|
||
.on("change", () => {
|
||
this._cat = this._$selCat.val();
|
||
this.__doSearch();
|
||
});
|
||
|
||
this._$iptSearch = $(`<input class="ui-search__ipt-search search form-control" autocomplete="off" placeholder="Search...">`).appendTo($wrpControls);
|
||
this._$wrpResults = $(`<div class="ui-search__wrp-results"></div>`).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});
|
||
$(`<div class="ve-flex-vh-center w-100 h-100"><span class="dnd-font italic ve-muted">Loading...</span></div>`).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 $(`<button class="btn btn-primary mr-2">${opts.buttonText || "OK"}</button>`)
|
||
.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 $(`<button class="btn btn-default">Cancel</button>`)
|
||
.click(evt => {
|
||
evt.stopPropagation();
|
||
doClose(false);
|
||
});
|
||
}
|
||
|
||
static _$getBtnSkip ({comp = null, opts, doClose}) {
|
||
return !opts.isSkippable ? null : $(`<button class="btn btn-default ml-3">Skip</button>`)
|
||
.click(evt => {
|
||
evt.stopPropagation();
|
||
doClose(VeCt.SYM_UI_SKIP);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @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<number>} 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 = $(`<input class="form-control mb-2 text-right" ${opts.min ? `min="${opts.min}"` : ""} ${opts.max ? `max="${opts.max}"` : ""}>`)
|
||
.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);
|
||
$$`<div class="ve-flex-v-center ve-flex-h-right pb-1 px-1">${$btnOk}${$btnCancel}${$btnSkip}</div>`.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.title] Prompt title.
|
||
* @param [opts.textYesRemember] Text for "yes, and remember" button.
|
||
* @param [opts.textYes] Text for "yes" button.
|
||
* @param [opts.textNo] Text for "no" button.
|
||
* @param [opts.textSkip] Text for "skip" button.
|
||
* @param [opts.htmlDescription] Description HTML for the modal.
|
||
* @param [opts.$eleDescription] Description element for the modal.
|
||
* @param [opts.storageKey] Storage key to use when "remember" options are passed.
|
||
* @param [opts.isGlobal] If the stored setting is global when "remember" options are passed.
|
||
* @param [opts.fnRemember] Custom function to run when saving the "yes and remember" option.
|
||
* @param [opts.isSkippable] If the prompt is skippable.
|
||
* @param [opts.isAlert] If this prompt is just a notification/alert.
|
||
* @return {Promise} A promise which resolves to true/false if the user chose, or null otherwise.
|
||
*/
|
||
static async pGetUserBoolean (opts) {
|
||
opts = opts || {};
|
||
|
||
if (opts.storageKey) {
|
||
const prev = await (opts.isGlobal ? StorageUtil.pGet(opts.storageKey) : StorageUtil.pGetForPage(opts.storageKey));
|
||
if (prev != null) return prev;
|
||
}
|
||
|
||
const $btnTrueRemember = opts.textYesRemember ? $(`<button class="btn btn-primary ve-flex-v-center mr-2"><span class="glyphicon glyphicon-ok mr-2"></span><span>${opts.textYesRemember}</span></button>`)
|
||
.click(() => {
|
||
doClose(true, true);
|
||
if (opts.fnRemember) {
|
||
opts.fnRemember(true);
|
||
} else {
|
||
opts.isGlobal
|
||
? StorageUtil.pSet(opts.storageKey, true)
|
||
: StorageUtil.pSetForPage(opts.storageKey, true);
|
||
}
|
||
}) : null;
|
||
|
||
const $btnTrue = $(`<button class="btn btn-primary ve-flex-v-center mr-3"><span class="glyphicon glyphicon-ok mr-2"></span><span>${opts.textYes || "OK"}</span></button>`)
|
||
.click(evt => {
|
||
evt.stopPropagation();
|
||
doClose(true, true);
|
||
});
|
||
|
||
const $btnFalse = opts.isAlert ? null : $(`<button class="btn btn-default btn-sm ve-flex-v-center"><span class="glyphicon glyphicon-remove mr-2"></span><span>${opts.textNo || "Cancel"}</span></button>`)
|
||
.click(evt => {
|
||
evt.stopPropagation();
|
||
doClose(true, false);
|
||
});
|
||
|
||
const $btnSkip = !opts.isSkippable ? null : $(`<button class="btn btn-default btn-sm ml-3"><span class="glyphicon glyphicon-forward"></span><span>${opts.textSkip || "Skip"}</span></button>`)
|
||
.click(evt => {
|
||
evt.stopPropagation();
|
||
doClose(VeCt.SYM_UI_SKIP);
|
||
});
|
||
|
||
const {$modalInner, doClose, pGetResolved, doAutoResize: doAutoResizeModal} = await InputUiUtil._pGetShowModal({
|
||
title: opts.title || "Choose",
|
||
isMinHeight0: true,
|
||
});
|
||
|
||
if (opts.$eleDescription?.length) $$`<div class="ve-flex w-100 mb-1">${opts.$eleDescription}</div>`.appendTo($modalInner);
|
||
else if (opts.htmlDescription && opts.htmlDescription.trim()) $$`<div class="ve-flex w-100 mb-1">${opts.htmlDescription}</div>`.appendTo($modalInner);
|
||
$$`<div class="ve-flex-v-center ve-flex-h-right py-1 px-1">${$btnTrueRemember}${$btnTrue}${$btnFalse}${$btnSkip}</div>`.appendTo($modalInner);
|
||
|
||
if (doAutoResizeModal) doAutoResizeModal();
|
||
|
||
$btnTrue.focus();
|
||
$btnTrue.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!`); // sanity check
|
||
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 = $(`<select class="form-control mb-2"><option value="-1" disabled>${opts.placeholder || "Select..."}</option></select>`)
|
||
.keydown(async evt => {
|
||
evt.stopPropagation();
|
||
if (evt.key === "Enter") {
|
||
evt.preventDefault();
|
||
doClose(true);
|
||
}
|
||
});
|
||
|
||
if (opts.isAllowNull) $(`<option value="-1"></option>`).text(opts.fnDisplay ? opts.fnDisplay(null, -1) : "(None)").appendTo($selEnum);
|
||
|
||
opts.values.forEach((v, i) => $(`<option value="${i}"></option>`).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);
|
||
$$`<div class="ve-flex-v-center ve-flex-h-right pb-1 px-1">${$btnOk}${$btnCancel}${$btnSkip}</div>`.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) {
|
||
$$`<label class="mb-1">
|
||
${$iptSearch}
|
||
</label>`.appendTo($modalInner);
|
||
}
|
||
$wrpList.appendTo($modalInner);
|
||
$$`<div class="ve-flex-v-center ve-flex-h-right no-shrink pb-1 px-1">${$btnOk}${$btnCancel}${$btnSkip}</div>`.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: "<n>", iconClass: "<c>", buttonClass: "<cs>", buttonClassActive: "<cs>"}`
|
||
* @param opts.title Prompt title.
|
||
* @param opts.default Default selected index.
|
||
* @param [opts.isSkippable] If the prompt is skippable.
|
||
* @return {Promise<number>} 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,
|
||
});
|
||
|
||
$$`<div class="ve-flex ve-flex-wrap ve-flex-h-center mb-2">${opts.values.map((v, i) => {
|
||
const $btn = $$`<div class="m-2 btn ${v.buttonClass || "btn-default"} ui__btn-xxl-square ve-flex-col ve-flex-h-center">
|
||
${v.iconClass ? `<div class="ui-icn__wrp-icon ${v.iconClass} mb-1"></div>` : ""}
|
||
${v.iconContent ? v.iconContent : ""}
|
||
<div class="whitespace-normal w-100">${v.name}</div>
|
||
</div>`
|
||
.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;
|
||
})}</div>`.appendTo($modalInner);
|
||
|
||
const $btnOk = this._$getBtnOk({opts, doClose});
|
||
const $btnCancel = this._$getBtnCancel({opts, doClose});
|
||
const $btnSkip = this._$getBtnSkip({opts, doClose});
|
||
|
||
$$`<div class="ve-flex-v-center ve-flex-h-right pb-1 px-1">${$btnOk}${$btnCancel}${$btnSkip}</div>`.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<String>} 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: `<input class="form-control mb-2" type="text">`,
|
||
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) $$`<div class="ve-flex w-100 mb-1">${opts.$eleDescription}</div>`.appendTo($modalInner);
|
||
else if (opts.htmlDescription && opts.htmlDescription.trim()) $$`<div class="ve-flex w-100 mb-1">${opts.htmlDescription}</div>`.appendTo($modalInner);
|
||
$iptStr.appendTo($modalInner);
|
||
if (opts.$elePost) opts.$elePost.appendTo($modalInner);
|
||
$$`<div class="ve-flex-v-center ve-flex-h-right pb-1 px-1">${$btnOk}${$btnCancel}${$btnSkip}</div>`.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<String>} A promise which resolves to the string if the user entered one, or null otherwise.
|
||
*/
|
||
static async pGetUserText (opts) {
|
||
opts = opts || {};
|
||
|
||
const $iptStr = $(`<textarea class="form-control mb-2 resize-vertical w-100" ${opts.disabled ? "disabled" : ""}></textarea>`)
|
||
.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);
|
||
$$`<div class="ve-flex-v-center ve-flex-h-right pb-1 px-1">${$btnOk}${$btnCancel}${$btnSkip}</div>`.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<String>} A promise which resolves to the color if the user entered one, or null otherwise.
|
||
*/
|
||
static async pGetUserColor (opts) {
|
||
opts = opts || {};
|
||
|
||
const $iptRgb = $(`<input class="form-control mb-2" ${opts.default != null ? `value="${opts.default}"` : ""} type="color">`);
|
||
|
||
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);
|
||
$$`<div class="ve-flex-v-center ve-flex-h-right pb-1 px-1">${$btnOk}${$btnCancel}${$btnSkip}</div>`.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<number>} 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 = $(`<div class="ui-dir__arm"></div>`);
|
||
const handleAngle = () => $arm.css({transform: `rotate(${curAngle + 180}deg)`});
|
||
handleAngle();
|
||
|
||
const $pad = $$`<div class="ui-dir__face">${$arm}</div>`.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(
|
||
$(`<button class="btn btn-default btn-xxs absolute">${steps[i]}</button>`)
|
||
.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 = $$`<div class="ve-flex-vh-center relative">${$btns}${$pad}</div>`
|
||
.css({
|
||
width: CONTROLS_RADIUS * 2,
|
||
height: CONTROLS_RADIUS * 2,
|
||
});
|
||
|
||
return $$`<div class="ve-flex-vh-center">${$wrpInner}</div>`
|
||
.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});
|
||
|
||
$$`<div class="ve-flex-vh-center mb-3">
|
||
${$padOuter || $pad}
|
||
</div>`.appendTo($modalInner);
|
||
$$`<div class="ve-flex-v-center ve-flex-h-right pb-1 px-1">${$btnOk}${$btnCancel}${$btnSkip}</div>`.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<String>} 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: $(`<input class="form-control input-xs form-control--minimal ve-text-center mr-1">`)})
|
||
.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 = $(`<input class="form-control input-xs form-control--minimal ve-text-center">`)
|
||
.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();
|
||
|
||
$$`<div class="ve-flex-vh-center">${$iptNum}<div class="mr-1">d</div>${$selFaces}${$iptBonus}</div>`.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);
|
||
|
||
$$`<div class="ve-flex-v-center ve-flex-h-right pb-1 px-1 mt-2">${$btnOk}${$btnCancel}${$btnSkip}</div>`.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<String>} 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],
|
||
});
|
||
$$`<div class="ve-flex-col w-640p">${slider.$get()}</div>`.appendTo($modalInner);
|
||
|
||
const $btnOk = this._$getBtnOk({opts, doClose});
|
||
const $btnCancel = this._$getBtnCancel({opts, doClose});
|
||
const $btnSkip = this._$getBtnSkip({opts, doClose});
|
||
|
||
$$`<div class="ve-flex-v-center ve-flex-h-right pb-1 px-1">${$btnOk}${$btnCancel}${$btnSkip}</div>`.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 = $(`<div class="ve-flex-col ui-drag__wrp-drag-block"></div>`).appendTo(opts.$parent);
|
||
dragMeta.$dummies = [];
|
||
|
||
const ids = opts.componentsParent[opts.componentsProp].map(it => it.id);
|
||
|
||
ids.forEach(id => {
|
||
const $dummy = $(`<div class="w-100 ${id === opts.componentId ? "ui-drag__wrp-drag-dummy--highlight" : "ui-drag__wrp-drag-dummy--lowlight"}"></div>`)
|
||
.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 $(`<div class="m${opts.marginSide || "l"}-2 ui-drag__patch" title="Drag to Reorder">
|
||
<div class="ui-drag__patch-col"><div>∙</div><div>∙</div><div>∙</div></div>
|
||
<div class="ui-drag__patch-col"><div>∙</div><div>∙</div><div>∙</div></div>
|
||
</div>`).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 = $(`<div class="ve-flex-col ui-drag__wrp-drag-block"></div>`).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 = $(`<div class="no-shrink ${i === ixRow ? "ui-drag__wrp-drag-dummy--highlight" : "ui-drag__wrp-drag-dummy--lowlight"}"></div>`)
|
||
.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 $(`<div class="mr-2 ui-drag__patch" title="Drag to Reorder">
|
||
<div class="ui-drag__patch-col"><div>∙</div><div>∙</div><div>∙</div></div>
|
||
<div class="ui-drag__patch-col"><div>∙</div><div>∙</div><div>∙</div></div>
|
||
</div>`).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 = $(`<input class="form-control ui-source__ipt-named">`)
|
||
.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 = $(`<input class="form-control ui-source__ipt-named">`)
|
||
.keydown(evt => { if (evt.key === "Escape") $iptAbv.blur(); })
|
||
.change(() => {
|
||
$iptAbv.removeClass("form-control--error");
|
||
});
|
||
if (options.source) $iptAbv.val(options.source.abbreviation);
|
||
const $iptJson = $(`<input class="form-control ui-source__ipt-named" ${isEditMode ? "disabled" : ""}>`)
|
||
.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 = $(`<input type="color" class="w-100 b-0">`)
|
||
.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 = $(`<input class="form-control ui-source__ipt-named">`)
|
||
.keydown(evt => { if (evt.key === "Escape") $iptUrl.blur(); });
|
||
if (options.source) $iptUrl.val(options.source.url);
|
||
const $iptAuthors = $(`<input class="form-control ui-source__ipt-named">`)
|
||
.keydown(evt => { if (evt.key === "Escape") $iptAuthors.blur(); });
|
||
if (options.source) $iptAuthors.val((options.source.authors || []).join(", "));
|
||
const $iptConverters = $(`<input class="form-control ui-source__ipt-named">`)
|
||
.keydown(evt => { if (evt.key === "Escape") $iptConverters.blur(); });
|
||
if (options.source) $iptConverters.val((options.source.convertedBy || []).join(", "));
|
||
|
||
const $btnOk = $(`<button class="btn btn-primary">OK</button>`)
|
||
.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
|
||
: $(`<button class="btn btn-default ml-2">Cancel</button>`).click(() => options.cbCancel());
|
||
|
||
const $btnUseExisting = $(`<button class="btn btn-default">Use an Existing Source</button>`)
|
||
.click(() => {
|
||
$stageInitial.hideVe();
|
||
$stageExisting.showVe();
|
||
|
||
// cleanup
|
||
[$iptName, $iptAbv, $iptJson].forEach($ipt => $ipt.removeClass("form-control--error"));
|
||
});
|
||
|
||
const $stageInitial = $$`<div class="h-100 w-100 ve-flex-vh-center"><div class="ve-flex-col">
|
||
<h3 class="ve-text-center">${isEditMode ? "Edit Homebrew Source" : "Add a Homebrew Source"}</h3>
|
||
<div class="ui-source__row mb-2"><div class="col-12 ve-flex-v-center">
|
||
<span class="mr-2 ui-source__name help" title="The name or title for the homebrew you wish to create. This could be the name of a book or PDF; for example, 'Monster Manual'">Title</span>
|
||
${$iptName}
|
||
</div></div>
|
||
<div class="ui-source__row mb-2"><div class="col-12 ve-flex-v-center">
|
||
<span class="mr-2 ui-source__name help" title="An abbreviated form of the title. This will be shown in lists on the site, and in the top-right corner of stat blocks or data entries; for example, 'MM'">Abbreviation</span>
|
||
${$iptAbv}
|
||
</div></div>
|
||
<div class="ui-source__row mb-2"><div class="col-12 ve-flex-v-center">
|
||
<span class="mr-2 ui-source__name help" title="This will be used to identify your homebrew universally, so should be unique to you and you alone">JSON Identifier</span>
|
||
${$iptJson}
|
||
</div></div>
|
||
<div class="ui-source__row mb-2"><div class="col-12 ve-flex-v-center">
|
||
<span class="mr-2 ui-source__name help" title="A color which should be used when displaying the source abbreviation">Color</span>
|
||
${$iptColor}
|
||
</div></div>
|
||
<div class="ui-source__row mb-2"><div class="col-12 ve-flex-v-center">
|
||
<span class="mr-2 ui-source__name help" title="A link to the original homebrew, e.g. a GM Binder page">Source URL</span>
|
||
${$iptUrl}
|
||
</div></div>
|
||
<div class="ui-source__row mb-2"><div class="col-12 ve-flex-v-center">
|
||
<span class="mr-2 ui-source__name help" title="A comma-separated list of authors, e.g. 'John Doe, Joe Bloggs'">Author(s)</span>
|
||
${$iptAuthors}
|
||
</div></div>
|
||
<div class="ui-source__row mb-2"><div class="col-12 ve-flex-v-center">
|
||
<span class="mr-2 ui-source__name help" title="A comma-separated list of people who converted the homebrew to 5etools' format, e.g. 'John Doe, Joe Bloggs'">Converted By</span>
|
||
${$iptConverters}
|
||
</div></div>
|
||
<div class="ve-text-center mb-2">${$btnOk}${$btnCancel}</div>
|
||
|
||
${!isEditMode && BrewUtil2.getMetaLookup("sources")?.length ? $$`<div class="ve-flex-vh-center mb-3 mt-3"><span class="ui-source__divider"></span>or<span class="ui-source__divider"></span></div>
|
||
<div class="ve-flex-vh-center">${$btnUseExisting}</div>` : ""}
|
||
</div></div>`.appendTo(options.$parent);
|
||
|
||
const $selExisting = $$`<select class="form-control input-sm">
|
||
<option disabled>Select</option>
|
||
${(BrewUtil2.getMetaLookup("sources") || []).sort((a, b) => SortUtil.ascSortLower(a.full, b.full)).map(s => `<option value="${s.json.escapeQuotes()}">${s.full.escapeQuotes()}</option>`)}
|
||
</select>`.change(() => $selExisting.removeClass("form-control--error"));
|
||
$selExisting[0].selectedIndex = 0;
|
||
|
||
const $btnConfirmExisting = $(`<button class="btn btn-default btn-sm">Confirm</button>`)
|
||
.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 = $(`<button class="btn btn-default btn-sm mr-2">Back</button>`)
|
||
.click(() => {
|
||
$selExisting[0].selectedIndex = 0;
|
||
$stageExisting.hideVe();
|
||
$stageInitial.showVe();
|
||
});
|
||
|
||
const $stageExisting = $$`<div class="h-100 w-100 ve-flex-vh-center ve-hidden"><div>
|
||
<h3 class="ve-text-center">Select a Homebrew Source</h3>
|
||
<div class="mb-2"><div class="col-12 ve-flex-vh-center">${$selExisting}</div></div>
|
||
<div class="col-12 ve-flex-vh-center">${$btnBackExisting}${$btnConfirmExisting}</div>
|
||
</div></div>`.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 $(`<button class="btn btn-xxs btn-danger" title="${title.qq()}"><span class="glyphicon glyphicon-trash"></span></button>`)
|
||
.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 $(`<div class="ve-flex-v-center w-100"></div>`);
|
||
}
|
||
|
||
/**
|
||
* @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 $(`<div class="ve-flex-v-center w-100"></div>`);
|
||
}
|
||
|
||
/**
|
||
* @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 || `<div></div>`));
|
||
|
||
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 || `<input class="form-control input-xs form-control--minimal text-right">`)).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 || `<input class="form-control input-xs form-control--minimal">`))
|
||
.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 = $$`<div class="relative w-100">${$ipt}${$decorLeft}${$decorRight}</div>`;
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
static _$getDecor (component, prop, $ipt, decorType, side, opts) {
|
||
switch (decorType) {
|
||
case "search": {
|
||
return $(`<div class="ui-ideco__wrp ui-ideco__wrp--${side} no-events ve-flex-vh-center"><span class="glyphicon glyphicon-search"></span></div>`);
|
||
}
|
||
case "clear": {
|
||
return $(`<div class="ui-ideco__wrp ui-ideco__wrp--${side} ve-flex-vh-center clickable" title="Clear"><span class="glyphicon glyphicon-remove"></span></div>`)
|
||
.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 = $(`<button class="btn btn-default ui-ideco__btn-ticker bold no-select">+</button>`)
|
||
.click(() => handleClick(1));
|
||
|
||
const $btnDown = $(`<button class="btn btn-default ui-ideco__btn-ticker bold no-select">\u2012</button>`)
|
||
.click(() => handleClick(-1));
|
||
|
||
return $$`<div class="ui-ideco__wrp ui-ideco__wrp--${side} ve-flex-vh-center ve-flex-col">
|
||
${$btnUp}
|
||
${$btnDown}
|
||
</div>`;
|
||
}
|
||
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 || $(`<textarea class="form-control input-xs form-control--minimal resize-vertical"></textarea>`))
|
||
.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 || `<input class="form-control input-xs form-control--minimal ui__ipt-color" type="color">`))
|
||
.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 || `<input class="form-control input-xs form-control--minimal">`))
|
||
.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 || `<input class="form-control input-xs form-control--minimal">`))
|
||
.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 = $(`<div class="absolute ui-sel2__wrp-options overflow-y-scroll"></div>`);
|
||
|
||
const $wrp = $$`<div class="ve-flex relative ui-sel2__wrp w-100">
|
||
${$iptDisplay}
|
||
${$iptSearch}
|
||
${$wrpChoices}
|
||
<div class="ui-sel2__disp-arrow absolute no-events bold"><span class="glyphicon glyphicon-menu-down"></span></div>
|
||
</div>`;
|
||
|
||
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 = $(`<div class="ve-flex-v-center py-1 px-1 clickable ui-sel2__disp-option ${v == null ? `italic` : ""} ${additionalStyleClasses ? additionalStyleClasses.join(" ") : ""}" tabindex="0">${display}</div>`)
|
||
.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 = $(`<button class="btn btn-xxs btn-default ui-pick__btn-add ml-auto">+</button>`)
|
||
.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 = $(`<button class="btn btn-xxs btn-default ui-pick__btn-add">+</button>`)
|
||
.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 = $(`<button class="btn btn-danger ui-pick__btn-remove ve-text-center">×</button>`)
|
||
.click(() => this._state[k] = false);
|
||
const txt = `${opts.fnDisplay ? opts.fnDisplay(k) : k}`;
|
||
$$`<div class="ve-flex mx-1 mb-1 ui-pick__disp-pill max-w-100 min-w-0"><div class="px-1 ui-pick__disp-text ve-flex-v-center text-clip-ellipsis" title="${txt.qq()}">${txt}</div>${$btnRemove}</div>`.appendTo($parent);
|
||
});
|
||
};
|
||
|
||
const $wrpPills = $(`<div class="ve-flex ve-flex-wrap max-w-100 min-w-0"></div>`);
|
||
const $wrp = $$`<div class="ve-flex-v-center w-100">${$btnAdd}${$wrpPills}</div>`;
|
||
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 = $(`<div class="ve-flex-col w-100"></div>`);
|
||
const metas = opts.values.map(it => {
|
||
const $cb = $(`<input type="checkbox">`)
|
||
.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]];
|
||
});
|
||
|
||
$$`<label class="split-v-center my-1 stripe-odd ${opts.isIndent ? "ml-4" : ""}"><div class="no-wrap ve-flex-v-center">${opts.fnDisplay ? opts.fnDisplay(it) : it}</div>${$cb}</label>`.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 `<value group name> -> 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($(`<hr class="w-100 hr-2 hr--dotted">`));
|
||
|
||
if (group.name) {
|
||
const $wrpName = $$`<div class="split-v-center py-1">
|
||
<div class="ve-flex-v-center"><span class="mr-2">‒</span><span>${group.name}</span></div>
|
||
${opts.valueGroupSplitControlsLookup?.[group.name]}
|
||
</div>`;
|
||
$eles.push($wrpName);
|
||
}
|
||
|
||
if (group.text) $eles.push($(`<div class="ve-flex-v-center py-1"><div class="ml-1 mr-3"></div><i>${group.text}</i></div>`));
|
||
|
||
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
|
||
? $(`<input type="checkbox" disabled checked title="This option is required.">`)
|
||
: 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 = $$`<label class="ve-flex-v-center py-1 stripe-even">
|
||
<div class="col-1 ve-flex-vh-center">${$cb}</div>
|
||
<div class="col-11 ve-flex-v-center">${displayValue}</div>
|
||
</label>`;
|
||
$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: $$`<div class="ve-flex-col w-100 overflow-y-auto">${$eles}</div>`,
|
||
$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 || $(`<input type="range">`))
|
||
.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;
|