mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
v1.209.0
This commit is contained in:
@@ -55,8 +55,6 @@ class ActionsPage extends ListPage {
|
||||
|
||||
dataProps: ["action"],
|
||||
|
||||
isMarkdownPopout: true,
|
||||
|
||||
isPreviewable: true,
|
||||
});
|
||||
}
|
||||
@@ -75,7 +73,7 @@ class ActionsPage extends ListPage {
|
||||
<span class="ve-col-0-3 px-0 ve-flex-vh-center lst__btn-toggle-expand ve-self-flex-stretch">[+]</span>
|
||||
<span class="ve-col-5-7 px-1 bold">${it.name}</span>
|
||||
<span class="ve-col-4 ve-text-center">${time}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
</a>
|
||||
<div class="ve-flex ve-hidden relative lst__wrp-preview">
|
||||
<div class="vr-0 absolute lst__vr-preview"></div>
|
||||
|
||||
@@ -63,8 +63,6 @@ class BackgroundPage extends ListPage {
|
||||
},
|
||||
|
||||
dataProps: ["background"],
|
||||
|
||||
isMarkdownPopout: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,7 +79,7 @@ class BackgroundPage extends ListPage {
|
||||
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
|
||||
<span class="bold ve-col-4 pl-0">${name}</span>
|
||||
<span class="ve-col-6">${bg._skillDisplay}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(bg.source)} pr-0" title="${Parser.sourceJsonToFull(bg.source)}" ${Parser.sourceJsonToStyle(bg.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(bg.source)} pr-0" title="${Parser.sourceJsonToFull(bg.source)}" ${Parser.sourceJsonToStyle(bg.source)}>${source}</span>
|
||||
</a>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
|
||||
@@ -362,8 +362,6 @@ class BestiaryPage extends ListPageMultiSource {
|
||||
environment: {name: "Environment", transform: it => Renderer.monster.getRenderedEnvironment(it)},
|
||||
},
|
||||
},
|
||||
|
||||
isMarkdownPopout: true,
|
||||
propEntryData: "monster",
|
||||
|
||||
propLoader: "monster",
|
||||
@@ -436,7 +434,7 @@ class BestiaryPage extends ListPageMultiSource {
|
||||
e_({tag: "span", clazz: `ve-col-1-7 ve-text-center`, text: cr}),
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: `ve-col-2 ve-text-center ${Parser.sourceJsonToColor(mon.source)} pr-0`,
|
||||
clazz: `ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(mon.source)} pr-0`,
|
||||
style: Parser.sourceJsonToStylePart(mon.source),
|
||||
title: `${Parser.sourceJsonToFull(mon.source)}${Renderer.utils.getSourceSubText(mon)}`,
|
||||
text: source,
|
||||
@@ -946,15 +944,6 @@ class BestiaryPage extends ListPageMultiSource {
|
||||
}
|
||||
}
|
||||
|
||||
async pHandleUnknownHash (link, sub) {
|
||||
const src = Object.keys(this._loadedSources)
|
||||
.find(src => src.toLowerCase() === (UrlUtil.decodeHash(link)[1] || "").toLowerCase());
|
||||
if (src) {
|
||||
await this._pLoadSource(src, "yes");
|
||||
Hist.hashChange();
|
||||
}
|
||||
}
|
||||
|
||||
_pOnLoad_initVisibleItemsDisplay (...args) {
|
||||
super._pOnLoad_initVisibleItemsDisplay(...arguments);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
class BlocklistUtil {
|
||||
static _IGNORED_CATEGORIES = new Set([
|
||||
"_meta",
|
||||
"_test",
|
||||
"linkedLootTables",
|
||||
|
||||
// `items-base.json`
|
||||
|
||||
@@ -98,7 +98,11 @@ class AdventuresBooksList {
|
||||
this.addData(data);
|
||||
await handleBrew(await PrereleaseUtil.pGetBrewProcessed());
|
||||
await handleBrew(await BrewUtil2.pGetBrewProcessed());
|
||||
ManageBrewUi.bindBtnOpen($(`#manage-brew`));
|
||||
// TODO(MODULES) refactor
|
||||
import("./utils-brew/utils-brew-ui-manage.js")
|
||||
.then(({ManageBrewUi}) => {
|
||||
ManageBrewUi.bindBtngroupManager(e_({id: "btngroup-manager"}));
|
||||
});
|
||||
this._list.init();
|
||||
this._listAlt.init();
|
||||
|
||||
@@ -179,7 +183,7 @@ class AdventuresBooksList {
|
||||
this._list.addItem(listItem);
|
||||
|
||||
// region Alt list (covers/thumbnails)
|
||||
const eleLiAlt = $(`<a href="${this._rootPage}#${UrlUtil.encodeForHash(it.id)}" class="ve-flex-col ve-flex-v-center m-3 bks__wrp-bookshelf-item ${isExcluded ? `bks__wrp-bookshelf-item--blocklisted` : ""} py-3 px-2 ${Parser.sourceJsonToColor(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>
|
||||
const eleLiAlt = $(`<a href="${this._rootPage}#${UrlUtil.encodeForHash(it.id)}" class="ve-flex-col ve-flex-v-center m-3 bks__wrp-bookshelf-item ${isExcluded ? `bks__wrp-bookshelf-item--blocklisted` : ""} py-3 px-2 ${Parser.sourceJsonToSourceClassname(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>
|
||||
<img src="${Renderer.adventureBook.getCoverUrl(it)}" class="mb-2 bks__bookshelf-image" loading="lazy" alt="Cover Image: ${(it.name || "").qq()}">
|
||||
<div class="bks__bookshelf-item-name ve-flex-vh-center ve-text-center">${it.name}</div>
|
||||
</a>`)[0];
|
||||
|
||||
@@ -56,8 +56,6 @@ class CharCreationOptionsPage extends ListPage {
|
||||
pageFilter,
|
||||
|
||||
dataProps: ["charoption"],
|
||||
|
||||
isMarkdownPopout: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,7 +71,7 @@ class CharCreationOptionsPage extends ListPage {
|
||||
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
|
||||
<span class="ve-col-5 ve-text-center pl-0">${it._fOptionType}</span>
|
||||
<span class="bold ve-col-5">${it.name}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(it.source)}" title="${Parser.sourceJsonToFull(it.source)} pr-0" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(it.source)}" title="${Parser.sourceJsonToFull(it.source)} pr-0" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
</a>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
|
||||
@@ -302,17 +302,21 @@ class ClassesPage extends MixinComponentGlobalState(MixinBaseComponent(MixinProx
|
||||
|
||||
this._pageFilter.trimState();
|
||||
|
||||
ManageBrewUi.bindBtnOpen($(`#manage-brew`));
|
||||
// TODO(MODULES) refactor
|
||||
import("./utils-brew/utils-brew-ui-manage.js")
|
||||
.then(({ManageBrewUi}) => {
|
||||
ManageBrewUi.bindBtngroupManager(e_({id: "btngroup-manager"}));
|
||||
});
|
||||
this._renderListFeelingLucky({isCompact: true, $btnReset});
|
||||
|
||||
window.onhashchange = this._handleHashChange.bind(this);
|
||||
window.onhashchange = this._pHandleHashChange.bind(this);
|
||||
|
||||
this._list.init();
|
||||
|
||||
$(`.initial-message`).text(`Select a class from the list to view it here`);
|
||||
|
||||
// Silently prepare our initial state
|
||||
this._setClassFromHash(Hist.initialLoad);
|
||||
await this._pSetClassFromHash(Hist.initialLoad);
|
||||
this._setStateFromHash(Hist.initialLoad);
|
||||
|
||||
await this._pInitAndRunRender();
|
||||
@@ -467,21 +471,23 @@ class ClassesPage extends MixinComponentGlobalState(MixinBaseComponent(MixinProx
|
||||
const rawLocation = window.location.hash;
|
||||
const location = rawLocation[0] === "#" ? rawLocation.slice(1) : rawLocation;
|
||||
if (nxtHash !== location) {
|
||||
if (isSuppressHistory) Hist.replaceHistoryHash(nxtHash);
|
||||
else window.location.hash = nxtHash;
|
||||
if (isSuppressHistory) {
|
||||
Hist.replaceHistoryHash(nxtHash);
|
||||
this._updateSelected();
|
||||
} else window.location.hash = nxtHash;
|
||||
}
|
||||
}
|
||||
|
||||
_handleHashChange () {
|
||||
async _pHandleHashChange () {
|
||||
// Parity with the implementation in hist.js
|
||||
if (Hist.isHistorySuppressed) return Hist.setSuppressHistory(false);
|
||||
|
||||
this._setClassFromHash();
|
||||
await this._pSetClassFromHash();
|
||||
this._setStateFromHash();
|
||||
}
|
||||
|
||||
_setClassFromHash (isInitialLoad) {
|
||||
const [link] = Hist.getHashParts();
|
||||
async _pSetClassFromHash (isInitialLoad) {
|
||||
const [link, ...sub] = Hist.getHashParts();
|
||||
|
||||
let ixToLoad;
|
||||
|
||||
@@ -489,8 +495,10 @@ class ClassesPage extends MixinComponentGlobalState(MixinBaseComponent(MixinProx
|
||||
else {
|
||||
const listItem = Hist.getActiveListItem(link);
|
||||
|
||||
if (listItem == null) ixToLoad = -1;
|
||||
else {
|
||||
if (listItem == null) {
|
||||
ixToLoad = -1;
|
||||
if (link && await this.pHandleUnknownHash(link, sub)) return;
|
||||
} else {
|
||||
const toLoad = listItem.ix;
|
||||
if (toLoad == null) ixToLoad = -1;
|
||||
else ixToLoad = listItem.ix;
|
||||
@@ -507,6 +515,8 @@ class ClassesPage extends MixinComponentGlobalState(MixinBaseComponent(MixinProx
|
||||
document.title = `${cls ? cls.name : "Classes"} - 5etools`;
|
||||
this._updateSelected();
|
||||
target._ = ixToLoad;
|
||||
} else {
|
||||
this._updateSelected();
|
||||
}
|
||||
} else {
|
||||
// This should never occur (failed loads should pick the first list item), but attempt to handle it semi-gracefully
|
||||
@@ -516,7 +526,7 @@ class ClassesPage extends MixinComponentGlobalState(MixinBaseComponent(MixinProx
|
||||
}
|
||||
|
||||
_setStateFromHash (isInitialLoad) {
|
||||
let [_, ...subs] = Hist.getHashParts();
|
||||
let [, ...subs] = Hist.getHashParts();
|
||||
subs = this.filterBox.setFromSubHashes(subs);
|
||||
|
||||
const target = isInitialLoad ? this.__state : this._state;
|
||||
@@ -673,7 +683,7 @@ class ClassesPage extends MixinComponentGlobalState(MixinBaseComponent(MixinProx
|
||||
|
||||
const $lnk = $(`<a href="#${hash}" class="lst--border lst__row-inner">
|
||||
<span class="bold ve-col-8 pl-0">${cls.name}</span>
|
||||
<span class="ve-col-4 ve-text-center ${Parser.sourceJsonToColor(cls.source)} pr-0" title="${Parser.sourceJsonToFull(cls.source)}" ${Parser.sourceJsonToStyle(cls.source)}>${source}</span>
|
||||
<span class="ve-col-4 ve-text-center ${Parser.sourceJsonToSourceClassname(cls.source)} pr-0" title="${Parser.sourceJsonToFull(cls.source)}" ${Parser.sourceJsonToStyle(cls.source)}>${source}</span>
|
||||
</a>`);
|
||||
|
||||
const $ele = $$`<li class="lst__row ve-flex-col ${isExcluded ? "row--blocklisted" : ""}">${$lnk}</li>`;
|
||||
@@ -777,8 +787,8 @@ class ClassesPage extends MixinComponentGlobalState(MixinBaseComponent(MixinProx
|
||||
// reset all hooks in preparation for rendering
|
||||
this._initHashAndStateSync();
|
||||
this.filterBox
|
||||
.off(FilterBox.EVNT_VALCHANGE)
|
||||
.on(FilterBox.EVNT_VALCHANGE, () => this._handleFilterChange(true));
|
||||
.off(FILTER_BOX_EVNT_VALCHANGE)
|
||||
.on(FILTER_BOX_EVNT_VALCHANGE, () => this._handleFilterChange(true));
|
||||
|
||||
// region bind list updates
|
||||
const hkSetHref = () => {
|
||||
@@ -1463,7 +1473,7 @@ class ClassesPage extends MixinComponentGlobalState(MixinBaseComponent(MixinProx
|
||||
const isClassFeatureVariantsDisplayed = f[this._pageFilter.optionsFilter.header].isClassFeatureVariant;
|
||||
$btnToggleFeatureVariants.toggleClass("active", isClassFeatureVariantsDisplayed);
|
||||
};
|
||||
this.filterBox.on(FilterBox.EVNT_VALCHANGE, () => hkUpdateBtnFeatureVariants());
|
||||
this.filterBox.on(FILTER_BOX_EVNT_VALCHANGE, () => hkUpdateBtnFeatureVariants());
|
||||
hkUpdateBtnFeatureVariants();
|
||||
|
||||
const $btnToggleFluff = ComponentUiUtil.$getBtnBool(this, "isShowFluff", {text: "Info"}).title("Toggle Class Info");
|
||||
@@ -1565,6 +1575,7 @@ class ClassesPage extends MixinComponentGlobalState(MixinBaseComponent(MixinProx
|
||||
SourceUtil.FILTER_GROUP_STANDARD,
|
||||
SourceUtil.FILTER_GROUP_PARTNERED,
|
||||
SourceUtil.FILTER_GROUP_NON_STANDARD,
|
||||
SourceUtil.FILTER_GROUP_PRERELEASE,
|
||||
SourceUtil.FILTER_GROUP_HOMEBREW,
|
||||
].filter(it => !toInclude.includes(it));
|
||||
|
||||
@@ -1613,7 +1624,7 @@ class ClassesPage extends MixinComponentGlobalState(MixinBaseComponent(MixinProx
|
||||
this._proxyAssign("state", "_state", "__state", cls.subclasses.mergeMap(sc => ({[UrlUtil.getStateKeySubclass(sc)]: false})));
|
||||
});
|
||||
|
||||
this.filterBox.on(FilterBox.EVNT_VALCHANGE, this._handleSubclassFilterChange.bind(this));
|
||||
this.filterBox.on(FILTER_BOX_EVNT_VALCHANGE, this._handleSubclassFilterChange.bind(this));
|
||||
this._handleSubclassFilterChange();
|
||||
// Remove the temporary "hidden" class used to prevent popping
|
||||
this._listSubclass.items.forEach(it => it.ele.showVe());
|
||||
|
||||
@@ -57,8 +57,6 @@ class ConditionsDiseasesPage extends ListPage {
|
||||
|
||||
dataProps: ["condition", "disease", "status"],
|
||||
|
||||
isMarkdownPopout: true,
|
||||
|
||||
isPreviewable: true,
|
||||
});
|
||||
}
|
||||
@@ -76,7 +74,7 @@ class ConditionsDiseasesPage extends ListPage {
|
||||
<span class="ve-col-0-3 px-0 ve-flex-vh-center lst__btn-toggle-expand ve-self-flex-stretch">[+]</span>
|
||||
<span class="ve-col-3 ve-text-center">${PageFilterConditionsDiseases.getDisplayProp(it.__prop)}</span>
|
||||
<span class="bold ve-col-6-7 px-1">${it.name}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
</a>
|
||||
<div class="ve-flex ve-hidden relative lst__wrp-preview">
|
||||
<div class="vr-0 absolute lst__vr-preview"></div>
|
||||
|
||||
@@ -108,8 +108,9 @@ class ItemParser extends BaseParser {
|
||||
ChargeTag.tryRun(stats);
|
||||
RechargeTypeTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Recharge type requires manual conversion`)});
|
||||
RechargeAmountTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Recharge amount requires manual conversion`)});
|
||||
BonusTag.tryRun(stats);
|
||||
ItemMiscTag.tryRun(stats);
|
||||
BonusTag.tryRun(stats);
|
||||
ItemOtherTagsTag.tryRun(stats);
|
||||
ItemSpellcastingFocusTag.tryRun(stats);
|
||||
DamageResistanceTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Damage resistance tagging requires manual conversion`)});
|
||||
DamageImmunityTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Damage immunity tagging requires manual conversion`)});
|
||||
|
||||
@@ -299,7 +299,7 @@ class AcConvert {
|
||||
case "half-plate": return "{@item half plate armor|phb}";
|
||||
|
||||
case "scale armor": return "{@item scale mail|phb}";
|
||||
case "splint armor": return "{@item splint mail|phb}";
|
||||
case "splint armor": return "{@item splint armor|phb}";
|
||||
case "chain shirt": return "{@item chain shirt|phb}";
|
||||
case "shields": return "{@item shield|phb|shields}";
|
||||
|
||||
@@ -741,6 +741,8 @@ class TraitActionTag {
|
||||
"tunneler": "Tunneler",
|
||||
|
||||
"beast of burden": "Beast of Burden",
|
||||
|
||||
"mimicry": "Mimicry",
|
||||
},
|
||||
action: {
|
||||
"multiattack": "Multiattack",
|
||||
@@ -1271,11 +1273,14 @@ class MiscTag {
|
||||
static _THROWN_WEAPON_MATCHERS = null;
|
||||
|
||||
static _IS_INIT = false;
|
||||
static _WALKER = null;
|
||||
|
||||
static init ({items}) {
|
||||
if (this._IS_INIT) return;
|
||||
this._IS_INIT = true;
|
||||
|
||||
this._WALKER = MiscUtil.getWalker({isNoModification: true, keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST});
|
||||
|
||||
const weaponsBase = items
|
||||
.filter(it => it._category === "Basic" && (it.type === "M" || it.type === "W"));
|
||||
|
||||
@@ -1292,6 +1297,8 @@ class MiscTag {
|
||||
.map(it => new RegExp(`(^|\\W)(${it.name.escapeRegexp()})(\\W|$)`, "gi"));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @return empty string for easy use in `.replace` */
|
||||
static _addTag ({tagSet, allowlistTags, tag}) {
|
||||
if (allowlistTags != null && !allowlistTags.has(tag)) return "";
|
||||
@@ -1299,68 +1306,191 @@ class MiscTag {
|
||||
return "";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
static _handleProp ({m, prop, tagSet, allowlistTags}) {
|
||||
if (!m[prop]) return;
|
||||
|
||||
m[prop].forEach(it => {
|
||||
let hasRangedAttack = false;
|
||||
|
||||
const strEntries = it.entries ? JSON.stringify(it.entries, null, "\t") : null;
|
||||
|
||||
if (strEntries) {
|
||||
// Weapon attacks
|
||||
// - any melee/ranged attack
|
||||
strEntries.replace(/{@atk ([^}]+)}/g, (...mx) => {
|
||||
const spl = mx[1].split(",");
|
||||
if (spl.includes("rw")) {
|
||||
this._addTag({tagSet, allowlistTags, tag: "RW"});
|
||||
hasRangedAttack = true;
|
||||
}
|
||||
if (spl.includes("mw")) this._addTag({tagSet, allowlistTags, tag: "MW"});
|
||||
});
|
||||
|
||||
// - reach
|
||||
strEntries.replace(/reach (\d+) ft\./g, (...m) => {
|
||||
if (Number(m[1]) > 5) this._addTag({tagSet, allowlistTags, tag: "RCH"});
|
||||
});
|
||||
|
||||
// AoE effects
|
||||
strEntries.replace(/\d+-foot[- ](line|cube|cone|radius|sphere|hemisphere|cylinder)/g, () => this._addTag({tagSet, allowlistTags, tag: "AOE"}));
|
||||
strEntries.replace(/each creature within \d+ feet/gi, () => this._addTag({tagSet, allowlistTags, tag: "AOE"}));
|
||||
|
||||
strEntries.replace(/\bhit point maximum is reduced\b/gi, () => this._addTag({tagSet, allowlistTags, tag: "HPR"}));
|
||||
}
|
||||
|
||||
if (it.name) {
|
||||
// Melee weapons
|
||||
// Ranged weapon
|
||||
[
|
||||
{res: this._MELEE_WEAPON_MATCHERS, tag: "MLW"},
|
||||
{res: this._RANGED_WEAPON_MATCHERS, tag: "RNG"},
|
||||
]
|
||||
.forEach(({res, tag}) => {
|
||||
res
|
||||
.forEach(re => {
|
||||
it.name
|
||||
.replace(re, () => {
|
||||
const mAtk = /{@atk ([^}]+)}/.exec(strEntries || "");
|
||||
if (mAtk) {
|
||||
const spl = mAtk[1].split(",");
|
||||
// Avoid adding the "ranged attack" tag for spell attacks
|
||||
if (spl.includes("rs")) return "";
|
||||
}
|
||||
this._addTag({tagSet, allowlistTags, tag});
|
||||
return "";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Thrown weapon
|
||||
if (hasRangedAttack) this._THROWN_WEAPON_MATCHERS.forEach(r => it.name.replace(r, () => this._addTag({tagSet, allowlistTags, tag: "THW"})));
|
||||
}
|
||||
m[prop].forEach(subEntry => {
|
||||
this._handleProp_attacks({subEntry, tagSet, allowlistTags});
|
||||
this._handleProp_curse({subEntry, tagSet, allowlistTags});
|
||||
this._handleProp_disease({subEntry, tagSet, allowlistTags});
|
||||
this._handleProp_other({subEntry, tagSet, allowlistTags});
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------- */
|
||||
|
||||
static _handleProp_attacks (
|
||||
{
|
||||
subEntry,
|
||||
tagSet,
|
||||
allowlistTags,
|
||||
},
|
||||
) {
|
||||
let hasRangedAttack = false;
|
||||
|
||||
// Weapon attacks
|
||||
this._WALKER.walk(
|
||||
subEntry.entries,
|
||||
{
|
||||
string: (str) => {
|
||||
// - any melee/ranged attack
|
||||
str
|
||||
.replace(/{@atk ([^}]+)}/g, (...mx) => {
|
||||
const spl = mx[1].split(",");
|
||||
|
||||
if (spl.includes("rw")) {
|
||||
this._addTag({tagSet, allowlistTags, tag: "RW"});
|
||||
hasRangedAttack = true;
|
||||
}
|
||||
|
||||
if (spl.includes("mw")) this._addTag({tagSet, allowlistTags, tag: "MW"});
|
||||
});
|
||||
|
||||
// - reach
|
||||
str
|
||||
.replace(/reach (\d+) ft\./g, (...m) => {
|
||||
if (Number(m[1]) > 5) this._addTag({tagSet, allowlistTags, tag: "RCH"});
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!subEntry.name) return;
|
||||
|
||||
// Melee weapons
|
||||
// Ranged weapon
|
||||
[
|
||||
{res: this._MELEE_WEAPON_MATCHERS, tag: "MLW"},
|
||||
{res: this._RANGED_WEAPON_MATCHERS, tag: "RNG"},
|
||||
]
|
||||
.forEach(({res, tag}) => {
|
||||
res
|
||||
.forEach(re => {
|
||||
if (!re.test(subEntry.name)) return;
|
||||
|
||||
this._WALKER.walk(
|
||||
subEntry.entries,
|
||||
{
|
||||
string: (str) => {
|
||||
const mAtk = /{@atk ([^}]+)}/.exec(str || "");
|
||||
if (mAtk) {
|
||||
const spl = mAtk[1].split(",");
|
||||
// Avoid adding the "ranged attack" tag for spell attacks
|
||||
if (spl.includes("rs")) return "";
|
||||
}
|
||||
this._addTag({tagSet, allowlistTags, tag});
|
||||
return "";
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Thrown weapon
|
||||
if (hasRangedAttack) this._THROWN_WEAPON_MATCHERS.forEach(r => subEntry.name.replace(r, () => this._addTag({tagSet, allowlistTags, tag: "THW"})));
|
||||
}
|
||||
|
||||
/* --------------------- */
|
||||
|
||||
static _handleProp_curse (
|
||||
{
|
||||
subEntry,
|
||||
tagSet,
|
||||
allowlistTags,
|
||||
},
|
||||
) {
|
||||
this._WALKER.walk(
|
||||
subEntry.entries,
|
||||
{
|
||||
string: (str) => {
|
||||
const strClean = str
|
||||
.replace(/{@spell bestow curse[^}]+}/gi, " - ") // Ignore the spell; it already has a dedicated filter
|
||||
;
|
||||
|
||||
const isCurseLine = /\bbe(?:comes)? cursed\b/.test(strClean)
|
||||
|| /\bis cursed\b/.test(strClean)
|
||||
;
|
||||
|
||||
if (!isCurseLine) return;
|
||||
|
||||
// Treat "curses" with limited durations as generic combat effects, rather than real curses
|
||||
const isLimitedDuration = /\bfor \d+ (turn|round|minute)s?\b/i.test(strClean)
|
||||
|| /\bfor 1 hour\b/i.test(strClean) // Consider e.g. "24 hours" sufficient time
|
||||
|| /\bnext turn\b/i.test(strClean)
|
||||
;
|
||||
|
||||
if (isLimitedDuration) return false;
|
||||
|
||||
this._addTag({tagSet, allowlistTags, tag: "CUR"});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------- */
|
||||
|
||||
static _RES_DISEASE = [
|
||||
/\bbecome diseased\b/i,
|
||||
/\binfected with a disease\b/i,
|
||||
|
||||
/\binfected with the [^.!?]+ disease\b/i,
|
||||
|
||||
/\bsuffer the [^.!?]+ disease\b/i,
|
||||
|
||||
/\bcontract a disease\b/i,
|
||||
/\bcontract the [^.!?]+ disease\b/i,
|
||||
|
||||
/\bsaving throw against disease\b/i,
|
||||
|
||||
/\bany effect that cures disease\b/i,
|
||||
/\buntil the disease is cured\b/i,
|
||||
];
|
||||
|
||||
static _handleProp_disease (
|
||||
{
|
||||
subEntry,
|
||||
tagSet,
|
||||
allowlistTags,
|
||||
},
|
||||
) {
|
||||
this._WALKER.walk(
|
||||
subEntry.entries,
|
||||
{
|
||||
string: (str) => {
|
||||
if (this._RES_DISEASE.some(re => re.test(str))) this._addTag({tagSet, allowlistTags, tag: "DIS"});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------- */
|
||||
|
||||
static _handleProp_other (
|
||||
{
|
||||
subEntry,
|
||||
tagSet,
|
||||
allowlistTags,
|
||||
},
|
||||
) {
|
||||
this._WALKER.walk(
|
||||
subEntry.entries,
|
||||
{
|
||||
string: (str) => {
|
||||
// AoE effects
|
||||
str.replace(/\d+-foot[- ](line|cube|cone|radius|sphere|hemisphere|cylinder)/g, () => this._addTag({tagSet, allowlistTags, tag: "AOE"}));
|
||||
str.replace(/each creature within \d+ feet/gi, () => this._addTag({tagSet, allowlistTags, tag: "AOE"}));
|
||||
|
||||
// Hit point max reduction
|
||||
str.replace(/\bhit point maximum is reduced\b/gi, () => this._addTag({tagSet, allowlistTags, tag: "HPR"}));
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
static tryRun (m, {isAdditiveOnly = false, allowlistTags = null} = {}) {
|
||||
const tagSet = new Set(isAdditiveOnly ? m.miscTags || [] : []);
|
||||
MiscTag._handleProp({m, prop: "action", tagSet, allowlistTags});
|
||||
|
||||
@@ -494,23 +494,62 @@ class BasicTextClean {
|
||||
|
||||
globalThis.BasicTextClean = BasicTextClean;
|
||||
|
||||
class ItemMiscTag {
|
||||
class ItemOtherTagsTag {
|
||||
static tryRun (it, opts) {
|
||||
if (!(it.entries || (it.inherits && it.inherits.entries))) return;
|
||||
|
||||
const isInherits = !it.entries && it.inherits.entries;
|
||||
const tgt = it.entries ? it : it.inherits;
|
||||
|
||||
const strEntries = JSON.stringify(it.entries || it.inherits.entries);
|
||||
|
||||
strEntries.replace(/"Sentience"/, (...m) => tgt.sentient = true);
|
||||
strEntries.replace(/"Curse"/, (...m) => tgt.curse = true);
|
||||
strEntries.replace(/"Sentience"/, () => tgt.sentient = true);
|
||||
strEntries.replace(/"Curse"/, () => tgt.curse = true);
|
||||
|
||||
strEntries.replace(/you[^.]* (gain|have)? proficiency/gi, (...m) => tgt.grantsProficiency = true);
|
||||
strEntries.replace(/you gain[^.]* following proficiencies/gi, (...m) => tgt.grantsProficiency = true);
|
||||
strEntries.replace(/you are[^.]* considered proficient/gi, (...m) => tgt.grantsProficiency = true);
|
||||
strEntries.replace(/you[^.]* (gain|have)? proficiency/gi, () => tgt.grantsProficiency = true);
|
||||
strEntries.replace(/you gain[^.]* following proficiencies/gi, () => tgt.grantsProficiency = true);
|
||||
strEntries.replace(/you are[^.]* considered proficient/gi, () => tgt.grantsProficiency = true);
|
||||
|
||||
strEntries.replace(/[Yy]ou can speak( and understand)? [A-Z]/g, (...m) => tgt.grantsLanguage = true);
|
||||
strEntries.replace(/[Yy]ou can speak( and understand)? [A-Z]/g, () => tgt.grantsLanguage = true);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.ItemOtherTagsTag = ItemOtherTagsTag;
|
||||
|
||||
class ItemMiscTag {
|
||||
/** @return empty string for easy use in `.replace` */
|
||||
static _addTag ({tagSet, allowlistTags, tag}) {
|
||||
if (allowlistTags != null && !allowlistTags.has(tag)) return "";
|
||||
tagSet.add(tag);
|
||||
return "";
|
||||
}
|
||||
|
||||
static tryRun (ent, {isAdditiveOnly = false, allowlistTags = null} = {}) {
|
||||
const tagSet = new Set(isAdditiveOnly ? ent.miscTags || [] : []);
|
||||
const tgt = ent.inherits || ent;
|
||||
this._tryRun_consumable({tagSet, allowlistTags, tgt});
|
||||
if (tagSet.size) ent.miscTags = [...tagSet].sort(SortUtil.ascSortLower);
|
||||
else if (!isAdditiveOnly) delete ent.miscTags;
|
||||
}
|
||||
|
||||
static _tryRun_consumable ({tagSet, allowlistTags, tgt}) {
|
||||
if (
|
||||
[
|
||||
"AF", // Futuristic Ammo
|
||||
"EXP", // Explosive
|
||||
"FD", // Food and Drink
|
||||
"IDG", // Illegal Drug
|
||||
"P", // Potion
|
||||
"SC", // Scroll
|
||||
]
|
||||
.includes(tgt.type)
|
||||
) {
|
||||
this._addTag({tagSet, allowlistTags, tag: "CNS"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (tgt.poison) {
|
||||
this._addTag({tagSet, allowlistTags, tag: "CNS"});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -402,7 +402,7 @@ class TagCondition {
|
||||
"unconscious",
|
||||
];
|
||||
|
||||
static _STATUS_MATCHER = new RegExp(`\\b(concentration)\\b`, "g");
|
||||
static _STATUS_MATCHER = new RegExp(`\\b(concentration|surprised)\\b`, "g");
|
||||
|
||||
static _STATUS_MATCHER_ALT = new RegExp(`\\b(concentrating)\\b`, "g");
|
||||
|
||||
|
||||
@@ -168,8 +168,12 @@ function addMonsterFeatures (mfData) {
|
||||
function calculateCr () {
|
||||
const expectedCr = parseInt($("#expectedcr").val());
|
||||
|
||||
// Effective HP
|
||||
let hp = parseInt($("#crcalc #hp").val());
|
||||
|
||||
// Used in e.g. "Damage Transfer"
|
||||
const hpActual = hp;
|
||||
|
||||
if ($("#vulnerabilities").prop("checked")) hp *= 0.5;
|
||||
if ($("#resistances").val() === "res") {
|
||||
if (expectedCr >= 0 && expectedCr <= 4) hp *= 2;
|
||||
|
||||
@@ -59,8 +59,6 @@ class CultsBoonsPage extends ListPage {
|
||||
pageFilter,
|
||||
|
||||
dataProps: ["cult", "boon"],
|
||||
|
||||
isMarkdownPopout: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,7 +78,7 @@ class CultsBoonsPage extends ListPage {
|
||||
<span class="ve-col-2 ve-text-center pl-0">${it._lType}</span>
|
||||
<span class="ve-col-2 ve-text-center">${it._lSubType}</span>
|
||||
<span class="bold ve-col-6">${it.name}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
</a>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
|
||||
@@ -136,7 +136,7 @@ class DecksPage extends ListPage {
|
||||
|
||||
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
|
||||
<span class="ve-col-10 bold pl-0">${ent.name}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(ent.source)} pr-0" title="${Parser.sourceJsonToFull(ent.source)}" ${Parser.sourceJsonToStyle(ent.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(ent.source)} pr-0" title="${Parser.sourceJsonToFull(ent.source)}" ${Parser.sourceJsonToStyle(ent.source)}>${source}</span>
|
||||
</a>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
|
||||
@@ -68,7 +68,7 @@ class DeitiesPage extends ListPage {
|
||||
|
||||
dataProps: ["deity"],
|
||||
|
||||
isMarkdownPopout: true,
|
||||
listSyntax: new ListSyntaxDeities({fnGetDataList: () => this._dataList}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ class DeitiesPage extends ListPage {
|
||||
<span class="ve-col-2 ve-text-center">${ent.pantheon}</span>
|
||||
<span class="ve-col-2 ve-text-center">${alignment}</span>
|
||||
<span class="ve-col-3 ${ent.domains[0] === VeCt.STR_NONE ? `list-entry-none` : ""}">${domains}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(ent.source)} pr-0" title="${Parser.sourceJsonToFull(ent.source)}" ${Parser.sourceJsonToStyle(ent.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(ent.source)} pr-0" title="${Parser.sourceJsonToFull(ent.source)}" ${Parser.sourceJsonToStyle(ent.source)}>${source}</span>
|
||||
</a>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
|
||||
@@ -68,8 +68,6 @@ class FeatsPage extends ListPage {
|
||||
},
|
||||
|
||||
isPreviewable: true,
|
||||
|
||||
isMarkdownPopout: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,7 +85,7 @@ class FeatsPage extends ListPage {
|
||||
<span class="bold ve-col-3-5 px-1">${feat.name}</span>
|
||||
<span class="ve-col-3-5 ${feat._slAbility === VeCt.STR_NONE ? "list-entry-none " : ""}">${feat._slAbility}</span>
|
||||
<span class="ve-col-3 ${feat._slPrereq === VeCt.STR_NONE ? "list-entry-none " : ""}">${feat._slPrereq}</span>
|
||||
<span class="source ve-col-1-7 ve-text-center ${Parser.sourceJsonToColor(feat.source)} pr-0" title="${Parser.sourceJsonToFull(feat.source)}" ${Parser.sourceJsonToStyle(feat.source)}>${source}</span>
|
||||
<span class="source ve-col-1-7 ve-text-center ${Parser.sourceJsonToSourceClassname(feat.source)} pr-0" title="${Parser.sourceJsonToFull(feat.source)}" ${Parser.sourceJsonToStyle(feat.source)}>${source}</span>
|
||||
</a>
|
||||
<div class="ve-flex ve-hidden relative lst__wrp-preview">
|
||||
<div class="vr-0 absolute lst__vr-preview"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterActions extends PageFilter {
|
||||
class PageFilterActions extends PageFilterBase {
|
||||
static getTimeText (time) {
|
||||
return typeof time === "string" ? time : Parser.getTimeToFull(time);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterBackgrounds extends PageFilter {
|
||||
class PageFilterBackgrounds extends PageFilterBase {
|
||||
// TODO(Future) expand/move to `Renderer.generic`
|
||||
static _getToolDisplayText (tool) {
|
||||
if (tool === "anyTool") return "Any Tool";
|
||||
@@ -106,7 +106,7 @@ class PageFilterBackgrounds extends PageFilter {
|
||||
|
||||
globalThis.PageFilterBackgrounds = PageFilterBackgrounds;
|
||||
|
||||
class ModalFilterBackgrounds extends ModalFilter {
|
||||
class ModalFilterBackgrounds extends ModalFilterBase {
|
||||
/**
|
||||
* @param opts
|
||||
* @param opts.namespace
|
||||
@@ -128,7 +128,7 @@ class ModalFilterBackgrounds extends ModalFilter {
|
||||
{sort: "skills", text: "Skills", width: "6"},
|
||||
{sort: "source", text: "Source", width: "1"},
|
||||
];
|
||||
return ModalFilter._$getFilterColumnHeaders(btnMeta);
|
||||
return ModalFilterBase._$getFilterColumnHeaders(btnMeta);
|
||||
}
|
||||
|
||||
async _pLoadAllData () {
|
||||
@@ -155,7 +155,7 @@ class ModalFilterBackgrounds extends ModalFilter {
|
||||
|
||||
<div class="ve-col-4 ${bg._versionBase_isVersion ? "italic" : ""} ${this._getNameStyle()}">${bg._versionBase_isVersion ? `<span class="px-3"></span>` : ""}${bg.name}</div>
|
||||
<div class="ve-col-6">${bg._skillDisplay}</div>
|
||||
<div class="ve-col-1 pr-0 ve-flex-h-center ${Parser.sourceJsonToColor(bg.source)}" title="${Parser.sourceJsonToFull(bg.source)}" ${Parser.sourceJsonToStyle(bg.source)}>${source}${Parser.sourceJsonToMarkerHtml(bg.source)}</div>
|
||||
<div class="ve-col-1 pr-0 ve-flex-h-center ${Parser.sourceJsonToSourceClassname(bg.source)}" title="${Parser.sourceJsonToFull(bg.source)}" ${Parser.sourceJsonToStyle(bg.source)}>${source}${Parser.sourceJsonToMarkerHtml(bg.source)}</div>
|
||||
</div>`;
|
||||
|
||||
const btnShowHidePreview = eleRow.firstElementChild.children[1].firstElementChild;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterBestiary extends PageFilter {
|
||||
class PageFilterBestiary extends PageFilterBase {
|
||||
static _NEUT_ALIGNS = ["NX", "NY"];
|
||||
static MISC_FILTER_SPELLCASTER = "Spellcaster, ";
|
||||
static _RE_SPELL_TAG = /{@spell ([^}]+)}/g;
|
||||
@@ -622,7 +622,7 @@ class PageFilterBestiary extends PageFilter {
|
||||
|
||||
globalThis.PageFilterBestiary = PageFilterBestiary;
|
||||
|
||||
class ModalFilterBestiary extends ModalFilter {
|
||||
class ModalFilterBestiary extends ModalFilterBase {
|
||||
/**
|
||||
* @param opts
|
||||
* @param opts.namespace
|
||||
@@ -646,7 +646,7 @@ class ModalFilterBestiary extends ModalFilter {
|
||||
{sort: "cr", text: "CR", width: "2"},
|
||||
{sort: "source", text: "Source", width: "1"},
|
||||
];
|
||||
return ModalFilter._$getFilterColumnHeaders(btnMeta);
|
||||
return ModalFilterBase._$getFilterColumnHeaders(btnMeta);
|
||||
}
|
||||
|
||||
async _pLoadAllData () {
|
||||
@@ -679,7 +679,7 @@ class ModalFilterBestiary extends ModalFilter {
|
||||
<div class="ve-col-4 ${mon._versionBase_isVersion ? "italic" : ""} ${this._getNameStyle()}">${mon._versionBase_isVersion ? `<span class="px-3"></span>` : ""}${mon.name}</div>
|
||||
<div class="ve-col-4">${type}</div>
|
||||
<div class="ve-col-2 ve-text-center">${cr}</div>
|
||||
<div class="ve-col-1 ve-flex-h-center ${Parser.sourceJsonToColor(mon.source)} pr-0" title="${Parser.sourceJsonToFull(mon.source)}" ${Parser.sourceJsonToStyle(mon.source)}>${source}${Parser.sourceJsonToMarkerHtml(mon.source)}</div>
|
||||
<div class="ve-col-1 ve-flex-h-center ${Parser.sourceJsonToSourceClassname(mon.source)} pr-0" title="${Parser.sourceJsonToFull(mon.source)}" ${Parser.sourceJsonToStyle(mon.source)}>${source}${Parser.sourceJsonToMarkerHtml(mon.source)}</div>
|
||||
</div>`;
|
||||
|
||||
const btnShowHidePreview = eleRow.firstElementChild.children[1].firstElementChild;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterCharCreationOptions extends PageFilter {
|
||||
class PageFilterCharCreationOptions extends PageFilterBase {
|
||||
static _filterFeatureTypeSort (a, b) {
|
||||
return SortUtil.ascSort(Parser.charCreationOptionTypeToFull(a.item), Parser.charCreationOptionTypeToFull(b.item));
|
||||
}
|
||||
|
||||
@@ -616,7 +616,7 @@ class PageFilterClassesRaw extends PageFilterClassesBase {
|
||||
|
||||
globalThis.PageFilterClassesRaw = PageFilterClassesRaw;
|
||||
|
||||
class ModalFilterClasses extends ModalFilter {
|
||||
class ModalFilterClasses extends ModalFilterBase {
|
||||
/**
|
||||
* @param opts
|
||||
* @param opts.namespace
|
||||
@@ -830,7 +830,7 @@ class ModalFilterClasses extends ModalFilter {
|
||||
|
||||
pageFilter.trimState();
|
||||
|
||||
pageFilter.filterBox.on(FilterBox.EVNT_VALCHANGE, handleFilterChange);
|
||||
pageFilter.filterBox.on(FILTER_BOX_EVNT_VALCHANGE, handleFilterChange);
|
||||
pageFilter.filterBox.render();
|
||||
handleFilterChange();
|
||||
|
||||
@@ -968,7 +968,7 @@ class ModalFilterClasses extends ModalFilter {
|
||||
|
||||
eleLabel.innerHTML = `<div class="ve-col-1 pl-0 ve-flex-vh-center"><div class="fltr-cls__tgl"></div></div>
|
||||
<div class="bold ve-col-9 ${cls._versionBase_isVersion ? "italic" : ""}">${cls._versionBase_isVersion ? `<span class="px-3"></span>` : ""}${cls.name}</div>
|
||||
<div class="ve-col-2 pr-0 ve-flex-h-center ${Parser.sourceJsonToColor(cls.source)}" title="${Parser.sourceJsonToFull(cls.source)}" ${Parser.sourceJsonToStyle(cls.source)}>${source}${Parser.sourceJsonToMarkerHtml(cls.source)}</div>`;
|
||||
<div class="ve-col-2 pr-0 ve-flex-h-center ${Parser.sourceJsonToSourceClassname(cls.source)}" title="${Parser.sourceJsonToFull(cls.source)}" ${Parser.sourceJsonToStyle(cls.source)}>${source}${Parser.sourceJsonToMarkerHtml(cls.source)}</div>`;
|
||||
|
||||
return new ListItem(
|
||||
clsI,
|
||||
@@ -992,7 +992,7 @@ class ModalFilterClasses extends ModalFilter {
|
||||
|
||||
eleLabel.innerHTML = `<div class="ve-col-1 pl-0 ve-flex-vh-center"><div class="fltr-cls__tgl"></div></div>
|
||||
<div class="ve-col-9 pl-1 ve-flex-v-center ${sc._versionBase_isVersion ? "italic" : ""}">${sc._versionBase_isVersion ? `<span class="px-3"></span>` : ""}<span class="mx-3">\u2014</span> ${sc.name}</div>
|
||||
<div class="ve-col-2 pr-0 ve-flex-h-center ${Parser.sourceJsonToColor(sc.source)}" title="${Parser.sourceJsonToFull(sc.source)}" ${Parser.sourceJsonToStyle(sc.source)}>${source}${Parser.sourceJsonToMarkerHtml(sc.source)}</div>`;
|
||||
<div class="ve-col-2 pr-0 ve-flex-h-center ${Parser.sourceJsonToSourceClassname(sc.source)}" title="${Parser.sourceJsonToFull(sc.source)}" ${Parser.sourceJsonToStyle(sc.source)}>${source}${Parser.sourceJsonToMarkerHtml(sc.source)}</div>`;
|
||||
|
||||
return new ListItem(
|
||||
`${clsI}--${scI}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterClassesBase extends PageFilter {
|
||||
class PageFilterClassesBase extends PageFilterBase {
|
||||
constructor () {
|
||||
super();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterConditionsDiseases extends PageFilter {
|
||||
class PageFilterConditionsDiseases extends PageFilterBase {
|
||||
// region static
|
||||
static getDisplayProp (prop) {
|
||||
return prop === "status" ? "Other" : Parser.getPropDisplayName(prop);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterCultsBoons extends PageFilter {
|
||||
class PageFilterCultsBoons extends PageFilterBase {
|
||||
constructor () {
|
||||
super();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterDecks extends PageFilter {
|
||||
class PageFilterDecks extends PageFilterBase {
|
||||
constructor () {
|
||||
super();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterDeities extends PageFilter {
|
||||
class PageFilterDeities extends PageFilterBase {
|
||||
static unpackAlignment (ent) {
|
||||
ent.alignment.sort(SortUtil.alignmentSort);
|
||||
if (ent.alignment.length === 2 && ent.alignment.includes("N")) {
|
||||
@@ -85,3 +85,22 @@ class PageFilterDeities extends PageFilter {
|
||||
}
|
||||
|
||||
globalThis.PageFilterDeities = PageFilterDeities;
|
||||
|
||||
class ListSyntaxDeities extends ListUiUtil.ListSyntax {
|
||||
_getSearchCacheStats (entity) {
|
||||
const ptrOut = {_: ""};
|
||||
|
||||
const entriesMeta = Renderer.deity.getDeityRenderableEntriesMeta(entity);
|
||||
Object.entries(entriesMeta.entriesAttributes)
|
||||
.forEach(entry => this._getSearchCache_handleEntry(entry, ptrOut));
|
||||
|
||||
return ptrOut._;
|
||||
}
|
||||
|
||||
/** Treat entries on the deity as "fluff" */
|
||||
async _pGetSearchCacheFluff (entity) {
|
||||
return this._getSearchCache_entries(entity, {indexableProps: ["entries"]});
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.ListSyntaxDeities = ListSyntaxDeities;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterFeats extends PageFilter {
|
||||
class PageFilterFeats extends PageFilterBase {
|
||||
// region static
|
||||
static _PREREQ_KEYs_OTHER_IGNORED = new Set(["level"]);
|
||||
// endregion
|
||||
@@ -147,7 +147,7 @@ class PageFilterFeats extends PageFilter {
|
||||
|
||||
globalThis.PageFilterFeats = PageFilterFeats;
|
||||
|
||||
class ModalFilterFeats extends ModalFilter {
|
||||
class ModalFilterFeats extends ModalFilterBase {
|
||||
/**
|
||||
* @param opts
|
||||
* @param opts.namespace
|
||||
@@ -170,7 +170,7 @@ class ModalFilterFeats extends ModalFilter {
|
||||
{sort: "prerequisite", text: "Prerequisite", width: "3"},
|
||||
{sort: "source", text: "Source", width: "1"},
|
||||
];
|
||||
return ModalFilter._$getFilterColumnHeaders(btnMeta);
|
||||
return ModalFilterBase._$getFilterColumnHeaders(btnMeta);
|
||||
}
|
||||
|
||||
async _pLoadAllData () {
|
||||
@@ -198,7 +198,7 @@ class ModalFilterFeats extends ModalFilter {
|
||||
<div class="ve-col-4 ${feat._versionBase_isVersion ? "italic" : ""} ${this._getNameStyle()}">${feat._versionBase_isVersion ? `<span class="px-3"></span>` : ""}${feat.name}</div>
|
||||
<span class="ve-col-3 ${feat._slAbility === VeCt.STR_NONE ? "italic" : ""}">${feat._slAbility}</span>
|
||||
<span class="ve-col-3 ${feat._slPrereq === VeCt.STR_NONE ? "italic" : ""}">${feat._slPrereq}</span>
|
||||
<div class="ve-col-1 pr-0 ve-flex-h-center ${Parser.sourceJsonToColor(feat.source)}" title="${Parser.sourceJsonToFull(feat.source)}" ${Parser.sourceJsonToStyle(feat.source)}>${source}${Parser.sourceJsonToMarkerHtml(feat.source)}</div>
|
||||
<div class="ve-col-1 pr-0 ve-flex-h-center ${Parser.sourceJsonToSourceClassname(feat.source)}" title="${Parser.sourceJsonToFull(feat.source)}" ${Parser.sourceJsonToStyle(feat.source)}>${source}${Parser.sourceJsonToMarkerHtml(feat.source)}</div>
|
||||
</div>`;
|
||||
|
||||
const btnShowHidePreview = eleRow.firstElementChild.children[1].firstElementChild;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterEquipment extends PageFilter {
|
||||
class PageFilterEquipment extends PageFilterBase {
|
||||
static _MISC_FILTER_ITEMS = [
|
||||
"Item Group",
|
||||
"Bundle",
|
||||
@@ -442,7 +442,7 @@ class PageFilterItems extends PageFilterEquipment {
|
||||
|
||||
globalThis.PageFilterItems = PageFilterItems;
|
||||
|
||||
class ModalFilterItems extends ModalFilter {
|
||||
class ModalFilterItems extends ModalFilterBase {
|
||||
/**
|
||||
* @param opts
|
||||
* @param opts.namespace
|
||||
@@ -465,7 +465,7 @@ class ModalFilterItems extends ModalFilter {
|
||||
{sort: "type", text: "Type", width: "6"},
|
||||
{sort: "source", text: "Source", width: "1"},
|
||||
];
|
||||
return ModalFilter._$getFilterColumnHeaders(btnMeta);
|
||||
return ModalFilterBase._$getFilterColumnHeaders(btnMeta);
|
||||
}
|
||||
|
||||
async _pInit () {
|
||||
@@ -502,7 +502,7 @@ class ModalFilterItems extends ModalFilter {
|
||||
|
||||
<div class="ve-col-5 ${item._versionBase_isVersion ? "italic" : ""} ${this._getNameStyle()}">${item._versionBase_isVersion ? `<span class="px-3"></span>` : ""}${item.name}</div>
|
||||
<div class="ve-col-5">${type.uppercaseFirst()}</div>
|
||||
<div class="ve-col-1 ve-flex-h-center ${Parser.sourceJsonToColor(item.source)} pr-0" title="${Parser.sourceJsonToFull(item.source)}" ${Parser.sourceJsonToStyle(item.source)}>${source}${Parser.sourceJsonToMarkerHtml(item.source)}</div>
|
||||
<div class="ve-col-1 ve-flex-h-center ${Parser.sourceJsonToSourceClassname(item.source)} pr-0" title="${Parser.sourceJsonToFull(item.source)}" ${Parser.sourceJsonToStyle(item.source)}>${source}${Parser.sourceJsonToMarkerHtml(item.source)}</div>
|
||||
</div>`;
|
||||
|
||||
const btnShowHidePreview = eleRow.firstElementChild.children[1].firstElementChild;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterLanguages extends PageFilter {
|
||||
class PageFilterLanguages extends PageFilterBase {
|
||||
constructor () {
|
||||
super();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterObjects extends PageFilter {
|
||||
class PageFilterObjects extends PageFilterBase {
|
||||
constructor () {
|
||||
super();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterOptionalFeatures extends PageFilter {
|
||||
class PageFilterOptionalFeatures extends PageFilterBase {
|
||||
// region static
|
||||
static _filterFeatureTypeSort (a, b) {
|
||||
return SortUtil.ascSort(Parser.optFeatureTypeToFull(a.item), Parser.optFeatureTypeToFull(b.item));
|
||||
@@ -170,7 +170,7 @@ class PageFilterOptionalFeatures extends PageFilter {
|
||||
|
||||
globalThis.PageFilterOptionalFeatures = PageFilterOptionalFeatures;
|
||||
|
||||
class ModalFilterOptionalFeatures extends ModalFilter {
|
||||
class ModalFilterOptionalFeatures extends ModalFilterBase {
|
||||
/**
|
||||
* @param opts
|
||||
* @param opts.namespace
|
||||
@@ -194,7 +194,7 @@ class ModalFilterOptionalFeatures extends ModalFilter {
|
||||
{sort: "level", text: "Level", width: "1"},
|
||||
{sort: "source", text: "Source", width: "1"},
|
||||
];
|
||||
return ModalFilter._$getFilterColumnHeaders(btnMeta);
|
||||
return ModalFilterBase._$getFilterColumnHeaders(btnMeta);
|
||||
}
|
||||
|
||||
async _pLoadAllData () {
|
||||
@@ -225,7 +225,7 @@ class ModalFilterOptionalFeatures extends ModalFilter {
|
||||
<span class="ve-col-2 ve-text-center" title="${optfeat._dFeatureType}">${optfeat._lFeatureType}</span>
|
||||
<span class="ve-col-4 ve-text-center">${prerequisite}</span>
|
||||
<span class="ve-col-1 ve-text-center">${level}</span>
|
||||
<div class="ve-col-1 pr-0 ve-flex-h-center ${Parser.sourceJsonToColor(optfeat.source)}" title="${Parser.sourceJsonToFull(optfeat.source)}" ${Parser.sourceJsonToStyle(optfeat.source)}>${source}${Parser.sourceJsonToMarkerHtml(optfeat.source)}</div>
|
||||
<div class="ve-col-1 pr-0 ve-flex-h-center ${Parser.sourceJsonToSourceClassname(optfeat.source)}" title="${Parser.sourceJsonToFull(optfeat.source)}" ${Parser.sourceJsonToStyle(optfeat.source)}>${source}${Parser.sourceJsonToMarkerHtml(optfeat.source)}</div>
|
||||
</div>`;
|
||||
|
||||
const btnShowHidePreview = eleRow.firstElementChild.children[1].firstElementChild;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterPsionics extends PageFilter {
|
||||
class PageFilterPsionics extends PageFilterBase {
|
||||
// region static
|
||||
static _sortFilterTypes (a, b) {
|
||||
a = a.item; b = b.item;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterRaces extends PageFilter {
|
||||
class PageFilterRaces extends PageFilterBase {
|
||||
// region static
|
||||
static getLanguageProficiencyTags (lProfs) {
|
||||
if (!lProfs) return [];
|
||||
@@ -233,7 +233,7 @@ class PageFilterRaces extends PageFilter {
|
||||
|
||||
globalThis.PageFilterRaces = PageFilterRaces;
|
||||
|
||||
class ModalFilterRaces extends ModalFilter {
|
||||
class ModalFilterRaces extends ModalFilterBase {
|
||||
/**
|
||||
* @param opts
|
||||
* @param opts.namespace
|
||||
@@ -256,7 +256,7 @@ class ModalFilterRaces extends ModalFilter {
|
||||
{sort: "size", text: "Size", width: "2"},
|
||||
{sort: "source", text: "Source", width: "1"},
|
||||
];
|
||||
return ModalFilter._$getFilterColumnHeaders(btnMeta);
|
||||
return ModalFilterBase._$getFilterColumnHeaders(btnMeta);
|
||||
}
|
||||
|
||||
async _pLoadAllData () {
|
||||
@@ -286,7 +286,7 @@ class ModalFilterRaces extends ModalFilter {
|
||||
<div class="ve-col-4 ${race._versionBase_isVersion ? "italic" : ""} ${this._getNameStyle()}">${race._versionBase_isVersion ? `<span class="px-3"></span>` : ""}${race.name}</div>
|
||||
<div class="ve-col-4">${ability.asTextShort}</div>
|
||||
<div class="ve-col-2 ve-text-center">${size}</div>
|
||||
<div class="ve-col-1 pr-0 ve-flex-h-center ${Parser.sourceJsonToColor(race.source)}" title="${Parser.sourceJsonToFull(race.source)}" ${Parser.sourceJsonToStyle(race.source)}>${source}${Parser.sourceJsonToMarkerHtml(race.source)}</div>
|
||||
<div class="ve-col-1 pr-0 ve-flex-h-center ${Parser.sourceJsonToSourceClassname(race.source)}" title="${Parser.sourceJsonToFull(race.source)}" ${Parser.sourceJsonToStyle(race.source)}>${source}${Parser.sourceJsonToMarkerHtml(race.source)}</div>
|
||||
</div>`;
|
||||
|
||||
const btnShowHidePreview = eleRow.firstElementChild.children[1].firstElementChild;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterRecipes extends PageFilter {
|
||||
class PageFilterRecipes extends PageFilterBase {
|
||||
static _DIET_TO_FULL = {
|
||||
"V": "Vegan",
|
||||
"C": "Vegetarian",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterRewards extends PageFilter {
|
||||
class PageFilterRewards extends PageFilterBase {
|
||||
constructor () {
|
||||
super();
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ MultiFilterClasses._DEFAULT_META = {
|
||||
isVariantSplit: false,
|
||||
};
|
||||
|
||||
class PageFilterSpells extends PageFilter {
|
||||
class PageFilterSpells extends PageFilterBase {
|
||||
// toss these into the "Tags" section to save screen space
|
||||
static _META_ADD_CONC = "Concentration";
|
||||
static _META_ADD_V = "Verbal";
|
||||
@@ -612,7 +612,7 @@ class PageFilterSpells extends PageFilter {
|
||||
|
||||
globalThis.PageFilterSpells = PageFilterSpells;
|
||||
|
||||
class ModalFilterSpells extends ModalFilter {
|
||||
class ModalFilterSpells extends ModalFilterBase {
|
||||
/**
|
||||
* @param opts
|
||||
* @param opts.namespace
|
||||
@@ -639,7 +639,7 @@ class ModalFilterSpells extends ModalFilter {
|
||||
{sort: "range", text: "Range", width: "2"},
|
||||
{sort: "source", text: "Source", width: "1"},
|
||||
];
|
||||
return ModalFilter._$getFilterColumnHeaders(btnMeta);
|
||||
return ModalFilterBase._$getFilterColumnHeaders(btnMeta);
|
||||
}
|
||||
|
||||
async _pInit () {
|
||||
@@ -680,7 +680,7 @@ class ModalFilterSpells extends ModalFilter {
|
||||
<div class="ve-col-1 sp__school-${spell.school} ve-text-center" title="${Parser.spSchoolAndSubschoolsAbvsToFull(spell.school, spell.subschools)}" ${Parser.spSchoolAbvToStyle(spell.school)}>${school}</div>
|
||||
<div class="ve-col-0-5 ve-text-center" title="Concentration">${concentration}</div>
|
||||
<div class="ve-col-2 text-right">${range}</div>
|
||||
<div class="ve-col-1 pr-0 ve-flex-h-center ${Parser.sourceJsonToColor(spell.source)}" title="${Parser.sourceJsonToFull(spell.source)}" ${Parser.sourceJsonToStyle(spell.source)}>${source}${Parser.sourceJsonToMarkerHtml(spell.source)}</div>
|
||||
<div class="ve-col-1 pr-0 ve-flex-h-center ${Parser.sourceJsonToSourceClassname(spell.source)}" title="${Parser.sourceJsonToFull(spell.source)}" ${Parser.sourceJsonToStyle(spell.source)}>${source}${Parser.sourceJsonToMarkerHtml(spell.source)}</div>
|
||||
</div>`;
|
||||
|
||||
const btnShowHidePreview = eleRow.firstElementChild.children[1].firstElementChild;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterTables extends PageFilter {
|
||||
class PageFilterTables extends PageFilterBase {
|
||||
// region static
|
||||
static _sourceSelFn (val) {
|
||||
return !SourceUtil.isNonstandardSource(val) && !SourceUtil.isAdventure(val);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterTrapsHazards extends PageFilter {
|
||||
class PageFilterTrapsHazards extends PageFilterBase {
|
||||
// region static
|
||||
static sortFilterType (a, b) {
|
||||
return SortUtil.ascSortLower(Parser.trapHazTypeToFull(a.item), Parser.trapHazTypeToFull(b.item));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterVariantRules extends PageFilter {
|
||||
class PageFilterVariantRules extends PageFilterBase {
|
||||
// region static
|
||||
// endregion
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
class PageFilterVehicles extends PageFilter {
|
||||
class PageFilterVehicles extends PageFilterBase {
|
||||
constructor () {
|
||||
super();
|
||||
|
||||
|
||||
5373
js/filter.js
5373
js/filter.js
File diff suppressed because it is too large
Load Diff
826
js/filter/filter-box.js
Normal file
826
js/filter/filter-box.js
Normal file
@@ -0,0 +1,826 @@
|
||||
import {EVNT_VALCHANGE, SOURCE_HEADER, SUB_HASH_PREFIX_LENGTH, TITLE_BTN_RESET} from "./filter-constants.js";
|
||||
import {FilterRegistry} from "./filter-registry.js";
|
||||
|
||||
export class FilterBox extends ProxyBase {
|
||||
static selectFirstVisible (entryList) {
|
||||
if (Hist.lastLoadedId == null && !Hist.initialLoad) {
|
||||
Hist._freshLoad();
|
||||
}
|
||||
|
||||
// This version deemed too annoying to be of practical use
|
||||
// Instead of always loading the URL, this would switch to the first visible item that matches the filter
|
||||
/*
|
||||
if (Hist.lastLoadedId && !Hist.initialLoad) {
|
||||
const last = entryList[Hist.lastLoadedId];
|
||||
const lastHash = UrlUtil.autoEncodeHash(last);
|
||||
const link = $("#listcontainer").find(`.list a[href="#${lastHash.toLowerCase()}"]`);
|
||||
if (!link.length) Hist._freshLoad();
|
||||
} else if (Hist.lastLoadedId == null && !Hist.initialLoad) {
|
||||
Hist._freshLoad();
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* @param opts Options object.
|
||||
* @param [opts.$wrpFormTop] Form input group.
|
||||
* @param opts.$btnReset Form reset button.
|
||||
* @param [opts.$btnOpen] A custom button to use to open the filter overlay.
|
||||
* @param [opts.$iptSearch] Search input associated with the "form" this filter is a part of. Only used for passing
|
||||
* through search terms in @filter tags.
|
||||
* @param [opts.$wrpMiniPills] Element to house mini pills.
|
||||
* @param [opts.$btnToggleSummaryHidden] Button which toggles the filter summary.
|
||||
* @param opts.filters Array of filters to be included in this box.
|
||||
* @param [opts.isCompact] True if this box should have a compact/reduced UI.
|
||||
* @param [opts.namespace] Namespace for this filter, to prevent collisions with other filters on the same page.
|
||||
*/
|
||||
constructor (opts) {
|
||||
super();
|
||||
|
||||
this._$iptSearch = opts.$iptSearch;
|
||||
this._$wrpFormTop = opts.$wrpFormTop;
|
||||
this._$btnReset = opts.$btnReset;
|
||||
this._$btnOpen = opts.$btnOpen;
|
||||
this._$wrpMiniPills = opts.$wrpMiniPills;
|
||||
this._$btnToggleSummaryHidden = opts.$btnToggleSummaryHidden;
|
||||
this._filters = opts.filters;
|
||||
this._isCompact = opts.isCompact;
|
||||
this._namespace = opts.namespace;
|
||||
|
||||
this._doSaveStateThrottled = MiscUtil.throttle(() => this._pDoSaveState(), 50);
|
||||
this.__meta = this._getDefaultMeta();
|
||||
if (this._isCompact) this.__meta.isSummaryHidden = true;
|
||||
|
||||
this._meta = this._getProxy("meta", this.__meta);
|
||||
this.__minisHidden = {};
|
||||
this._minisHidden = this._getProxy("minisHidden", this.__minisHidden);
|
||||
this.__combineAs = {};
|
||||
this._combineAs = this._getProxy("combineAs", this.__combineAs);
|
||||
this._modalMeta = null;
|
||||
this._isRendered = false;
|
||||
|
||||
this._cachedState = null;
|
||||
|
||||
this._compSearch = BaseComponent.fromObject({search: ""});
|
||||
this._metaIptSearch = null;
|
||||
|
||||
this._filters.forEach(f => f.filterBox = this);
|
||||
|
||||
this._eventListeners = {};
|
||||
}
|
||||
|
||||
get filters () { return this._filters; }
|
||||
|
||||
teardown () {
|
||||
this._filters.forEach(f => f._doTeardown());
|
||||
if (this._modalMeta) this._modalMeta.doTeardown();
|
||||
}
|
||||
|
||||
// region Event listeners
|
||||
on (identifier, fn) {
|
||||
const [eventName, namespace] = identifier.split(".");
|
||||
(this._eventListeners[eventName] = this._eventListeners[eventName] || []).push({namespace, fn});
|
||||
return this;
|
||||
}
|
||||
|
||||
off (identifier, fn = null) {
|
||||
const [eventName, namespace] = identifier.split(".");
|
||||
this._eventListeners[eventName] = (this._eventListeners[eventName] || []).filter(it => {
|
||||
if (fn != null) return it.namespace !== namespace || it.fn !== fn;
|
||||
return it.namespace !== namespace;
|
||||
});
|
||||
if (!this._eventListeners[eventName].length) delete this._eventListeners[eventName];
|
||||
return this;
|
||||
}
|
||||
|
||||
fireChangeEvent () {
|
||||
this._doSaveStateThrottled();
|
||||
this.fireEvent(EVNT_VALCHANGE);
|
||||
}
|
||||
|
||||
fireEvent (eventName) {
|
||||
(this._eventListeners[eventName] || []).forEach(it => it.fn());
|
||||
}
|
||||
// endregion
|
||||
|
||||
_getNamespacedStorageKey () { return `${FilterBox._STORAGE_KEY}${this._namespace ? `.${this._namespace}` : ""}`; }
|
||||
getNamespacedHashKey (k) { return `${k || "_".repeat(SUB_HASH_PREFIX_LENGTH)}${this._namespace ? `.${this._namespace}` : ""}`; }
|
||||
|
||||
async pGetStoredActiveSources () {
|
||||
const stored = await StorageUtil.pGetForPage(this._getNamespacedStorageKey());
|
||||
if (stored) {
|
||||
const sourceFilterData = stored.filters[SOURCE_HEADER];
|
||||
if (sourceFilterData) {
|
||||
const state = sourceFilterData.state;
|
||||
const blue = [];
|
||||
const white = [];
|
||||
Object.entries(state).forEach(([src, mode]) => {
|
||||
if (mode === 1) blue.push(src);
|
||||
else if (mode !== -1) white.push(src);
|
||||
});
|
||||
if (blue.length) return blue; // if some are selected, we load those
|
||||
else return white; // otherwise, we load non-red
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
registerMinisHiddenHook (prop, hook) {
|
||||
this._addHook("minisHidden", prop, hook);
|
||||
}
|
||||
|
||||
isMinisHidden (header) {
|
||||
return !!this._minisHidden[header];
|
||||
}
|
||||
|
||||
async pDoLoadState () {
|
||||
const toLoad = await StorageUtil.pGetForPage(this._getNamespacedStorageKey());
|
||||
if (toLoad == null) return;
|
||||
this._setStateFromLoaded(toLoad, {isUserSavedState: true});
|
||||
}
|
||||
|
||||
_setStateFromLoaded (state, {isUserSavedState = false} = {}) {
|
||||
state.box = state.box || {};
|
||||
this._proxyAssign("meta", "_meta", "__meta", state.box.meta || {}, true);
|
||||
this._proxyAssign("minisHidden", "_minisHidden", "__minisHidden", state.box.minisHidden || {}, true);
|
||||
this._proxyAssign("combineAs", "_combineAs", "__combineAs", state.box.combineAs || {}, true);
|
||||
this._filters.forEach(it => it.setStateFromLoaded(state.filters, {isUserSavedState}));
|
||||
}
|
||||
|
||||
_getSaveableState () {
|
||||
const filterOut = {};
|
||||
this._filters.forEach(it => Object.assign(filterOut, it.getSaveableState()));
|
||||
return {
|
||||
box: {
|
||||
meta: {...this.__meta},
|
||||
minisHidden: {...this.__minisHidden},
|
||||
combineAs: {...this.__combineAs},
|
||||
},
|
||||
filters: filterOut,
|
||||
};
|
||||
}
|
||||
|
||||
async _pDoSaveState () {
|
||||
await StorageUtil.pSetForPage(this._getNamespacedStorageKey(), this._getSaveableState());
|
||||
}
|
||||
|
||||
trimState_ () {
|
||||
this._filters.forEach(f => f.trimState_());
|
||||
}
|
||||
|
||||
render () {
|
||||
if (this._isRendered) {
|
||||
// already rendered previously; simply update the filters
|
||||
this._filters.map(f => f.update());
|
||||
return;
|
||||
}
|
||||
this._isRendered = true;
|
||||
|
||||
if (this._$wrpFormTop || this._$wrpMiniPills) {
|
||||
if (!this._$wrpMiniPills) {
|
||||
this._$wrpMiniPills = $(`<div class="fltr__mini-view btn-group"></div>`).insertAfter(this._$wrpFormTop);
|
||||
} else {
|
||||
this._$wrpMiniPills.addClass("fltr__mini-view");
|
||||
}
|
||||
}
|
||||
|
||||
if (this._$btnReset) {
|
||||
this._$btnReset
|
||||
.title(TITLE_BTN_RESET)
|
||||
.click((evt) => this.reset(evt.shiftKey));
|
||||
}
|
||||
|
||||
if (this._$wrpFormTop || this._$btnToggleSummaryHidden) {
|
||||
if (!this._$btnToggleSummaryHidden) {
|
||||
this._$btnToggleSummaryHidden = $(`<button class="btn btn-default ${this._isCompact ? "p-2" : ""}" title="Toggle Filter Summary"><span class="glyphicon glyphicon-resize-small"></span></button>`)
|
||||
.prependTo(this._$wrpFormTop);
|
||||
} else if (!this._$btnToggleSummaryHidden.parent().length) {
|
||||
this._$btnToggleSummaryHidden.prependTo(this._$wrpFormTop);
|
||||
}
|
||||
this._$btnToggleSummaryHidden
|
||||
.click(() => {
|
||||
this._meta.isSummaryHidden = !this._meta.isSummaryHidden;
|
||||
this._doSaveStateThrottled();
|
||||
});
|
||||
const summaryHiddenHook = () => {
|
||||
this._$btnToggleSummaryHidden.toggleClass("active", !!this._meta.isSummaryHidden);
|
||||
this._$wrpMiniPills.toggleClass("ve-hidden", !!this._meta.isSummaryHidden);
|
||||
};
|
||||
this._addHook("meta", "isSummaryHidden", summaryHiddenHook);
|
||||
summaryHiddenHook();
|
||||
}
|
||||
|
||||
if (this._$wrpFormTop || this._$btnOpen) {
|
||||
if (!this._$btnOpen) {
|
||||
this._$btnOpen = $(`<button class="btn btn-default ${this._isCompact ? "px-2" : ""}">Filter</button>`)
|
||||
.prependTo(this._$wrpFormTop);
|
||||
} else if (!this._$btnOpen.parent().length) {
|
||||
this._$btnOpen.prependTo(this._$wrpFormTop);
|
||||
}
|
||||
this._$btnOpen.click(() => this.show());
|
||||
}
|
||||
|
||||
const sourceFilter = this._filters.find(it => it.header === SOURCE_HEADER);
|
||||
if (sourceFilter) {
|
||||
const selFnAlt = (val) => !SourceUtil.isNonstandardSource(val) && !PrereleaseUtil.hasSourceJson(val) && !BrewUtil2.hasSourceJson(val);
|
||||
const hkSelFn = () => {
|
||||
if (this._meta.isBrewDefaultHidden) sourceFilter.setTempFnSel(selFnAlt);
|
||||
else sourceFilter.setTempFnSel(null);
|
||||
sourceFilter.updateMiniPillClasses();
|
||||
};
|
||||
this._addHook("meta", "isBrewDefaultHidden", hkSelFn);
|
||||
hkSelFn();
|
||||
}
|
||||
|
||||
if (this._$wrpMiniPills) this._filters.map((f, i) => f.$renderMinis({filterBox: this, isFirst: i === 0, $wrpMini: this._$wrpMiniPills}));
|
||||
}
|
||||
|
||||
async _render_pRenderModal () {
|
||||
this._isModalRendered = true;
|
||||
|
||||
this._modalMeta = await UiUtil.pGetShowModal({
|
||||
isHeight100: true,
|
||||
isWidth100: true,
|
||||
isUncappedHeight: true,
|
||||
isIndestructible: true,
|
||||
isClosed: true,
|
||||
isEmpty: true,
|
||||
title: "Filter", // Not shown due toe `isEmpty`, but useful for external overrides
|
||||
cbClose: (isDataEntered) => this._pHandleHide(!isDataEntered),
|
||||
});
|
||||
|
||||
const $children = this._filters.map((f, i) => f.$render({filterBox: this, isFirst: i === 0, $wrpMini: this._$wrpMiniPills}));
|
||||
|
||||
this._metaIptSearch = ComponentUiUtil.$getIptStr(
|
||||
this._compSearch, "search",
|
||||
{decorationRight: "clear", asMeta: true, html: `<input class="form-control input-xs" placeholder="Search...">`},
|
||||
);
|
||||
this._compSearch._addHookBase("search", () => {
|
||||
const searchTerm = this._compSearch._state.search.toLowerCase();
|
||||
this._filters.forEach(f => f.handleSearch(searchTerm));
|
||||
});
|
||||
|
||||
const $btnShowAllFilters = $(`<button class="btn btn-xs btn-default">Show All</button>`)
|
||||
.click(() => this.showAllFilters());
|
||||
const $btnHideAllFilters = $(`<button class="btn btn-xs btn-default">Hide All</button>`)
|
||||
.click(() => this.hideAllFilters());
|
||||
|
||||
const $btnReset = $(`<button class="btn btn-xs btn-default mr-3" title="${TITLE_BTN_RESET}">Reset</button>`)
|
||||
.click(evt => this.reset(evt.shiftKey));
|
||||
|
||||
const $btnSettings = $(`<button class="btn btn-xs btn-default mr-3"><span class="glyphicon glyphicon-cog"></span></button>`)
|
||||
.click(() => this._pOpenSettingsModal());
|
||||
|
||||
const $btnSaveAlt = $(`<button class="btn btn-xs btn-primary" title="Save"><span class="glyphicon glyphicon-ok"></span></button>`)
|
||||
.click(() => this._modalMeta.doClose(true));
|
||||
|
||||
const $wrpBtnCombineFilters = $(`<div class="btn-group mr-3"></div>`);
|
||||
const $btnCombineFilterSettings = $(`<button class="btn btn-xs btn-default"><span class="glyphicon glyphicon-cog"></span></button>`)
|
||||
.click(() => this._pOpenCombineAsModal());
|
||||
|
||||
const btnCombineFiltersAs = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xs btn-default`,
|
||||
click: () => this._meta.modeCombineFilters = FilterBox._COMBINE_MODES.getNext(this._meta.modeCombineFilters),
|
||||
title: `"AND" requires every filter to match. "OR" requires any filter to match. "Custom" allows you to specify a combination (every "AND" filter must match; only one "OR" filter must match) .`,
|
||||
}).appendTo($wrpBtnCombineFilters[0]);
|
||||
|
||||
const hook = () => {
|
||||
btnCombineFiltersAs.innerText = this._meta.modeCombineFilters === "custom" ? this._meta.modeCombineFilters.uppercaseFirst() : this._meta.modeCombineFilters.toUpperCase();
|
||||
if (this._meta.modeCombineFilters === "custom") $wrpBtnCombineFilters.append($btnCombineFilterSettings);
|
||||
else $btnCombineFilterSettings.detach();
|
||||
this._doSaveStateThrottled();
|
||||
};
|
||||
this._addHook("meta", "modeCombineFilters", hook);
|
||||
hook();
|
||||
|
||||
const $btnSave = $(`<button class="btn btn-primary fltr__btn-close mr-2">Save</button>`)
|
||||
.click(() => this._modalMeta.doClose(true));
|
||||
|
||||
const $btnCancel = $(`<button class="btn btn-default fltr__btn-close">Cancel</button>`)
|
||||
.click(() => this._modalMeta.doClose(false));
|
||||
|
||||
$$(this._modalMeta.$modal)`<div class="split mb-2 mt-2 ve-flex-v-center mobile__ve-flex-col">
|
||||
<div class="ve-flex-v-baseline mobile__ve-flex-col">
|
||||
<h4 class="m-0 mr-2 mobile__mb-2">Filters</h4>
|
||||
${this._metaIptSearch.$wrp.addClass("mobile__mb-2")}
|
||||
</div>
|
||||
<div class="ve-flex-v-center mobile__ve-flex-col">
|
||||
<div class="ve-flex-v-center mobile__m-1">
|
||||
<div class="mr-2">Combine as</div>
|
||||
${$wrpBtnCombineFilters}
|
||||
</div>
|
||||
<div class="ve-flex-v-center mobile__m-1">
|
||||
<div class="btn-group mr-2 ve-flex-h-center">
|
||||
${$btnShowAllFilters}
|
||||
${$btnHideAllFilters}
|
||||
</div>
|
||||
${$btnReset}
|
||||
${$btnSettings}
|
||||
${$btnSaveAlt}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="w-100 m-0 mb-2">
|
||||
|
||||
<hr class="mt-1 mb-1">
|
||||
<div class="ui-modal__scroller smooth-scroll px-1">
|
||||
${$children}
|
||||
</div>
|
||||
<hr class="my-1 w-100">
|
||||
<div class="w-100 ve-flex-vh-center my-1">${$btnSave}${$btnCancel}</div>`;
|
||||
}
|
||||
|
||||
async _pOpenSettingsModal () {
|
||||
const {$modalInner} = await UiUtil.pGetShowModal({title: "Settings"});
|
||||
|
||||
UiUtil.$getAddModalRowCb($modalInner, "Deselect Homebrew Sources by Default", this._meta, "isBrewDefaultHidden");
|
||||
|
||||
UiUtil.addModalSep($modalInner);
|
||||
|
||||
UiUtil.$getAddModalRowHeader($modalInner, "Hide summary for filter...", {helpText: "The summary is the small red and blue button panel which appear below the search bar."});
|
||||
this._filters.forEach(f => UiUtil.$getAddModalRowCb($modalInner, f.header, this._minisHidden, f.header));
|
||||
|
||||
UiUtil.addModalSep($modalInner);
|
||||
|
||||
const $rowResetAlwaysSave = UiUtil.$getAddModalRow($modalInner, "div").addClass("pr-2");
|
||||
$rowResetAlwaysSave.append(`<span>Always Save on Close</span>`);
|
||||
$(`<button class="btn btn-xs btn-default">Reset</button>`)
|
||||
.appendTo($rowResetAlwaysSave)
|
||||
.click(async () => {
|
||||
await StorageUtil.pRemove(FilterBox._STORAGE_KEY_ALWAYS_SAVE_UNCHANGED);
|
||||
JqueryUtil.doToast("Saved!");
|
||||
});
|
||||
}
|
||||
|
||||
async _pOpenCombineAsModal () {
|
||||
const {$modalInner} = await UiUtil.pGetShowModal({title: "Filter Combination Logic"});
|
||||
const $btnReset = $(`<button class="btn btn-xs btn-default">Reset</button>`)
|
||||
.click(() => {
|
||||
Object.keys(this._combineAs).forEach(k => this._combineAs[k] = "and");
|
||||
$sels.forEach($sel => $sel.val("0"));
|
||||
});
|
||||
UiUtil.$getAddModalRowHeader($modalInner, "Combine filters as...", {$eleRhs: $btnReset});
|
||||
const $sels = this._filters.map(f => UiUtil.$getAddModalRowSel($modalInner, f.header, this._combineAs, f.header, ["and", "or"], {fnDisplay: (it) => it.toUpperCase()}));
|
||||
}
|
||||
|
||||
getValues ({nxtStateOuter = null} = {}) {
|
||||
const outObj = {};
|
||||
this._filters.forEach(f => Object.assign(outObj, f.getValues({nxtState: nxtStateOuter?.filters})));
|
||||
return outObj;
|
||||
}
|
||||
|
||||
addEventListener (type, listener) {
|
||||
(this._$wrpFormTop ? this._$wrpFormTop[0] : this._$btnOpen[0]).addEventListener(type, listener);
|
||||
}
|
||||
|
||||
_mutNextState_reset_meta ({tgt}) {
|
||||
Object.assign(tgt, this._getDefaultMeta());
|
||||
}
|
||||
|
||||
_mutNextState_minisHidden ({tgt}) {
|
||||
Object.assign(tgt, this._getDefaultMinisHidden(tgt));
|
||||
}
|
||||
|
||||
_mutNextState_combineAs ({tgt}) {
|
||||
Object.assign(tgt, this._getDefaultCombineAs(tgt));
|
||||
}
|
||||
|
||||
_reset_meta () {
|
||||
const nxtBoxState = this._getNextBoxState_base();
|
||||
this._mutNextState_reset_meta({tgt: nxtBoxState.meta});
|
||||
this._setBoxStateFromNextBoxState(nxtBoxState);
|
||||
}
|
||||
|
||||
_reset_minisHidden () {
|
||||
const nxtBoxState = this._getNextBoxState_base();
|
||||
this._mutNextState_minisHidden({tgt: nxtBoxState.minisHidden});
|
||||
this._setBoxStateFromNextBoxState(nxtBoxState);
|
||||
}
|
||||
|
||||
_reset_combineAs () {
|
||||
const nxtBoxState = this._getNextBoxState_base();
|
||||
this._mutNextState_combineAs({tgt: nxtBoxState.combineAs});
|
||||
this._setBoxStateFromNextBoxState(nxtBoxState);
|
||||
}
|
||||
|
||||
reset (isResetAll) {
|
||||
this._filters.forEach(f => f.reset({isResetAll}));
|
||||
if (isResetAll) {
|
||||
this._reset_meta();
|
||||
this._reset_minisHidden();
|
||||
this._reset_combineAs();
|
||||
}
|
||||
this.render();
|
||||
this.fireChangeEvent();
|
||||
}
|
||||
|
||||
async show () {
|
||||
if (!this._isModalRendered) await this._render_pRenderModal();
|
||||
this._cachedState = this._getSaveableState();
|
||||
this._modalMeta.doOpen();
|
||||
if (this._metaIptSearch?.$ipt) this._metaIptSearch.$ipt.focus();
|
||||
}
|
||||
|
||||
async _pHandleHide (isCancel = false) {
|
||||
if (this._cachedState && isCancel) {
|
||||
const curState = this._getSaveableState();
|
||||
const hasChanges = !CollectionUtil.deepEquals(curState, this._cachedState);
|
||||
|
||||
if (hasChanges) {
|
||||
const isSave = await InputUiUtil.pGetUserBoolean({
|
||||
title: "Unsaved Changes",
|
||||
textYesRemember: "Always Save",
|
||||
textYes: "Save",
|
||||
textNo: "Discard",
|
||||
storageKey: FilterBox._STORAGE_KEY_ALWAYS_SAVE_UNCHANGED,
|
||||
isGlobal: true,
|
||||
});
|
||||
if (isSave) {
|
||||
this._cachedState = null;
|
||||
this.fireChangeEvent();
|
||||
return;
|
||||
} else this._setStateFromLoaded(this._cachedState, {isUserSavedState: true});
|
||||
}
|
||||
} else {
|
||||
this.fireChangeEvent();
|
||||
}
|
||||
|
||||
this._cachedState = null;
|
||||
}
|
||||
|
||||
showAllFilters () {
|
||||
this._filters.forEach(f => f.show());
|
||||
}
|
||||
|
||||
hideAllFilters () {
|
||||
this._filters.forEach(f => f.hide());
|
||||
}
|
||||
|
||||
unpackSubHashes (subHashes, {force = false} = {}) {
|
||||
// TODO(unpack) refactor
|
||||
const unpacked = {};
|
||||
subHashes.forEach(s => {
|
||||
const unpackedPart = UrlUtil.unpackSubHash(s, true);
|
||||
if (Object.keys(unpackedPart).length > 1) throw new Error(`Multiple keys in subhash!`);
|
||||
const k = Object.keys(unpackedPart)[0];
|
||||
unpackedPart[k] = {clean: unpackedPart[k], raw: s};
|
||||
Object.assign(unpacked, unpackedPart);
|
||||
});
|
||||
|
||||
const urlHeaderToFilter = {};
|
||||
this._filters.forEach(f => {
|
||||
const childFilters = f.getChildFilters();
|
||||
if (childFilters.length) childFilters.forEach(f => urlHeaderToFilter[f.header.toLowerCase()] = f);
|
||||
urlHeaderToFilter[f.header.toLowerCase()] = f;
|
||||
});
|
||||
|
||||
const urlHeadersUpdated = new Set();
|
||||
const subHashesConsumed = new Set();
|
||||
let filterInitialSearch;
|
||||
|
||||
const filterBoxState = {};
|
||||
const statePerFilter = {};
|
||||
const prefixLen = this.getNamespacedHashKey().length;
|
||||
Object.entries(unpacked)
|
||||
.forEach(([hashKey, data]) => {
|
||||
const rawPrefix = hashKey.substring(0, prefixLen);
|
||||
const prefix = rawPrefix.substring(0, SUB_HASH_PREFIX_LENGTH);
|
||||
|
||||
const urlHeader = hashKey.substring(prefixLen);
|
||||
|
||||
if (FilterRegistry.SUB_HASH_PREFIXES.has(prefix) && urlHeaderToFilter[urlHeader]) {
|
||||
(statePerFilter[urlHeader] = statePerFilter[urlHeader] || {})[prefix] = data.clean;
|
||||
urlHeadersUpdated.add(urlHeader);
|
||||
subHashesConsumed.add(data.raw);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.values(FilterBox._SUB_HASH_PREFIXES).includes(prefix)) {
|
||||
// special case for the search """state"""
|
||||
if (prefix === VeCt.FILTER_BOX_SUB_HASH_SEARCH_PREFIX) filterInitialSearch = data.clean[0];
|
||||
else filterBoxState[prefix] = data.clean;
|
||||
subHashesConsumed.add(data.raw);
|
||||
return;
|
||||
}
|
||||
|
||||
if (FilterRegistry.SUB_HASH_PREFIXES.has(prefix)) throw new Error(`Could not find filter with header ${urlHeader} for subhash ${data.raw}`);
|
||||
});
|
||||
|
||||
if (!subHashesConsumed.size && !force) return null;
|
||||
|
||||
return {
|
||||
urlHeaderToFilter,
|
||||
filterBoxState,
|
||||
statePerFilter,
|
||||
urlHeadersUpdated,
|
||||
unpacked,
|
||||
subHashesConsumed,
|
||||
filterInitialSearch,
|
||||
};
|
||||
}
|
||||
|
||||
setFromSubHashes (subHashes, {force = false, $iptSearch = null} = {}) {
|
||||
const unpackedSubhashes = this.unpackSubHashes(subHashes, {force});
|
||||
|
||||
if (unpackedSubhashes == null) return subHashes;
|
||||
|
||||
const {
|
||||
unpacked,
|
||||
subHashesConsumed,
|
||||
filterInitialSearch,
|
||||
} = unpackedSubhashes;
|
||||
|
||||
// region Update filter state
|
||||
const {box: nxtStateBox, filters: nxtStatesFilters} = this.getNextStateFromSubHashes({unpackedSubhashes});
|
||||
|
||||
this._setBoxStateFromNextBoxState(nxtStateBox);
|
||||
|
||||
this._filters
|
||||
.flatMap(f => [
|
||||
f,
|
||||
...f.getChildFilters(),
|
||||
])
|
||||
.filter(filter => nxtStatesFilters[filter.header])
|
||||
.forEach(filter => filter.setStateFromNextState(nxtStatesFilters));
|
||||
// endregion
|
||||
|
||||
// region Update search input value
|
||||
if (filterInitialSearch && ($iptSearch || this._$iptSearch)) ($iptSearch || this._$iptSearch).val(filterInitialSearch).change().keydown().keyup().trigger("instantKeyup");
|
||||
// endregion
|
||||
|
||||
// region Re-assemble and return remaining subhashes
|
||||
const [link] = Hist.getHashParts();
|
||||
|
||||
const outSub = [];
|
||||
Object.values(unpacked)
|
||||
.filter(v => !subHashesConsumed.has(v.raw))
|
||||
.forEach(v => outSub.push(v.raw));
|
||||
|
||||
Hist.setSuppressHistory(true);
|
||||
Hist.replaceHistoryHash(`${link}${outSub.length ? `${HASH_PART_SEP}${outSub.join(HASH_PART_SEP)}` : ""}`);
|
||||
|
||||
this.fireChangeEvent();
|
||||
Hist.hashChange({isBlankFilterLoad: true});
|
||||
return outSub;
|
||||
// endregion
|
||||
}
|
||||
|
||||
getNextStateFromSubHashes ({unpackedSubhashes}) {
|
||||
const {
|
||||
urlHeaderToFilter,
|
||||
filterBoxState,
|
||||
statePerFilter,
|
||||
urlHeadersUpdated,
|
||||
} = unpackedSubhashes;
|
||||
|
||||
const nxtStateBox = this._getNextBoxStateFromSubHashes(urlHeaderToFilter, filterBoxState);
|
||||
|
||||
const nxtStateFilters = {};
|
||||
|
||||
Object.entries(statePerFilter)
|
||||
.forEach(([urlHeader, state]) => {
|
||||
const filter = urlHeaderToFilter[urlHeader];
|
||||
Object.assign(nxtStateFilters, filter.getNextStateFromSubhashState(state));
|
||||
});
|
||||
|
||||
// reset any other state/meta state/etc
|
||||
Object.keys(urlHeaderToFilter)
|
||||
.filter(k => !urlHeadersUpdated.has(k))
|
||||
.forEach(k => {
|
||||
const filter = urlHeaderToFilter[k];
|
||||
Object.assign(nxtStateFilters, filter.getNextStateFromSubhashState(null));
|
||||
});
|
||||
|
||||
return {box: nxtStateBox, filters: nxtStateFilters};
|
||||
}
|
||||
|
||||
_getNextBoxState_base () {
|
||||
return {
|
||||
meta: MiscUtil.copyFast(this.__meta),
|
||||
minisHidden: MiscUtil.copyFast(this.__minisHidden),
|
||||
combineAs: MiscUtil.copyFast(this.__combineAs),
|
||||
};
|
||||
}
|
||||
|
||||
_getNextBoxStateFromSubHashes (urlHeaderToFilter, filterBoxState) {
|
||||
const nxtBoxState = this._getNextBoxState_base();
|
||||
|
||||
let hasMeta = false;
|
||||
let hasMinisHidden = false;
|
||||
let hasCombineAs = false;
|
||||
|
||||
Object.entries(filterBoxState).forEach(([k, vals]) => {
|
||||
const mappedK = this.getNamespacedHashKey(Parser._parse_bToA(FilterBox._SUB_HASH_PREFIXES, k));
|
||||
switch (mappedK) {
|
||||
case "meta": {
|
||||
hasMeta = true;
|
||||
const data = vals.map(v => UrlUtil.mini.decompress(v));
|
||||
Object.keys(this._getDefaultMeta()).forEach((k, i) => nxtBoxState.meta[k] = data[i]);
|
||||
break;
|
||||
}
|
||||
case "minisHidden": {
|
||||
hasMinisHidden = true;
|
||||
Object.keys(nxtBoxState.minisHidden).forEach(k => nxtBoxState.minisHidden[k] = false);
|
||||
vals.forEach(v => {
|
||||
const [urlHeader, isHidden] = v.split("=");
|
||||
const filter = urlHeaderToFilter[urlHeader];
|
||||
if (!filter) throw new Error(`Could not find filter with name "${urlHeader}"`);
|
||||
nxtBoxState.minisHidden[filter.header] = !!Number(isHidden);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "combineAs": {
|
||||
hasCombineAs = true;
|
||||
Object.keys(nxtBoxState.combineAs).forEach(k => nxtBoxState.combineAs[k] = "and");
|
||||
vals.forEach(v => {
|
||||
const [urlHeader, ixCombineMode] = v.split("=");
|
||||
const filter = urlHeaderToFilter[urlHeader];
|
||||
if (!filter) throw new Error(`Could not find filter with name "${urlHeader}"`);
|
||||
nxtBoxState.combineAs[filter.header] = FilterBox._COMBINE_MODES[ixCombineMode] || FilterBox._COMBINE_MODES[0];
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasMeta) this._mutNextState_reset_meta({tgt: nxtBoxState.meta});
|
||||
if (!hasMinisHidden) this._mutNextState_minisHidden({tgt: nxtBoxState.minisHidden});
|
||||
if (!hasCombineAs) this._mutNextState_combineAs({tgt: nxtBoxState.combineAs});
|
||||
|
||||
return nxtBoxState;
|
||||
}
|
||||
|
||||
_setBoxStateFromNextBoxState (nxtBoxState) {
|
||||
this._proxyAssignSimple("meta", nxtBoxState.meta, true);
|
||||
this._proxyAssignSimple("minisHidden", nxtBoxState.minisHidden, true);
|
||||
this._proxyAssignSimple("combineAs", nxtBoxState.combineAs, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param [opts] Options object.
|
||||
* @param [opts.isAddSearchTerm] If the active search should be added to the subhashes.
|
||||
*/
|
||||
getSubHashes (opts) {
|
||||
opts = opts || {};
|
||||
const out = [];
|
||||
const boxSubHashes = this.getBoxSubHashes();
|
||||
if (boxSubHashes) out.push(boxSubHashes);
|
||||
out.push(...this._filters.map(f => f.getSubHashes()).filter(Boolean));
|
||||
if (opts.isAddSearchTerm && this._$iptSearch) {
|
||||
const searchTerm = UrlUtil.encodeForHash(this._$iptSearch.val().trim());
|
||||
if (searchTerm) out.push(UrlUtil.packSubHash(this._getSubhashPrefix("search"), [searchTerm]));
|
||||
}
|
||||
return out.flat();
|
||||
}
|
||||
|
||||
getBoxSubHashes () {
|
||||
const out = [];
|
||||
|
||||
const defaultMeta = this._getDefaultMeta();
|
||||
|
||||
// serialize base meta in a set order
|
||||
const anyNotDefault = Object.keys(defaultMeta).find(k => this._meta[k] !== defaultMeta[k]);
|
||||
if (anyNotDefault) {
|
||||
const serMeta = Object.keys(defaultMeta).map(k => UrlUtil.mini.compress(this._meta[k] === undefined ? defaultMeta[k] : this._meta[k]));
|
||||
out.push(UrlUtil.packSubHash(this._getSubhashPrefix("meta"), serMeta));
|
||||
}
|
||||
|
||||
// serialize minisHidden as `key=value` pairs
|
||||
const setMinisHidden = Object.entries(this._minisHidden).filter(([k, v]) => !!v).map(([k]) => `${k.toUrlified()}=1`);
|
||||
if (setMinisHidden.length) {
|
||||
out.push(UrlUtil.packSubHash(this._getSubhashPrefix("minisHidden"), setMinisHidden));
|
||||
}
|
||||
|
||||
// serialize combineAs as `key=value` pairs
|
||||
const setCombineAs = Object.entries(this._combineAs).filter(([k, v]) => v !== FilterBox._COMBINE_MODES[0]).map(([k, v]) => `${k.toUrlified()}=${FilterBox._COMBINE_MODES.indexOf(v)}`);
|
||||
if (setCombineAs.length) {
|
||||
out.push(UrlUtil.packSubHash(this._getSubhashPrefix("combineAs"), setCombineAs));
|
||||
}
|
||||
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
getFilterTag ({isAddSearchTerm = false} = {}) {
|
||||
const parts = this._filters.map(f => f.getFilterTagPart()).filter(Boolean);
|
||||
if (isAddSearchTerm && this._$iptSearch) {
|
||||
const term = this._$iptSearch.val().trim();
|
||||
if (term) parts.push(`search=${term}`);
|
||||
}
|
||||
return `{@filter |${UrlUtil.getCurrentPage().replace(/\.html$/, "")}|${parts.join("|")}}`;
|
||||
}
|
||||
|
||||
getDisplayState ({nxtStateOuter = null} = {}) {
|
||||
return this._filters
|
||||
.map(filter => filter.getDisplayStatePart({nxtState: nxtStateOuter?.filters}))
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
setFromValues (values) {
|
||||
this._filters.forEach(it => it.setFromValues(values));
|
||||
this.fireChangeEvent();
|
||||
}
|
||||
|
||||
toDisplay (boxState, ...entryVals) {
|
||||
return this._toDisplay(boxState, this._filters, entryVals);
|
||||
}
|
||||
|
||||
/** `filterToValueTuples` should be an array of `{filter: <Filter>, value: <Any>}` objects */
|
||||
toDisplayByFilters (boxState, ...filterToValueTuples) {
|
||||
return this._toDisplay(
|
||||
boxState,
|
||||
filterToValueTuples.map(it => it.filter),
|
||||
filterToValueTuples.map(it => it.value),
|
||||
);
|
||||
}
|
||||
|
||||
_toDisplay (boxState, filters, entryVals) {
|
||||
switch (this._meta.modeCombineFilters) {
|
||||
case "and": return this._toDisplay_isAndDisplay(boxState, filters, entryVals);
|
||||
case "or": return this._toDisplay_isOrDisplay(boxState, filters, entryVals);
|
||||
case "custom": {
|
||||
if (entryVals.length !== filters.length) throw new Error(`Number of filters and number of values did not match!`);
|
||||
|
||||
const andFilters = [];
|
||||
const andValues = [];
|
||||
const orFilters = [];
|
||||
const orValues = [];
|
||||
|
||||
for (let i = 0; i < filters.length; ++i) {
|
||||
const f = filters[i];
|
||||
if (!this._combineAs[f.header] || this._combineAs[f.header] === "and") { // default to "and" if undefined
|
||||
andFilters.push(f);
|
||||
andValues.push(entryVals[i]);
|
||||
} else {
|
||||
orFilters.push(f);
|
||||
orValues.push(entryVals[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return this._toDisplay_isAndDisplay(boxState, andFilters, andValues) && this._toDisplay_isOrDisplay(boxState, orFilters, orValues);
|
||||
}
|
||||
default: throw new Error(`Unhandled combining mode "${this._meta.modeCombineFilters}"`);
|
||||
}
|
||||
}
|
||||
|
||||
_toDisplay_isAndDisplay (boxState, filters, vals) {
|
||||
return filters
|
||||
.map((f, i) => f.toDisplay(boxState, vals[i]))
|
||||
.every(it => it);
|
||||
}
|
||||
|
||||
_toDisplay_isOrDisplay (boxState, filters, vals) {
|
||||
const res = filters.map((f, i) => {
|
||||
// filter out "ignored" filter (i.e. all white)
|
||||
if (!f.isActive(boxState)) return null;
|
||||
return f.toDisplay(boxState, vals[i]);
|
||||
}).filter(it => it != null);
|
||||
return res.length === 0 || res.find(it => it);
|
||||
}
|
||||
|
||||
_getSubhashPrefix (prop) {
|
||||
if (FilterBox._SUB_HASH_PREFIXES[prop]) return this.getNamespacedHashKey(FilterBox._SUB_HASH_PREFIXES[prop]);
|
||||
throw new Error(`Unknown property "${prop}"`);
|
||||
}
|
||||
|
||||
_getDefaultMeta () {
|
||||
const out = MiscUtil.copy(FilterBox._DEFAULT_META);
|
||||
if (this._isCompact) out.isSummaryHidden = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
_getDefaultMinisHidden (minisHidden) {
|
||||
if (!minisHidden) throw new Error(`Missing "minisHidden" argument!`);
|
||||
return Object.keys(minisHidden)
|
||||
.mergeMap(k => ({[k]: false}));
|
||||
}
|
||||
|
||||
_getDefaultCombineAs (combineAs) {
|
||||
if (!combineAs) throw new Error(`Missing "combineAs" argument!`);
|
||||
return Object.keys(combineAs)
|
||||
.mergeMap(k => ({[k]: "and"}));
|
||||
}
|
||||
}
|
||||
FilterBox._PILL_STATES = ["ignore", "yes", "no"];
|
||||
FilterBox._COMBINE_MODES = ["and", "or", "custom"];
|
||||
FilterBox._STORAGE_KEY = "filterBoxState";
|
||||
FilterBox._DEFAULT_META = {
|
||||
modeCombineFilters: "and",
|
||||
isSummaryHidden: false,
|
||||
isBrewDefaultHidden: false,
|
||||
};
|
||||
FilterBox._STORAGE_KEY_ALWAYS_SAVE_UNCHANGED = "filterAlwaysSaveUnchanged";
|
||||
|
||||
// These are assumed to be the same length (4 characters)
|
||||
FilterBox._SUB_HASH_BOX_META_PREFIX = "fbmt";
|
||||
FilterBox._SUB_HASH_BOX_MINIS_HIDDEN_PREFIX = "fbmh";
|
||||
FilterBox._SUB_HASH_BOX_COMBINE_AS_PREFIX = "fbca";
|
||||
FilterBox._SUB_HASH_PREFIXES = {
|
||||
meta: FilterBox._SUB_HASH_BOX_META_PREFIX,
|
||||
minisHidden: FilterBox._SUB_HASH_BOX_MINIS_HIDDEN_PREFIX,
|
||||
combineAs: FilterBox._SUB_HASH_BOX_COMBINE_AS_PREFIX,
|
||||
search: VeCt.FILTER_BOX_SUB_HASH_SEARCH_PREFIX,
|
||||
};
|
||||
|
||||
FilterRegistry.registerSubhashes(Object.values(FilterBox._SUB_HASH_PREFIXES));
|
||||
5
js/filter/filter-constants.js
Normal file
5
js/filter/filter-constants.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export const SUB_HASH_PREFIX_LENGTH = 4;
|
||||
|
||||
export const EVNT_VALCHANGE = "valchange";
|
||||
export const SOURCE_HEADER = "Source";
|
||||
export const TITLE_BTN_RESET = "Reset filters. SHIFT to reset everything.";
|
||||
25
js/filter/filter-item.js
Normal file
25
js/filter/filter-item.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export class FilterItem {
|
||||
/**
|
||||
* An alternative to string `Filter.items` with a change-handling function
|
||||
* @param options containing:
|
||||
* @param options.item the item string
|
||||
* @param [options.pFnChange] (optional) function to call when filter is changed
|
||||
* @param [options.group] (optional) group this item belongs to.
|
||||
* @param [options.nest] (optional) nest this item belongs to
|
||||
* @param [options.nestHidden] (optional) if nested, default visibility state
|
||||
* @param [options.isIgnoreRed] (optional) if this item should be ignored when negative filtering
|
||||
* @param [options.userData] (optional) extra data to be stored as part of the item
|
||||
*/
|
||||
constructor (options) {
|
||||
this.item = options.item;
|
||||
this.pFnChange = options.pFnChange;
|
||||
this.group = options.group;
|
||||
this.nest = options.nest;
|
||||
this.nestHidden = options.nestHidden;
|
||||
this.isIgnoreRed = options.isIgnoreRed;
|
||||
this.userData = options.userData;
|
||||
|
||||
this.rendered = null;
|
||||
this.searchText = null;
|
||||
}
|
||||
}
|
||||
298
js/filter/filter-modal-filter-base.js
Normal file
298
js/filter/filter-modal-filter-base.js
Normal file
@@ -0,0 +1,298 @@
|
||||
import {EVNT_VALCHANGE} from "./filter-constants.js";
|
||||
|
||||
export class ModalFilterBase {
|
||||
static _$getFilterColumnHeaders (btnMeta) {
|
||||
return btnMeta.map((it, i) => $(`<button class="ve-col-${it.width} ${i === 0 ? "pl-0" : i === btnMeta.length ? "pr-0" : ""} ${it.disabled ? "" : "sort"} btn btn-default btn-xs" ${it.disabled ? "" : `data-sort="${it.sort}"`} ${it.title ? `title="${it.title}"` : ""} ${it.disabled ? "disabled" : ""}>${it.text}</button>`));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param opts Options object.
|
||||
* @param opts.modalTitle
|
||||
* @param opts.fnSort
|
||||
* @param opts.pageFilter
|
||||
* @param [opts.namespace]
|
||||
* @param [opts.allData]
|
||||
*/
|
||||
constructor (opts) {
|
||||
this._modalTitle = opts.modalTitle;
|
||||
this._fnSort = opts.fnSort;
|
||||
this._pageFilter = opts.pageFilter;
|
||||
this._namespace = opts.namespace;
|
||||
this._allData = opts.allData || null;
|
||||
this._isRadio = !!opts.isRadio;
|
||||
|
||||
this._list = null;
|
||||
this._filterCache = null;
|
||||
}
|
||||
|
||||
get pageFilter () { return this._pageFilter; }
|
||||
|
||||
get allData () { return this._allData; }
|
||||
|
||||
_$getWrpList () { return $(`<div class="list ui-list__wrp ve-overflow-x-hidden ve-overflow-y-auto h-100 min-h-0"></div>`); }
|
||||
|
||||
_$getColumnHeaderPreviewAll (opts) {
|
||||
return $(`<button class="btn btn-default btn-xs ${opts.isBuildUi ? "ve-col-1" : "ve-col-0-5"}">${ListUiUtil.HTML_GLYPHICON_EXPAND}</button>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $wrp
|
||||
* @param opts
|
||||
* @param opts.$iptSearch
|
||||
* @param opts.$btnReset
|
||||
* @param opts.$btnOpen
|
||||
* @param opts.$btnToggleSummaryHidden
|
||||
* @param opts.$wrpMiniPills
|
||||
* @param opts.isBuildUi If an alternate UI should be used, which has "send to right" buttons.
|
||||
*/
|
||||
async pPopulateWrapper ($wrp, opts) {
|
||||
opts = opts || {};
|
||||
|
||||
await this._pInit();
|
||||
|
||||
const $ovlLoading = $(`<div class="w-100 h-100 ve-flex-vh-center"><i class="dnd-font ve-muted">Loading...</i></div>`).appendTo($wrp);
|
||||
|
||||
const $iptSearch = (opts.$iptSearch || $(`<input class="form-control lst__search lst__search--no-border-h h-100" type="search" placeholder="Search...">`)).disableSpellcheck();
|
||||
const $btnReset = opts.$btnReset || $(`<button class="btn btn-default">Reset</button>`);
|
||||
const $dispNumVisible = $(`<div class="lst__wrp-search-visible no-events ve-flex-vh-center"></div>`);
|
||||
|
||||
const $wrpIptSearch = $$`<div class="w-100 relative">
|
||||
${$iptSearch}
|
||||
<div class="lst__wrp-search-glass no-events ve-flex-vh-center"><span class="glyphicon glyphicon-search"></span></div>
|
||||
${$dispNumVisible}
|
||||
</div>`;
|
||||
|
||||
const $wrpFormTop = $$`<div class="ve-flex input-group btn-group w-100 lst__form-top">${$wrpIptSearch}${$btnReset}</div>`;
|
||||
|
||||
const $wrpFormBottom = opts.$wrpMiniPills || $(`<div class="w-100"></div>`);
|
||||
|
||||
const $wrpFormHeaders = $(`<div class="input-group input-group--bottom ve-flex no-shrink"></div>`);
|
||||
const $cbSelAll = opts.isBuildUi || this._isRadio ? null : $(`<input type="checkbox">`);
|
||||
const $btnSendAllToRight = opts.isBuildUi ? $(`<button class="btn btn-xxs btn-default ve-col-1" title="Add All"><span class="glyphicon glyphicon-arrow-right"></span></button>`) : null;
|
||||
|
||||
if (!opts.isBuildUi) {
|
||||
if (this._isRadio) $wrpFormHeaders.append(`<label class="btn btn-default btn-xs ve-col-0-5 ve-flex-vh-center" disabled></label>`);
|
||||
else $$`<label class="btn btn-default btn-xs ve-col-0-5 ve-flex-vh-center">${$cbSelAll}</label>`.appendTo($wrpFormHeaders);
|
||||
}
|
||||
|
||||
const $btnTogglePreviewAll = this._$getColumnHeaderPreviewAll(opts)
|
||||
.appendTo($wrpFormHeaders);
|
||||
|
||||
this._$getColumnHeaders().forEach($ele => $wrpFormHeaders.append($ele));
|
||||
if (opts.isBuildUi) $btnSendAllToRight.appendTo($wrpFormHeaders);
|
||||
|
||||
const $wrpForm = $$`<div class="ve-flex-col w-100 mb-1">${$wrpFormTop}${$wrpFormBottom}${$wrpFormHeaders}</div>`;
|
||||
const $wrpList = this._$getWrpList();
|
||||
|
||||
const $btnConfirm = opts.isBuildUi ? null : $(`<button class="btn btn-default">Confirm</button>`);
|
||||
|
||||
this._list = new List({
|
||||
$iptSearch,
|
||||
$wrpList,
|
||||
fnSort: this._fnSort,
|
||||
});
|
||||
const listSelectClickHandler = new ListSelectClickHandler({list: this._list});
|
||||
|
||||
if (!opts.isBuildUi && !this._isRadio) listSelectClickHandler.bindSelectAllCheckbox($cbSelAll);
|
||||
ListUiUtil.bindPreviewAllButton($btnTogglePreviewAll, this._list);
|
||||
SortUtil.initBtnSortHandlers($wrpFormHeaders, this._list);
|
||||
this._list.on("updated", () => $dispNumVisible.html(`${this._list.visibleItems.length}/${this._list.items.length}`));
|
||||
|
||||
this._allData = this._allData || await this._pLoadAllData();
|
||||
|
||||
await this._pageFilter.pInitFilterBox({
|
||||
$wrpFormTop,
|
||||
$btnReset,
|
||||
$wrpMiniPills: $wrpFormBottom,
|
||||
namespace: this._namespace,
|
||||
$btnOpen: opts.$btnOpen,
|
||||
$btnToggleSummaryHidden: opts.$btnToggleSummaryHidden,
|
||||
});
|
||||
|
||||
this._allData.forEach((it, i) => {
|
||||
this._pageFilter.mutateAndAddToFilters(it);
|
||||
const filterListItem = this._getListItem(this._pageFilter, it, i);
|
||||
this._list.addItem(filterListItem);
|
||||
if (!opts.isBuildUi) {
|
||||
if (this._isRadio) filterListItem.ele.addEventListener("click", evt => listSelectClickHandler.handleSelectClickRadio(filterListItem, evt));
|
||||
else filterListItem.ele.addEventListener("click", evt => listSelectClickHandler.handleSelectClick(filterListItem, evt));
|
||||
}
|
||||
});
|
||||
|
||||
this._list.init();
|
||||
this._list.update();
|
||||
|
||||
const handleFilterChange = () => {
|
||||
const f = this._pageFilter.filterBox.getValues();
|
||||
this._list.filter(li => this._isListItemMatchingFilter(f, li));
|
||||
};
|
||||
|
||||
this._pageFilter.trimState();
|
||||
|
||||
this._pageFilter.filterBox.on(EVNT_VALCHANGE, handleFilterChange);
|
||||
this._pageFilter.filterBox.render();
|
||||
handleFilterChange();
|
||||
|
||||
$ovlLoading.remove();
|
||||
|
||||
const $wrpInner = $$`<div class="ve-flex-col h-100">
|
||||
${$wrpForm}
|
||||
${$wrpList}
|
||||
${opts.isBuildUi ? null : $$`<hr class="hr-1"><div class="ve-flex-vh-center">${$btnConfirm}</div>`}
|
||||
</div>`.appendTo($wrp.empty());
|
||||
|
||||
return {
|
||||
$wrpIptSearch,
|
||||
$iptSearch,
|
||||
$wrpInner,
|
||||
$btnConfirm,
|
||||
pageFilter: this._pageFilter,
|
||||
list: this._list,
|
||||
$cbSelAll,
|
||||
$btnSendAllToRight,
|
||||
};
|
||||
}
|
||||
|
||||
_isListItemMatchingFilter (f, li) { return this._isEntityItemMatchingFilter(f, this._allData[li.ix]); }
|
||||
_isEntityItemMatchingFilter (f, it) { return this._pageFilter.toDisplay(f, it); }
|
||||
|
||||
async pPopulateHiddenWrapper () {
|
||||
await this._pInit();
|
||||
|
||||
this._allData = this._allData || await this._pLoadAllData();
|
||||
|
||||
await this._pageFilter.pInitFilterBox({namespace: this._namespace});
|
||||
|
||||
this._allData.forEach(it => {
|
||||
this._pageFilter.mutateAndAddToFilters(it);
|
||||
});
|
||||
|
||||
this._pageFilter.trimState();
|
||||
}
|
||||
|
||||
handleHiddenOpenButtonClick () {
|
||||
this._pageFilter.filterBox.show();
|
||||
}
|
||||
|
||||
handleHiddenResetButtonClick (evt) {
|
||||
this._pageFilter.filterBox.reset(evt.shiftKey);
|
||||
}
|
||||
|
||||
_getStateFromFilterExpression (filterExpression) {
|
||||
const filterSubhashMeta = Renderer.getFilterSubhashes(Renderer.splitTagByPipe(filterExpression), this._namespace);
|
||||
const subhashes = filterSubhashMeta.subhashes.map(it => `${it.key}${HASH_SUB_KV_SEP}${it.value}`);
|
||||
const unpackedSubhashes = this.pageFilter.filterBox.unpackSubHashes(subhashes, {force: true});
|
||||
return this.pageFilter.filterBox.getNextStateFromSubHashes({unpackedSubhashes});
|
||||
}
|
||||
|
||||
/**
|
||||
* N.b.: assumes any preloading has already been done
|
||||
* @param filterExpression
|
||||
*/
|
||||
getItemsMatchingFilterExpression ({filterExpression}) {
|
||||
const nxtStateOuter = this._getStateFromFilterExpression(filterExpression);
|
||||
|
||||
const f = this._pageFilter.filterBox.getValues({nxtStateOuter});
|
||||
const filteredItems = this._filterCache.list.getFilteredItems({
|
||||
items: this._filterCache.list.items,
|
||||
fnFilter: li => this._isListItemMatchingFilter(f, li),
|
||||
});
|
||||
|
||||
return this._filterCache.list.getSortedItems({items: filteredItems});
|
||||
}
|
||||
|
||||
getEntitiesMatchingFilterExpression ({filterExpression}) {
|
||||
const nxtStateOuter = this._getStateFromFilterExpression(filterExpression);
|
||||
|
||||
const f = this._pageFilter.filterBox.getValues({nxtStateOuter});
|
||||
return this._allData.filter(this._isEntityItemMatchingFilter.bind(this, f));
|
||||
}
|
||||
|
||||
getRenderedFilterExpression ({filterExpression}) {
|
||||
const nxtStateOuter = this._getStateFromFilterExpression(filterExpression);
|
||||
return this.pageFilter.filterBox.getDisplayState({nxtStateOuter});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param [opts]
|
||||
* @param [opts.filterExpression] A filter expression, as usually found in @filter tags, which will be applied.
|
||||
*/
|
||||
async pGetUserSelection ({filterExpression = null} = {}) {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async resolve => {
|
||||
const {$modalInner, doClose} = await this._pGetShowModal(resolve);
|
||||
|
||||
await this.pPreloadHidden($modalInner);
|
||||
|
||||
this._doApplyFilterExpression(filterExpression);
|
||||
|
||||
this._filterCache.$btnConfirm.off("click").click(async () => {
|
||||
const checked = this._filterCache.list.visibleItems.filter(it => it.data.cbSel.checked);
|
||||
resolve(checked);
|
||||
|
||||
doClose(true);
|
||||
|
||||
// region reset selection state
|
||||
if (this._filterCache.$cbSelAll) this._filterCache.$cbSelAll.prop("checked", false);
|
||||
this._filterCache.list.items.forEach(it => {
|
||||
if (it.data.cbSel) it.data.cbSel.checked = false;
|
||||
it.ele.classList.remove("list-multi-selected");
|
||||
});
|
||||
// endregion
|
||||
});
|
||||
|
||||
await UiUtil.pDoForceFocus(this._filterCache.$iptSearch[0]);
|
||||
});
|
||||
}
|
||||
|
||||
async _pGetShowModal (resolve) {
|
||||
const {$modalInner, doClose} = await UiUtil.pGetShowModal({
|
||||
isHeight100: true,
|
||||
isWidth100: true,
|
||||
title: `Filter/Search for ${this._modalTitle}`,
|
||||
cbClose: (isDataEntered) => {
|
||||
this._filterCache.$wrpModalInner.detach();
|
||||
if (!isDataEntered) resolve([]);
|
||||
},
|
||||
isUncappedHeight: true,
|
||||
});
|
||||
|
||||
return {$modalInner, doClose};
|
||||
}
|
||||
|
||||
_doApplyFilterExpression (filterExpression) {
|
||||
if (!filterExpression) return;
|
||||
|
||||
const filterSubhashMeta = Renderer.getFilterSubhashes(Renderer.splitTagByPipe(filterExpression), this._namespace);
|
||||
const subhashes = filterSubhashMeta.subhashes.map(it => `${it.key}${HASH_SUB_KV_SEP}${it.value}`);
|
||||
this.pageFilter.filterBox.setFromSubHashes(subhashes, {force: true, $iptSearch: this._filterCache.$iptSearch});
|
||||
}
|
||||
|
||||
_getNameStyle () { return `bold`; }
|
||||
|
||||
/**
|
||||
* Pre-heat the modal, thus allowing access to the filter box underneath.
|
||||
*
|
||||
* @param [$modalInner]
|
||||
*/
|
||||
async pPreloadHidden ($modalInner) {
|
||||
// If we're rendering in "hidden" mode, create a dummy element to attach the UI to.
|
||||
$modalInner = $modalInner || $(`<div></div>`);
|
||||
|
||||
if (this._filterCache) {
|
||||
this._filterCache.$wrpModalInner.appendTo($modalInner);
|
||||
} else {
|
||||
const meta = await this.pPopulateWrapper($modalInner);
|
||||
const {$iptSearch, $btnConfirm, pageFilter, list, $cbSelAll} = meta;
|
||||
const $wrpModalInner = meta.$wrpInner;
|
||||
|
||||
this._filterCache = {$iptSearch, $wrpModalInner, $btnConfirm, pageFilter, list, $cbSelAll};
|
||||
}
|
||||
}
|
||||
|
||||
/** Widths should total to 11/12ths, as 1/12th is set aside for the checkbox column. */
|
||||
_$getColumnHeaders () { throw new Error(`Unimplemented!`); }
|
||||
async _pInit () { /* Implement as required */ }
|
||||
async _pLoadAllData () { throw new Error(`Unimplemented!`); }
|
||||
async _getListItem () { throw new Error(`Unimplemented!`); }
|
||||
}
|
||||
101
js/filter/filter-page-filter-base.js
Normal file
101
js/filter/filter-page-filter-base.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import {FilterItem} from "./filter-item.js";
|
||||
|
||||
export class PageFilterBase {
|
||||
static defaultSourceSelFn (val) {
|
||||
// Assume the user wants to select their loaded homebrew by default
|
||||
// Overridden by the "Deselect Homebrew Sources by Default" option
|
||||
return SourceUtil.getFilterGroup(val) === SourceUtil.FILTER_GROUP_STANDARD
|
||||
|| (SourceUtil.getFilterGroup(val) === SourceUtil.FILTER_GROUP_PARTNERED && (typeof BrewUtil2 === "undefined" || BrewUtil2.hasSourceJson(val)))
|
||||
|| SourceUtil.getFilterGroup(val) === SourceUtil.FILTER_GROUP_HOMEBREW;
|
||||
}
|
||||
|
||||
constructor (opts) {
|
||||
opts = opts || {};
|
||||
this._sourceFilter = new SourceFilter(opts.sourceFilterOpts);
|
||||
this._filterBox = null;
|
||||
}
|
||||
|
||||
get filterBox () { return this._filterBox; }
|
||||
get sourceFilter () { return this._sourceFilter; }
|
||||
|
||||
mutateAndAddToFilters (entity, isExcluded, opts) {
|
||||
this.constructor.mutateForFilters(entity, opts);
|
||||
this.addToFilters(entity, isExcluded, opts);
|
||||
}
|
||||
|
||||
static mutateForFilters (entity, opts) { throw new Error("Unimplemented!"); }
|
||||
addToFilters (entity, isExcluded, opts) { throw new Error("Unimplemented!"); }
|
||||
toDisplay (values, entity) { throw new Error("Unimplemented!"); }
|
||||
async _pPopulateBoxOptions () { throw new Error("Unimplemented!"); }
|
||||
|
||||
async pInitFilterBox (opts) {
|
||||
opts = opts || {};
|
||||
await this._pPopulateBoxOptions(opts);
|
||||
this._filterBox = new FilterBox(opts);
|
||||
await this._filterBox.pDoLoadState();
|
||||
return this._filterBox;
|
||||
}
|
||||
|
||||
trimState () { return this._filterBox.trimState_(); }
|
||||
|
||||
// region Helpers
|
||||
static _getClassFilterItem ({className, classSource, isVariantClass, definedInSource}) {
|
||||
const nm = className.split("(")[0].trim();
|
||||
const variantSuffix = isVariantClass ? ` [${definedInSource ? Parser.sourceJsonToAbv(definedInSource) : "Unknown"}]` : "";
|
||||
const sourceSuffix = (
|
||||
SourceUtil.isNonstandardSource(classSource || Parser.SRC_PHB)
|
||||
|| (typeof PrereleaseUtil !== "undefined" && PrereleaseUtil.hasSourceJson(classSource || Parser.SRC_PHB))
|
||||
|| (typeof BrewUtil2 !== "undefined" && BrewUtil2.hasSourceJson(classSource || Parser.SRC_PHB))
|
||||
)
|
||||
? ` (${Parser.sourceJsonToAbv(classSource)})` : "";
|
||||
const name = `${nm}${variantSuffix}${sourceSuffix}`;
|
||||
|
||||
const opts = {
|
||||
item: name,
|
||||
userData: {
|
||||
group: SourceUtil.getFilterGroup(classSource || Parser.SRC_PHB),
|
||||
},
|
||||
};
|
||||
|
||||
if (isVariantClass) {
|
||||
opts.nest = definedInSource ? Parser.sourceJsonToFull(definedInSource) : "Unknown";
|
||||
opts.userData.equivalentClassName = `${nm}${sourceSuffix}`;
|
||||
opts.userData.definedInSource = definedInSource;
|
||||
}
|
||||
|
||||
return new FilterItem(opts);
|
||||
}
|
||||
|
||||
static _getSubclassFilterItem ({className, classSource, subclassShortName, subclassName, subclassSource, subSubclassName, isVariantClass, definedInSource}) {
|
||||
const group = SourceUtil.isSubclassReprinted(className, classSource, subclassShortName, subclassSource) || Parser.sourceJsonToFull(subclassSource).startsWith(Parser.UA_PREFIX) || Parser.sourceJsonToFull(subclassSource).startsWith(Parser.PS_PREFIX);
|
||||
|
||||
const classFilterItem = this._getClassFilterItem({
|
||||
className: subclassShortName || subclassName,
|
||||
classSource: subclassSource,
|
||||
});
|
||||
|
||||
return new FilterItem({
|
||||
item: `${className}: ${classFilterItem.item}${subSubclassName ? `, ${subSubclassName}` : ""}`,
|
||||
nest: className,
|
||||
userData: {
|
||||
group,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static _isReprinted ({reprintedAs, tag, page, prop}) {
|
||||
return reprintedAs?.length && reprintedAs.some(it => {
|
||||
const {name, source} = DataUtil.generic.unpackUid(it?.uid ?? it, tag);
|
||||
const hash = UrlUtil.URL_TO_HASH_BUILDER[page]({name, source});
|
||||
return !ExcludeUtil.isExcluded(hash, prop, source, {isNoCount: true});
|
||||
});
|
||||
}
|
||||
|
||||
static getListAliases (ent) {
|
||||
return (ent.alias || []).map(it => `"${it}"`).join(",");
|
||||
}
|
||||
|
||||
static _hasFluff (ent) { return ent.hasFluff || ent.fluff?.entries; }
|
||||
static _hasFluffImages (ent) { return ent.hasFluffImages || ent.fluff?.images; }
|
||||
// endregion
|
||||
}
|
||||
13
js/filter/filter-registry.js
Normal file
13
js/filter/filter-registry.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import {SUB_HASH_PREFIX_LENGTH} from "./filter-constants.js";
|
||||
|
||||
export class FilterRegistry {
|
||||
static SUB_HASH_PREFIXES = new Set();
|
||||
|
||||
static registerSubhashes (subhashes) {
|
||||
const subhashesInvalid = subhashes.filter(it => it.length !== SUB_HASH_PREFIX_LENGTH);
|
||||
|
||||
if (subhashesInvalid.length) throw new Error(`Invalid prefix! ${subhashesInvalid.map(it => `"${it}"`).join(", ")} ${subhashesInvalid.length === 1 ? `is` : `was`} not of length ${SUB_HASH_PREFIX_LENGTH}`);
|
||||
|
||||
subhashes.forEach(it => this.SUB_HASH_PREFIXES.add(it));
|
||||
}
|
||||
}
|
||||
693
js/filter/filter/filter-filter-ability-score.js
Normal file
693
js/filter/filter/filter-filter-ability-score.js
Normal file
@@ -0,0 +1,693 @@
|
||||
import {FilterBase} from "./filter-filter-base.js";
|
||||
import {Filter} from "./filter-filter-generic.js";
|
||||
import {FilterBox} from "../filter-box.js";
|
||||
|
||||
export class AbilityScoreFilter extends FilterBase {
|
||||
static _MODIFIER_SORT_OFFSET = 10000; // Arbitrarily large value
|
||||
|
||||
constructor (opts) {
|
||||
super(opts);
|
||||
|
||||
this._items = [];
|
||||
this._isItemsDirty = false;
|
||||
this._itemsLookup = {}; // Cache items for fast lookup
|
||||
this._seenUids = {};
|
||||
|
||||
this.__$wrpFilter = null;
|
||||
this.__wrpPills = null;
|
||||
this.__wrpPillsRows = {};
|
||||
this.__wrpMiniPills = null;
|
||||
|
||||
this._maxMod = 2;
|
||||
this._minMod = 0;
|
||||
|
||||
// region Init state
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
const itemAnyIncrease = new AbilityScoreFilter.FilterItem({isAnyIncrease: true, ability: ab});
|
||||
const itemAnyDecrease = new AbilityScoreFilter.FilterItem({isAnyDecrease: true, ability: ab});
|
||||
this._items.push(itemAnyIncrease, itemAnyDecrease);
|
||||
this._itemsLookup[itemAnyIncrease.uid] = itemAnyIncrease;
|
||||
this._itemsLookup[itemAnyDecrease.uid] = itemAnyDecrease;
|
||||
if (this.__state[itemAnyIncrease.uid] == null) this.__state[itemAnyIncrease.uid] = 0;
|
||||
if (this.__state[itemAnyDecrease.uid] == null) this.__state[itemAnyDecrease.uid] = 0;
|
||||
});
|
||||
|
||||
for (let i = this._minMod; i <= this._maxMod; ++i) {
|
||||
if (i === 0) continue;
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
const item = new AbilityScoreFilter.FilterItem({modifier: i, ability: ab});
|
||||
this._items.push(item);
|
||||
this._itemsLookup[item.uid] = item;
|
||||
if (this.__state[item.uid] == null) this.__state[item.uid] = 0;
|
||||
});
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
||||
/**
|
||||
* @param opts Options.
|
||||
* @param opts.filterBox The FilterBox to which this filter is attached.
|
||||
* @param opts.isFirst True if this is visually the first filter in the box.
|
||||
* @param opts.$wrpMini The form mini-view element.
|
||||
* @param opts.isMulti The name of the MultiFilter this filter belongs to, if any.
|
||||
*/
|
||||
$render (opts) {
|
||||
this._filterBox = opts.filterBox;
|
||||
this.__wrpMiniPills = e_({ele: opts.$wrpMini[0]});
|
||||
|
||||
const wrpControls = this._getHeaderControls(opts);
|
||||
|
||||
this.__wrpPills = e_({tag: "div", clazz: `fltr__wrp-pills ve-overflow-x-auto ve-flex-col w-100`});
|
||||
const hook = () => this.__wrpPills.toggleVe(!this._meta.isHidden);
|
||||
this._addHook("meta", "isHidden", hook);
|
||||
hook();
|
||||
|
||||
this._doRenderPills();
|
||||
|
||||
// FIXME refactor this so we're not stealing the private method
|
||||
const btnMobToggleControls = Filter.prototype._getBtnMobToggleControls.bind(this)(wrpControls);
|
||||
|
||||
this.__$wrpFilter = $$`<div>
|
||||
${opts.isFirst ? "" : `<div class="fltr__dropdown-divider ${opts.isMulti ? "fltr__dropdown-divider--indented" : ""} mb-1"></div>`}
|
||||
<div class="split fltr__h mb-1">
|
||||
<div class="ml-2 fltr__h-text ve-flex-h-center">${opts.isMulti ? `<span class="mr-2">\u2012</span>` : ""}${this._getRenderedHeader()}${btnMobToggleControls}</div>
|
||||
${wrpControls}
|
||||
</div>
|
||||
${this.__wrpPills}
|
||||
</div>`;
|
||||
|
||||
this.update(); // Force an update, to properly mute/unmute our pills
|
||||
|
||||
return this.__$wrpFilter;
|
||||
}
|
||||
|
||||
_getHeaderControls (opts) {
|
||||
const btnClear = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} fltr__h-btn--clear w-100`,
|
||||
click: () => this._doSetPillsClear(),
|
||||
html: "Clear",
|
||||
});
|
||||
|
||||
const wrpStateBtnsOuter = e_({
|
||||
tag: "div",
|
||||
clazz: "ve-flex-v-center fltr__h-wrp-state-btns-outer",
|
||||
children: [
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: "btn-group ve-flex-v-center w-100",
|
||||
children: [
|
||||
btnClear,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const wrpSummary = e_({tag: "div", clazz: "ve-flex-vh-center ve-hidden"});
|
||||
|
||||
const btnShowHide = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} ml-2`,
|
||||
click: () => this._meta.isHidden = !this._meta.isHidden,
|
||||
html: "Hide",
|
||||
});
|
||||
const hookShowHide = () => {
|
||||
e_({ele: btnShowHide}).toggleClass("active", this._meta.isHidden);
|
||||
wrpStateBtnsOuter.toggleVe(!this._meta.isHidden);
|
||||
|
||||
// TODO
|
||||
// region Render summary
|
||||
const cur = this.getValues()[this.header];
|
||||
|
||||
const htmlSummary = [
|
||||
cur._totals?.yes
|
||||
? `<span class="fltr__summary_item fltr__summary_item--include" title="${cur._totals.yes} hidden "required" tags">${cur._totals.yes}</span>`
|
||||
: null,
|
||||
].filter(Boolean).join("");
|
||||
e_({ele: wrpSummary, html: htmlSummary}).toggleVe(this._meta.isHidden);
|
||||
// endregion
|
||||
};
|
||||
this._addHook("meta", "isHidden", hookShowHide);
|
||||
hookShowHide();
|
||||
|
||||
return e_({
|
||||
tag: "div",
|
||||
clazz: `ve-flex-v-center fltr__h-wrp-btns-outer`,
|
||||
children: [
|
||||
wrpSummary,
|
||||
wrpStateBtnsOuter,
|
||||
btnShowHide,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
_doRenderPills () {
|
||||
this._items.sort(this.constructor._ascSortItems.bind(this.constructor));
|
||||
|
||||
if (!this.__wrpPills) return;
|
||||
this._items.forEach(it => {
|
||||
if (!it.rendered) it.rendered = this._getPill(it);
|
||||
if (!it.isAnyIncrease && !it.isAnyDecrease) it.rendered.toggleClass("fltr__pill--muted", !this._seenUids[it.uid]);
|
||||
|
||||
if (!this.__wrpPillsRows[it.ability]) {
|
||||
this.__wrpPillsRows[it.ability] = {
|
||||
row: e_({
|
||||
tag: "div",
|
||||
clazz: "ve-flex-v-center w-100 my-1",
|
||||
children: [
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: "mr-3 text-right fltr__label-ability-score no-shrink no-grow",
|
||||
text: Parser.attAbvToFull(it.ability),
|
||||
}),
|
||||
],
|
||||
}).appendTo(this.__wrpPills),
|
||||
searchText: Parser.attAbvToFull(it.ability).toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
it.rendered.appendTo(this.__wrpPillsRows[it.ability].row);
|
||||
});
|
||||
}
|
||||
|
||||
_getPill (item) {
|
||||
const unsetRow = () => {
|
||||
const nxtState = {};
|
||||
for (let i = this._minMod; i <= this._maxMod; ++i) {
|
||||
if (!i || i === item.modifier) continue;
|
||||
const siblingUid = AbilityScoreFilter.FilterItem.getUid_({ability: item.ability, modifier: i});
|
||||
nxtState[siblingUid] = 0;
|
||||
}
|
||||
|
||||
if (!item.isAnyIncrease) nxtState[AbilityScoreFilter.FilterItem.getUid_({ability: item.ability, isAnyIncrease: true})] = 0;
|
||||
if (!item.isAnyDecrease) nxtState[AbilityScoreFilter.FilterItem.getUid_({ability: item.ability, isAnyDecrease: true})] = 0;
|
||||
|
||||
this._proxyAssignSimple("state", nxtState);
|
||||
};
|
||||
|
||||
const btnPill = e_({
|
||||
tag: "div",
|
||||
clazz: `fltr__pill fltr__pill--ability-bonus px-2`,
|
||||
html: item.getPillDisplayHtml(),
|
||||
click: evt => {
|
||||
if (evt.shiftKey) {
|
||||
const nxtState = {};
|
||||
Object.keys(this._state).forEach(k => nxtState[k] = 0);
|
||||
this._proxyAssign("state", "_state", "__state", nxtState, true);
|
||||
}
|
||||
|
||||
this._state[item.uid] = this._state[item.uid] ? 0 : 1;
|
||||
if (this._state[item.uid]) unsetRow();
|
||||
},
|
||||
contextmenu: (evt) => {
|
||||
evt.preventDefault();
|
||||
|
||||
this._state[item.uid] = this._state[item.uid] ? 0 : 1;
|
||||
if (this._state[item.uid]) unsetRow();
|
||||
},
|
||||
});
|
||||
|
||||
const hook = () => {
|
||||
const val = FilterBox._PILL_STATES[this._state[item.uid] || 0];
|
||||
btnPill.attr("state", val);
|
||||
};
|
||||
this._addHook("state", item.uid, hook);
|
||||
hook();
|
||||
|
||||
return btnPill;
|
||||
}
|
||||
|
||||
_doRenderMiniPills () {
|
||||
// create a list view so we can freely sort
|
||||
this._items.slice(0)
|
||||
.sort(this.constructor._ascSortMiniPills.bind(this.constructor))
|
||||
.forEach(it => {
|
||||
// re-append existing elements to sort them
|
||||
(it.btnMini = it.btnMini || this._getBtnMini(it)).appendTo(this.__wrpMiniPills);
|
||||
});
|
||||
}
|
||||
|
||||
_getBtnMini (item) {
|
||||
const btnMini = e_({
|
||||
tag: "div",
|
||||
clazz: `fltr__mini-pill ${this._filterBox.isMinisHidden(this.header) ? "ve-hidden" : ""}`,
|
||||
text: item.getMiniPillDisplayText(),
|
||||
title: `Filter: ${this.header}`,
|
||||
click: () => {
|
||||
this._state[item.uid] = 0;
|
||||
this._filterBox.fireChangeEvent();
|
||||
},
|
||||
}).attr("state", FilterBox._PILL_STATES[this._state[item.uid] || 0]);
|
||||
|
||||
const hook = () => btnMini.attr("state", FilterBox._PILL_STATES[this._state[item.uid] || 0]);
|
||||
this._addHook("state", item.uid, hook);
|
||||
|
||||
const hideHook = () => btnMini.toggleClass("ve-hidden", this._filterBox.isMinisHidden(this.header));
|
||||
this._filterBox.registerMinisHiddenHook(this.header, hideHook);
|
||||
|
||||
return btnMini;
|
||||
}
|
||||
|
||||
static _ascSortItems (a, b) {
|
||||
return SortUtil.ascSort(Number(b.isAnyIncrease), Number(a.isAnyIncrease))
|
||||
|| SortUtil.ascSortAtts(a.ability, b.ability)
|
||||
// Offset ability scores to ensure they're all in positive space. This forces the "any decrease" section to
|
||||
// appear last.
|
||||
|| SortUtil.ascSort(b.modifier ? b.modifier + AbilityScoreFilter._MODIFIER_SORT_OFFSET : b.modifier, a.modifier ? a.modifier + AbilityScoreFilter._MODIFIER_SORT_OFFSET : a.modifier)
|
||||
|| SortUtil.ascSort(Number(b.isAnyDecrease), Number(a.isAnyDecrease));
|
||||
}
|
||||
|
||||
static _ascSortMiniPills (a, b) {
|
||||
return SortUtil.ascSort(Number(b.isAnyIncrease), Number(a.isAnyIncrease))
|
||||
|| SortUtil.ascSort(Number(b.isAnyDecrease), Number(a.isAnyDecrease))
|
||||
// Offset ability scores to ensure they're all in positive space. This forces the "any decrease" section to
|
||||
// appear last.
|
||||
|| SortUtil.ascSort(b.modifier ? b.modifier + AbilityScoreFilter._MODIFIER_SORT_OFFSET : b.modifier, a.modifier ? a.modifier + AbilityScoreFilter._MODIFIER_SORT_OFFSET : a.modifier)
|
||||
|| SortUtil.ascSortAtts(a.ability, b.ability);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param opts Options.
|
||||
* @param opts.filterBox The FilterBox to which this filter is attached.
|
||||
* @param opts.isFirst True if this is visually the first filter in the box.
|
||||
* @param opts.$wrpMini The form mini-view element.
|
||||
* @param opts.isMulti The name of the MultiFilter this filter belongs to, if any.
|
||||
*/
|
||||
$renderMinis (opts) {
|
||||
this._filterBox = opts.filterBox;
|
||||
this.__wrpMiniPills = e_({ele: opts.$wrpMini[0]});
|
||||
|
||||
this._doRenderMiniPills();
|
||||
}
|
||||
|
||||
getValues ({nxtState = null} = {}) {
|
||||
const out = {
|
||||
_totals: {yes: 0},
|
||||
};
|
||||
|
||||
const state = nxtState?.[this.header]?.state || this.__state;
|
||||
|
||||
Object.entries(state)
|
||||
.filter(([, value]) => value)
|
||||
.forEach(([uid]) => {
|
||||
out._totals.yes++;
|
||||
out[uid] = true;
|
||||
});
|
||||
|
||||
return {[this.header]: out};
|
||||
}
|
||||
|
||||
_mutNextState_reset (nxtState, {isResetAll = false} = {}) {
|
||||
Object.keys(nxtState[this.header].state).forEach(k => delete nxtState[this.header].state[k]);
|
||||
}
|
||||
|
||||
update () {
|
||||
if (this._isItemsDirty) {
|
||||
this._isItemsDirty = false;
|
||||
|
||||
this._doRenderPills();
|
||||
}
|
||||
|
||||
// always render the mini-pills, to ensure the overall order in the grid stays correct (shared between multiple filters)
|
||||
this._doRenderMiniPills();
|
||||
}
|
||||
|
||||
_doSetPillsClear () {
|
||||
Object.keys(this._state).forEach(k => {
|
||||
if (this._state[k] !== 0) this._state[k] = 0;
|
||||
});
|
||||
}
|
||||
|
||||
toDisplay (boxState, entryVal) {
|
||||
const filterState = boxState[this.header];
|
||||
if (!filterState) return true;
|
||||
|
||||
const activeItems = Object.keys(filterState)
|
||||
.filter(it => !it.startsWith("_"))
|
||||
.map(it => this._itemsLookup[it])
|
||||
.filter(Boolean);
|
||||
|
||||
if (!activeItems.length) return true;
|
||||
if ((!entryVal || !entryVal.length) && activeItems.length) return false;
|
||||
|
||||
return entryVal.some(abilObject => {
|
||||
const cpyAbilObject = MiscUtil.copy(abilObject);
|
||||
const vewActiveItems = [...activeItems];
|
||||
|
||||
// region Stage 1. Exact ability score match.
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
if (!cpyAbilObject[ab] || !vewActiveItems.length) return;
|
||||
|
||||
const ixExact = vewActiveItems.findIndex(it => it.ability === ab && it.modifier === cpyAbilObject[ab]);
|
||||
if (~ixExact) return vewActiveItems.splice(ixExact, 1);
|
||||
});
|
||||
if (!vewActiveItems.length) return true;
|
||||
// endregion
|
||||
|
||||
// region Stage 2. "Choice" ability score match
|
||||
if (cpyAbilObject.choose?.from) {
|
||||
const amount = cpyAbilObject.choose.amount || 1;
|
||||
const count = cpyAbilObject.choose.count || 1;
|
||||
|
||||
for (let i = 0; i < count; ++i) {
|
||||
if (!vewActiveItems.length) break;
|
||||
|
||||
const ix = vewActiveItems.findIndex(it => cpyAbilObject.choose.from.includes(it.ability) && amount === it.modifier);
|
||||
if (~ix) {
|
||||
const [cpyActiveItem] = vewActiveItems.splice(ix, 1);
|
||||
cpyAbilObject.choose.from = cpyAbilObject.choose.from.filter(it => it !== cpyActiveItem.ability);
|
||||
}
|
||||
}
|
||||
} else if (cpyAbilObject.choose?.weighted?.weights && cpyAbilObject.choose?.weighted?.from) {
|
||||
cpyAbilObject.choose.weighted.weights.forEach(weight => {
|
||||
const ix = vewActiveItems.findIndex(it => cpyAbilObject.choose.weighted.from.includes(it.ability) && weight === it.modifier);
|
||||
if (~ix) {
|
||||
const [cpyActiveItem] = vewActiveItems.splice(ix, 1);
|
||||
cpyAbilObject.choose.weighted.from = cpyAbilObject.choose.weighted.from.filter(it => it !== cpyActiveItem.ability);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!vewActiveItems.length) return true;
|
||||
// endregion
|
||||
|
||||
// region Stage 3. "Any" ability score match
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
if (!cpyAbilObject[ab] || !vewActiveItems.length) return;
|
||||
|
||||
const ix = vewActiveItems.findIndex(it => it.ability === ab && ((cpyAbilObject[ab] > 0 && it.isAnyIncrease) || (cpyAbilObject[ab] < 0 && it.isAnyDecrease)));
|
||||
if (~ix) return vewActiveItems.splice(ix, 1);
|
||||
});
|
||||
if (!vewActiveItems.length) return true;
|
||||
|
||||
if (cpyAbilObject.choose?.from) {
|
||||
const amount = cpyAbilObject.choose.amount || 1;
|
||||
const count = cpyAbilObject.choose.count || 1;
|
||||
|
||||
for (let i = 0; i < count; ++i) {
|
||||
if (!vewActiveItems.length) return true;
|
||||
|
||||
const ix = vewActiveItems.findIndex(it => cpyAbilObject.choose.from.includes(it.ability) && ((amount > 0 && it.isAnyIncrease) || (amount < 0 && it.isAnyDecrease)));
|
||||
if (~ix) {
|
||||
const [cpyActiveItem] = vewActiveItems.splice(ix, 1);
|
||||
cpyAbilObject.choose.from = cpyAbilObject.choose.from.filter(it => it !== cpyActiveItem.ability);
|
||||
}
|
||||
}
|
||||
} else if (cpyAbilObject.choose?.weighted?.weights && cpyAbilObject.choose?.weighted?.from) {
|
||||
cpyAbilObject.choose.weighted.weights.forEach(weight => {
|
||||
if (!vewActiveItems.length) return;
|
||||
|
||||
const ix = vewActiveItems.findIndex(it => cpyAbilObject.choose.weighted.from.includes(it.ability) && ((weight > 0 && it.isAnyIncrease) || (weight < 0 && it.isAnyDecrease)));
|
||||
if (~ix) {
|
||||
const [cpyActiveItem] = vewActiveItems.splice(ix, 1);
|
||||
cpyAbilObject.choose.weighted.from = cpyAbilObject.choose.weighted.from.filter(it => it !== cpyActiveItem.ability);
|
||||
}
|
||||
});
|
||||
}
|
||||
return !vewActiveItems.length;
|
||||
// endregion
|
||||
});
|
||||
}
|
||||
|
||||
addItem (abilArr) {
|
||||
if (!abilArr?.length) return;
|
||||
|
||||
// region Update our min/max scores
|
||||
let nxtMaxMod = this._maxMod;
|
||||
let nxtMinMod = this._minMod;
|
||||
|
||||
abilArr.forEach(abilObject => {
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
if (abilObject[ab] != null) {
|
||||
nxtMaxMod = Math.max(nxtMaxMod, abilObject[ab]);
|
||||
nxtMinMod = Math.min(nxtMinMod, abilObject[ab]);
|
||||
|
||||
const uid = AbilityScoreFilter.FilterItem.getUid_({ability: ab, modifier: abilObject[ab]});
|
||||
if (!this._seenUids[uid]) this._isItemsDirty = true;
|
||||
this._seenUids[uid] = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (abilObject.choose?.from) {
|
||||
const amount = abilObject.choose.amount || 1;
|
||||
nxtMaxMod = Math.max(nxtMaxMod, amount);
|
||||
nxtMinMod = Math.min(nxtMinMod, amount);
|
||||
|
||||
abilObject.choose.from.forEach(ab => {
|
||||
const uid = AbilityScoreFilter.FilterItem.getUid_({ability: ab, modifier: amount});
|
||||
if (!this._seenUids[uid]) this._isItemsDirty = true;
|
||||
this._seenUids[uid] = true;
|
||||
});
|
||||
}
|
||||
|
||||
if (abilObject.choose?.weighted?.weights) {
|
||||
nxtMaxMod = Math.max(nxtMaxMod, ...abilObject.choose.weighted.weights);
|
||||
nxtMinMod = Math.min(nxtMinMod, ...abilObject.choose.weighted.weights);
|
||||
|
||||
abilObject.choose.weighted.from.forEach(ab => {
|
||||
abilObject.choose.weighted.weights.forEach(weight => {
|
||||
const uid = AbilityScoreFilter.FilterItem.getUid_({ability: ab, modifier: weight});
|
||||
if (!this._seenUids[uid]) this._isItemsDirty = true;
|
||||
this._seenUids[uid] = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
// endregion
|
||||
|
||||
// region If we have a new max score, populate items
|
||||
if (nxtMaxMod > this._maxMod) {
|
||||
for (let i = this._maxMod + 1; i <= nxtMaxMod; ++i) {
|
||||
if (i === 0) continue;
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
const item = new AbilityScoreFilter.FilterItem({modifier: i, ability: ab});
|
||||
this._items.push(item);
|
||||
this._itemsLookup[item.uid] = item;
|
||||
if (this.__state[item.uid] == null) this.__state[item.uid] = 0;
|
||||
});
|
||||
}
|
||||
|
||||
this._isItemsDirty = true;
|
||||
this._maxMod = nxtMaxMod;
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region If we have a new min score, populate items
|
||||
if (nxtMinMod < this._minMod) {
|
||||
for (let i = nxtMinMod; i < this._minMod; ++i) {
|
||||
if (i === 0) continue;
|
||||
Parser.ABIL_ABVS.forEach(ab => {
|
||||
const item = new AbilityScoreFilter.FilterItem({modifier: i, ability: ab});
|
||||
this._items.push(item);
|
||||
this._itemsLookup[item.uid] = item;
|
||||
if (this.__state[item.uid] == null) this.__state[item.uid] = 0;
|
||||
});
|
||||
}
|
||||
|
||||
this._isItemsDirty = true;
|
||||
this._minMod = nxtMinMod;
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
||||
getSaveableState () {
|
||||
return {
|
||||
[this.header]: {
|
||||
...this.getBaseSaveableState(),
|
||||
state: {...this.__state},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setStateFromLoaded (filterState, {isUserSavedState = false} = {}) {
|
||||
if (!filterState?.[this.header]) return;
|
||||
|
||||
const toLoad = filterState[this.header];
|
||||
this._hasUserSavedState = this._hasUserSavedState || isUserSavedState;
|
||||
this.setBaseStateFromLoaded(toLoad);
|
||||
Object.assign(this._state, toLoad.state);
|
||||
}
|
||||
|
||||
getSubHashes () {
|
||||
const out = [];
|
||||
|
||||
const baseMeta = this.getMetaSubHashes();
|
||||
if (baseMeta) out.push(...baseMeta);
|
||||
|
||||
const areNotDefaultState = Object.entries(this._state).filter(([k, v]) => {
|
||||
if (k.startsWith("_")) return false;
|
||||
return !!v;
|
||||
});
|
||||
if (areNotDefaultState.length) {
|
||||
// serialize state as `key=value` pairs
|
||||
const serPillStates = areNotDefaultState.map(([k, v]) => `${k.toUrlified()}=${v}`);
|
||||
out.push(UrlUtil.packSubHash(this.getSubHashPrefix("state", this.header), serPillStates));
|
||||
}
|
||||
|
||||
if (!out.length) return null;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
getNextStateFromSubhashState (state) {
|
||||
const nxtState = this._getNextState_base();
|
||||
|
||||
if (state == null) {
|
||||
this._mutNextState_reset(nxtState);
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
let hasState = false;
|
||||
|
||||
Object.entries(state).forEach(([k, vals]) => {
|
||||
const prop = FilterBase.getProp(k);
|
||||
switch (prop) {
|
||||
case "state": {
|
||||
hasState = true;
|
||||
Object.keys(nxtState[this.header].state).forEach(k => nxtState[this.header].state[k] = 0);
|
||||
|
||||
vals.forEach(v => {
|
||||
const [statePropLower, state] = v.split("=");
|
||||
const stateProp = Object.keys(nxtState[this.header].state).find(k => k.toLowerCase() === statePropLower);
|
||||
if (stateProp) nxtState[this.header].state[stateProp] = Number(state) ? 1 : 0;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasState) this._mutNextState_reset(nxtState);
|
||||
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
setFromValues (values) {
|
||||
if (!values[this.header]) return;
|
||||
const nxtState = {};
|
||||
Object.keys(this._state).forEach(k => nxtState[k] = 0);
|
||||
Object.assign(nxtState, values[this.header]);
|
||||
}
|
||||
|
||||
handleSearch (searchTerm) {
|
||||
const isHeaderMatch = this.header.toLowerCase().includes(searchTerm);
|
||||
|
||||
if (isHeaderMatch) {
|
||||
Object.values(this.__wrpPillsRows).forEach(meta => meta.row.removeClass("fltr__hidden--search"));
|
||||
|
||||
if (this.__$wrpFilter) this.__$wrpFilter.toggleClass("fltr__hidden--search", false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Simply display all if the user searched a "+x" or "-x" value; we don't care if this produces false positives.
|
||||
const isModNumber = /^[-+]\d*$/.test(searchTerm);
|
||||
|
||||
let visibleCount = 0;
|
||||
Object.values(this.__wrpPillsRows).forEach(({row, searchText}) => {
|
||||
const isVisible = isModNumber || searchText.includes(searchTerm);
|
||||
row.toggleClass("fltr__hidden--search", !isVisible);
|
||||
if (isVisible) visibleCount++;
|
||||
});
|
||||
|
||||
if (this.__$wrpFilter) this.__$wrpFilter.toggleClass("fltr__hidden--search", visibleCount === 0);
|
||||
|
||||
return visibleCount !== 0;
|
||||
}
|
||||
|
||||
_doTeardown () {
|
||||
this._items.forEach(it => {
|
||||
if (it.rendered) it.rendered.detach();
|
||||
if (it.btnMini) it.btnMini.detach();
|
||||
});
|
||||
|
||||
Object.values(this.__wrpPillsRows).forEach(meta => meta.row.detach());
|
||||
}
|
||||
|
||||
_getStateNotDefault () {
|
||||
return Object.entries(this._state)
|
||||
.filter(([, v]) => !!v);
|
||||
}
|
||||
|
||||
getFilterTagPart () {
|
||||
const areNotDefaultState = this._getStateNotDefault();
|
||||
const compressedMeta = this._getCompressedMeta({isStripUiKeys: true});
|
||||
|
||||
// If _any_ value is non-default, we need to include _all_ values in the tag
|
||||
// The same goes for meta values
|
||||
if (!areNotDefaultState.length && !compressedMeta) return null;
|
||||
|
||||
const pt = Object.entries(this._state)
|
||||
.filter(([, v]) => !!v)
|
||||
.map(([k, v]) => `${v === 2 ? "!" : ""}${k}`)
|
||||
.join(";")
|
||||
.toLowerCase();
|
||||
|
||||
return [
|
||||
this.header.toLowerCase(),
|
||||
pt,
|
||||
compressedMeta ? compressedMeta.join(HASH_SUB_LIST_SEP) : null,
|
||||
]
|
||||
.filter(it => it != null)
|
||||
.join("=");
|
||||
}
|
||||
|
||||
getDisplayStatePart ({nxtState = null} = {}) {
|
||||
const state = nxtState?.[this.header]?.state || this.__state;
|
||||
|
||||
const areNotDefaultState = this._getStateNotDefault({nxtState});
|
||||
|
||||
// If _any_ value is non-default, we need to include _all_ values in the tag
|
||||
// The same goes for meta values
|
||||
if (!areNotDefaultState.length) return null;
|
||||
|
||||
const ptState = Object.entries(state)
|
||||
.filter(([, v]) => !!v)
|
||||
.map(([k, v]) => {
|
||||
const item = this._items.find(item => item.uid === k);
|
||||
if (!item) return null; // Should never occur
|
||||
return `${v === 2 ? "not " : ""}${item.getMiniPillDisplayText()}`;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return `${this.header}: ${ptState}`;
|
||||
}
|
||||
}
|
||||
|
||||
AbilityScoreFilter.FilterItem = class {
|
||||
static getUid_ ({ability = null, isAnyIncrease = false, isAnyDecrease = false, modifier = null}) {
|
||||
return `${Parser.attAbvToFull(ability)} ${modifier != null ? UiUtil.intToBonus(modifier) : (isAnyIncrease ? `+any` : isAnyDecrease ? `-any` : "?")}`;
|
||||
}
|
||||
|
||||
constructor ({isAnyIncrease = false, isAnyDecrease = false, modifier = null, ability = null}) {
|
||||
if (isAnyIncrease && isAnyDecrease) throw new Error(`Invalid arguments!`);
|
||||
if ((isAnyIncrease || isAnyDecrease) && modifier != null) throw new Error(`Invalid arguments!`);
|
||||
|
||||
this._ability = ability;
|
||||
this._modifier = modifier;
|
||||
this._isAnyIncrease = isAnyIncrease;
|
||||
this._isAnyDecrease = isAnyDecrease;
|
||||
this._uid = AbilityScoreFilter.FilterItem.getUid_({
|
||||
isAnyIncrease: this._isAnyIncrease,
|
||||
isAnyDecrease: this._isAnyDecrease,
|
||||
modifier: this._modifier,
|
||||
ability: this._ability,
|
||||
});
|
||||
}
|
||||
|
||||
get ability () { return this._ability; }
|
||||
get modifier () { return this._modifier; }
|
||||
get isAnyIncrease () { return this._isAnyIncrease; }
|
||||
get isAnyDecrease () { return this._isAnyDecrease; }
|
||||
get uid () { return this._uid; }
|
||||
|
||||
getMiniPillDisplayText () {
|
||||
if (this._isAnyIncrease) return `+Any ${Parser.attAbvToFull(this._ability)}`;
|
||||
if (this._isAnyDecrease) return `\u2012Any ${Parser.attAbvToFull(this._ability)}`;
|
||||
return `${UiUtil.intToBonus(this._modifier, {isPretty: true})} ${Parser.attAbvToFull(this._ability)}`;
|
||||
}
|
||||
|
||||
getPillDisplayHtml () {
|
||||
if (this._isAnyIncrease) return `+Any`;
|
||||
if (this._isAnyDecrease) return `\u2012Any`;
|
||||
return UiUtil.intToBonus(this._modifier, {isPretty: true});
|
||||
}
|
||||
};
|
||||
186
js/filter/filter/filter-filter-base.js
Normal file
186
js/filter/filter/filter-filter-base.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import {FilterRegistry} from "../filter-registry.js";
|
||||
|
||||
export class FilterBase extends BaseComponent {
|
||||
/**
|
||||
* @param opts
|
||||
* @param opts.header Filter header (name)
|
||||
* @param [opts.headerHelp] Filter header help text (tooltip)
|
||||
*/
|
||||
constructor (opts) {
|
||||
super();
|
||||
this._filterBox = null;
|
||||
|
||||
this.header = opts.header;
|
||||
this._headerHelp = opts.headerHelp;
|
||||
|
||||
this.__meta = {...this.getDefaultMeta()};
|
||||
this._meta = this._getProxy("meta", this.__meta);
|
||||
|
||||
this._hasUserSavedState = false;
|
||||
}
|
||||
|
||||
_getRenderedHeader () {
|
||||
return `<span ${this._headerHelp ? `title="${this._headerHelp.escapeQuotes()}" class="help-subtle"` : ""}>${this.header}</span>`;
|
||||
}
|
||||
|
||||
set filterBox (it) { this._filterBox = it; }
|
||||
|
||||
show () { this._meta.isHidden = false; }
|
||||
|
||||
hide () { this._meta.isHidden = true; }
|
||||
|
||||
getBaseSaveableState () { return {meta: {...this.__meta}}; }
|
||||
|
||||
_getNextState_base () {
|
||||
return {
|
||||
[this.header]: {
|
||||
state: MiscUtil.copyFast(this.__state),
|
||||
meta: MiscUtil.copyFast(this.__meta),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setStateFromNextState (nxtState) {
|
||||
this._proxyAssignSimple("state", nxtState[this.header].state, true);
|
||||
this._proxyAssignSimple("meta", nxtState[this.header].meta, true);
|
||||
}
|
||||
|
||||
reset ({isResetAll = false} = {}) {
|
||||
const nxtState = this._getNextState_base();
|
||||
this._mutNextState_reset(nxtState, {isResetAll});
|
||||
this.setStateFromNextState(nxtState);
|
||||
}
|
||||
|
||||
_mutNextState_resetBase (nxtState, {isResetAll = false} = {}) {
|
||||
Object.assign(nxtState[this.header].meta, MiscUtil.copy(this.getDefaultMeta()));
|
||||
}
|
||||
|
||||
getMetaSubHashes () {
|
||||
const compressedMeta = this._getCompressedMeta();
|
||||
if (!compressedMeta) return null;
|
||||
return [UrlUtil.packSubHash(this.getSubHashPrefix("meta", this.header), compressedMeta)];
|
||||
}
|
||||
|
||||
_mutNextState_meta_fromSubHashState (nxtState, subHashState) {
|
||||
const hasMeta = this._mutNextState_meta_fromSubHashState_mutGetHasMeta(nxtState, subHashState, this.getDefaultMeta());
|
||||
if (!hasMeta) this._mutNextState_resetBase(nxtState);
|
||||
}
|
||||
|
||||
_mutNextState_meta_fromSubHashState_mutGetHasMeta (nxtState, state, defaultMeta) {
|
||||
let hasMeta = false;
|
||||
|
||||
Object.entries(state)
|
||||
.forEach(([k, vals]) => {
|
||||
const prop = FilterBase.getProp(k);
|
||||
if (prop !== "meta") return;
|
||||
|
||||
hasMeta = true;
|
||||
const data = vals.map(v => UrlUtil.mini.decompress(v));
|
||||
Object.keys(defaultMeta).forEach((k, i) => {
|
||||
if (data[i] !== undefined) nxtState[this.header].meta[k] = data[i];
|
||||
else nxtState[this.header].meta[k] = defaultMeta[k];
|
||||
});
|
||||
});
|
||||
|
||||
return hasMeta;
|
||||
}
|
||||
|
||||
setBaseStateFromLoaded (toLoad) { Object.assign(this._meta, toLoad.meta); }
|
||||
|
||||
getSubHashPrefix (prop, header) {
|
||||
if (FilterBase._SUB_HASH_PREFIXES[prop]) {
|
||||
const prefix = this._filterBox.getNamespacedHashKey(FilterBase._SUB_HASH_PREFIXES[prop]);
|
||||
return `${prefix}${header.toUrlified()}`;
|
||||
}
|
||||
throw new Error(`Unknown property "${prop}"`);
|
||||
}
|
||||
|
||||
static getProp (prefix) {
|
||||
return Parser._parse_bToA(FilterBase._SUB_HASH_PREFIXES, prefix);
|
||||
}
|
||||
|
||||
_getBtnMobToggleControls (wrpControls) {
|
||||
const btnMobToggleControls = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xs btn-default mobile__visible ml-auto px-3 mr-2`,
|
||||
html: `<span class="glyphicon glyphicon-option-vertical"></span>`,
|
||||
click: () => this._meta.isMobileHeaderHidden = !this._meta.isMobileHeaderHidden,
|
||||
});
|
||||
const hkMobHeaderHidden = () => {
|
||||
btnMobToggleControls.toggleClass("active", !this._meta.isMobileHeaderHidden);
|
||||
wrpControls.toggleClass("mobile__hidden", !!this._meta.isMobileHeaderHidden);
|
||||
};
|
||||
this._addHook("meta", "isMobileHeaderHidden", hkMobHeaderHidden);
|
||||
hkMobHeaderHidden();
|
||||
|
||||
return btnMobToggleControls;
|
||||
}
|
||||
|
||||
getChildFilters () { return []; }
|
||||
getDefaultMeta () { return {...FilterBase._DEFAULT_META}; }
|
||||
|
||||
/**
|
||||
* @param vals Previously-read filter value may be passed in for performance.
|
||||
*/
|
||||
isActive (vals) {
|
||||
vals = vals || this.getValues();
|
||||
return vals[this.header]._isActive;
|
||||
}
|
||||
|
||||
_getCompressedMeta ({isStripUiKeys = false} = {}) {
|
||||
const defaultMeta = this.getDefaultMeta();
|
||||
const isAnyNotDefault = Object.keys(defaultMeta).some(k => this._meta[k] !== defaultMeta[k]);
|
||||
if (!isAnyNotDefault) return null;
|
||||
|
||||
let keys = Object.keys(defaultMeta);
|
||||
|
||||
if (isStripUiKeys) {
|
||||
// Always pop the trailing n keys, as these are all UI options, which we don't want to embed in @filter tags
|
||||
const popCount = Object.keys(FilterBase._DEFAULT_META).length;
|
||||
if (popCount) keys = keys.slice(0, -popCount);
|
||||
}
|
||||
|
||||
// Pop keys from the end if they match the default value
|
||||
while (keys.length && defaultMeta[keys.last()] === this._meta[keys.last()]) keys.pop();
|
||||
|
||||
return keys.map(k => UrlUtil.mini.compress(this._meta[k] === undefined ? defaultMeta[k] : this._meta[k]));
|
||||
}
|
||||
|
||||
$render () { throw new Error(`Unimplemented!`); }
|
||||
$renderMinis () { throw new Error(`Unimplemented!`); }
|
||||
getValues ({nxtState = null} = {}) { throw new Error(`Unimplemented!`); }
|
||||
_mutNextState_reset () { throw new Error(`Unimplemented!`); }
|
||||
update () { throw new Error(`Unimplemented!`); }
|
||||
toDisplay () { throw new Error(`Unimplemented!`); }
|
||||
addItem () { throw new Error(`Unimplemented!`); }
|
||||
// N.B.: due to a bug in Chrome, these return a copy of the underlying state rather than a copy of the proxied state
|
||||
getSaveableState () { throw new Error(`Unimplemented!`); }
|
||||
setStateFromLoaded () { throw new Error(`Unimplemented!`); }
|
||||
getSubHashes () { throw new Error(`Unimplemented!`); }
|
||||
getNextStateFromSubhashState () { throw new Error(`Unimplemented!`); }
|
||||
setFromValues () { throw new Error(`Unimplemented!`); }
|
||||
handleSearch () { throw new Error(`Unimplemented`); }
|
||||
getFilterTagPart () { throw new Error(`Unimplemented`); }
|
||||
getDisplayStatePart ({nxtState = null} = {}) { throw new Error(`Unimplemented`); }
|
||||
_doTeardown () { /* No-op */ }
|
||||
trimState_ () { /* No-op */ }
|
||||
}
|
||||
|
||||
FilterBase._DEFAULT_META = {
|
||||
isHidden: false,
|
||||
isMobileHeaderHidden: true,
|
||||
};
|
||||
|
||||
// These are assumed to be the same length (4 characters)
|
||||
FilterBase._SUB_HASH_STATE_PREFIX = "flst";
|
||||
FilterBase._SUB_HASH_META_PREFIX = "flmt";
|
||||
FilterBase._SUB_HASH_NESTS_HIDDEN_PREFIX = "flnh";
|
||||
FilterBase._SUB_HASH_OPTIONS_PREFIX = "flop";
|
||||
FilterBase._SUB_HASH_PREFIXES = {
|
||||
state: FilterBase._SUB_HASH_STATE_PREFIX,
|
||||
meta: FilterBase._SUB_HASH_META_PREFIX,
|
||||
nestsHidden: FilterBase._SUB_HASH_NESTS_HIDDEN_PREFIX,
|
||||
options: FilterBase._SUB_HASH_OPTIONS_PREFIX,
|
||||
};
|
||||
|
||||
FilterRegistry.registerSubhashes(Object.values(FilterBase._SUB_HASH_PREFIXES));
|
||||
1084
js/filter/filter/filter-filter-generic.js
Normal file
1084
js/filter/filter/filter-filter-generic.js
Normal file
File diff suppressed because it is too large
Load Diff
307
js/filter/filter/filter-filter-multi.js
Normal file
307
js/filter/filter/filter-filter-multi.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import {FilterBase} from "./filter-filter-base.js";
|
||||
import {RangeFilter} from "./filter-filter-range.js";
|
||||
|
||||
export class MultiFilter extends FilterBase {
|
||||
constructor (opts) {
|
||||
super(opts);
|
||||
this._filters = opts.filters;
|
||||
this._isAddDropdownToggle = !!opts.isAddDropdownToggle;
|
||||
|
||||
Object.assign(
|
||||
this.__state,
|
||||
{
|
||||
...MultiFilter._DETAULT_STATE,
|
||||
mode: opts.mode || MultiFilter._DETAULT_STATE.mode,
|
||||
},
|
||||
);
|
||||
this._defaultState = MiscUtil.copy(this.__state);
|
||||
this._state = this._getProxy("state", this.__state);
|
||||
|
||||
this.__$wrpFilter = null;
|
||||
this._$wrpChildren = null;
|
||||
}
|
||||
|
||||
getChildFilters () {
|
||||
return [...this._filters, ...this._filters.map(f => f.getChildFilters())].flat();
|
||||
}
|
||||
|
||||
getSaveableState () {
|
||||
const out = {
|
||||
[this.header]: {
|
||||
...this.getBaseSaveableState(),
|
||||
state: {...this.__state},
|
||||
},
|
||||
};
|
||||
this._filters.forEach(it => Object.assign(out, it.getSaveableState()));
|
||||
return out;
|
||||
}
|
||||
|
||||
setStateFromLoaded (filterState, {isUserSavedState = false} = {}) {
|
||||
if (!filterState?.[this.header]) return;
|
||||
|
||||
const toLoad = filterState[this.header];
|
||||
this._hasUserSavedState = this._hasUserSavedState || isUserSavedState;
|
||||
this.setBaseStateFromLoaded(toLoad);
|
||||
Object.assign(this._state, toLoad.state);
|
||||
this._filters.forEach(it => it.setStateFromLoaded(filterState, {isUserSavedState}));
|
||||
}
|
||||
|
||||
getSubHashes () {
|
||||
const out = [];
|
||||
|
||||
const baseMeta = this.getMetaSubHashes();
|
||||
if (baseMeta) out.push(...baseMeta);
|
||||
|
||||
const anyNotDefault = this._getStateNotDefault();
|
||||
if (anyNotDefault.length) {
|
||||
out.push(UrlUtil.packSubHash(this.getSubHashPrefix("state", this.header), this._getCompressedState()));
|
||||
}
|
||||
|
||||
// each getSubHashes should return an array of arrays, or null
|
||||
// flatten any arrays of arrays into our array of arrays
|
||||
this._filters.map(it => it.getSubHashes()).filter(Boolean).forEach(it => out.push(...it));
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
_getStateNotDefault () {
|
||||
return Object.entries(this._defaultState)
|
||||
.filter(([k, v]) => this._state[k] !== v);
|
||||
}
|
||||
|
||||
// `meta` is not included, as it is used purely for UI
|
||||
getFilterTagPart () {
|
||||
return [
|
||||
this._getFilterTagPart_self(),
|
||||
...this._filters.map(it => it.getFilterTagPart()).filter(Boolean),
|
||||
]
|
||||
.filter(it => it != null)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
_getFilterTagPart_self () {
|
||||
const areNotDefaultState = this._getStateNotDefault();
|
||||
if (!areNotDefaultState.length) return null;
|
||||
|
||||
return `${this.header.toLowerCase()}=${this._getCompressedState().join(HASH_SUB_LIST_SEP)}`;
|
||||
}
|
||||
|
||||
getDisplayStatePart ({nxtState = null} = {}) {
|
||||
return this._filters.map(it => it.getDisplayStatePart({nxtState}))
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
_getCompressedState () {
|
||||
return Object.keys(this._defaultState)
|
||||
.map(k => UrlUtil.mini.compress(this._state[k] === undefined ? this._defaultState[k] : this._state[k]));
|
||||
}
|
||||
|
||||
setStateFromNextState (nxtState) {
|
||||
super.setStateFromNextState(nxtState);
|
||||
}
|
||||
|
||||
getNextStateFromSubhashState (state) {
|
||||
const nxtState = this._getNextState_base();
|
||||
|
||||
if (state == null) {
|
||||
this._mutNextState_reset_self(nxtState);
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
this._mutNextState_meta_fromSubHashState(nxtState, state);
|
||||
|
||||
let hasState = false;
|
||||
|
||||
Object.entries(state).forEach(([k, vals]) => {
|
||||
const prop = FilterBase.getProp(k);
|
||||
if (prop === "state") {
|
||||
hasState = true;
|
||||
const data = vals.map(v => UrlUtil.mini.decompress(v));
|
||||
Object.keys(this._defaultState).forEach((k, i) => nxtState[this.header].state[k] = data[i]);
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasState) this._mutNextState_reset_self(nxtState);
|
||||
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
setFromValues (values) {
|
||||
this._filters.forEach(it => it.setFromValues(values));
|
||||
}
|
||||
|
||||
_getHeaderControls (opts) {
|
||||
const wrpSummary = e_({
|
||||
tag: "div",
|
||||
clazz: "fltr__summary_item",
|
||||
}).hideVe();
|
||||
|
||||
const btnForceMobile = this._isAddDropdownToggle ? ComponentUiUtil.getBtnBool(
|
||||
this,
|
||||
"isUseDropdowns",
|
||||
{
|
||||
$ele: $(`<button class="btn btn-default btn-xs ml-2">Show as Dropdowns</button>`),
|
||||
stateName: "meta",
|
||||
stateProp: "_meta",
|
||||
},
|
||||
) : null;
|
||||
// Propagate parent state to children
|
||||
const hkChildrenDropdowns = () => {
|
||||
this._filters
|
||||
.filter(it => it instanceof RangeFilter)
|
||||
.forEach(it => it.isUseDropdowns = this._meta.isUseDropdowns);
|
||||
};
|
||||
this._addHook("meta", "isUseDropdowns", hkChildrenDropdowns);
|
||||
hkChildrenDropdowns();
|
||||
|
||||
const btnResetAll = e_({
|
||||
tag: "button",
|
||||
clazz: "btn btn-default btn-xs ml-2",
|
||||
text: "Reset All",
|
||||
click: () => this._filters.forEach(it => it.reset()),
|
||||
});
|
||||
|
||||
const wrpBtns = e_({tag: "div", clazz: "ve-flex", children: [btnForceMobile, btnResetAll].filter(Boolean)});
|
||||
this._getHeaderControls_addExtraStateBtns(opts, wrpBtns);
|
||||
|
||||
const btnShowHide = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default btn-xs ml-2 ${this._meta.isHidden ? "active" : ""}`,
|
||||
text: "Hide",
|
||||
click: () => this._meta.isHidden = !this._meta.isHidden,
|
||||
});
|
||||
const wrpControls = e_({tag: "div", clazz: "ve-flex-v-center", children: [wrpSummary, wrpBtns, btnShowHide]});
|
||||
|
||||
const hookShowHide = () => {
|
||||
wrpBtns.toggleVe(!this._meta.isHidden);
|
||||
btnShowHide.toggleClass("active", this._meta.isHidden);
|
||||
this._$wrpChildren.toggleVe(!this._meta.isHidden);
|
||||
wrpSummary.toggleVe(this._meta.isHidden);
|
||||
|
||||
const numActive = this._filters.map(it => it.getValues()[it.header]._isActive).filter(Boolean).length;
|
||||
if (numActive) {
|
||||
e_({ele: wrpSummary, title: `${numActive} hidden active filter${numActive === 1 ? "" : "s"}`, text: `(${numActive})`});
|
||||
}
|
||||
};
|
||||
this._addHook("meta", "isHidden", hookShowHide);
|
||||
hookShowHide();
|
||||
|
||||
return wrpControls;
|
||||
}
|
||||
|
||||
_getHeaderControls_addExtraStateBtns (opts, wrpStateBtnsOuter) {}
|
||||
|
||||
$render (opts) {
|
||||
const btnAndOr = e_({
|
||||
tag: "div",
|
||||
clazz: `fltr__group-comb-toggle ve-muted`,
|
||||
click: () => this._state.mode = this._state.mode === "and" ? "or" : "and",
|
||||
title: `"Group AND" requires all filters in this group to match. "Group OR" required any filter in this group to match.`,
|
||||
});
|
||||
|
||||
const hookAndOr = () => btnAndOr.innerText = `(group ${this._state.mode.toUpperCase()})`;
|
||||
this._addHook("state", "mode", hookAndOr);
|
||||
hookAndOr();
|
||||
|
||||
const $children = this._filters.map((it, i) => it.$render({...opts, isMulti: true, isFirst: i === 0}));
|
||||
this._$wrpChildren = $$`<div>${$children}</div>`;
|
||||
|
||||
const wrpControls = this._getHeaderControls(opts);
|
||||
|
||||
return this.__$wrpFilter = $$`<div class="ve-flex-col">
|
||||
${opts.isFirst ? "" : `<div class="fltr__dropdown-divider mb-1"></div>`}
|
||||
<div class="split fltr__h fltr__h--multi ${this._minimalUi ? "fltr__minimal-hide" : ""} mb-1">
|
||||
<div class="ve-flex-v-center">
|
||||
<div class="mr-2">${this._getRenderedHeader()}</div>
|
||||
${btnAndOr}
|
||||
</div>
|
||||
${wrpControls}
|
||||
</div>
|
||||
${this._$wrpChildren}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
$renderMinis (opts) {
|
||||
this._filters.map((it, i) => it.$renderMinis({...opts, isMulti: true, isFirst: i === 0}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param vals Previously-read filter value may be passed in for performance.
|
||||
*/
|
||||
isActive (vals) {
|
||||
vals = vals || this.getValues();
|
||||
return this._filters.some(it => it.isActive(vals));
|
||||
}
|
||||
|
||||
getValues ({nxtState = null} = {}) {
|
||||
const out = {};
|
||||
this._filters.forEach(it => Object.assign(out, it.getValues({nxtState})));
|
||||
return out;
|
||||
}
|
||||
|
||||
_mutNextState_reset_self (nxtState) {
|
||||
Object.assign(nxtState[this.header].state, MiscUtil.copy(this._defaultState));
|
||||
}
|
||||
|
||||
_mutNextState_reset (nxtState, {isResetAll = false} = {}) {
|
||||
if (isResetAll) this._mutNextState_resetBase(nxtState, {isResetAll});
|
||||
this._mutNextState_reset_self(nxtState);
|
||||
}
|
||||
|
||||
reset ({isResetAll = false} = {}) {
|
||||
super.reset({isResetAll});
|
||||
this._filters.forEach(it => it.reset({isResetAll}));
|
||||
}
|
||||
|
||||
update () {
|
||||
this._filters.forEach(it => it.update());
|
||||
}
|
||||
|
||||
toDisplay (boxState, entryValArr) {
|
||||
if (this._filters.length !== entryValArr.length) throw new Error("Number of filters and number of values did not match");
|
||||
|
||||
const results = [];
|
||||
for (let i = this._filters.length - 1; i >= 0; --i) {
|
||||
const f = this._filters[i];
|
||||
if (f instanceof RangeFilter) {
|
||||
results.push(f.toDisplay(boxState, entryValArr[i]));
|
||||
} else {
|
||||
const totals = boxState[f.header]._totals;
|
||||
|
||||
if (totals.yes === 0 && totals.no === 0) results.push(null);
|
||||
else results.push(f.toDisplay(boxState, entryValArr[i]));
|
||||
}
|
||||
}
|
||||
|
||||
const resultsActive = results.filter(r => r !== null);
|
||||
if (this._state.mode === "or") {
|
||||
if (!resultsActive.length) return true;
|
||||
return resultsActive.find(r => r);
|
||||
} else {
|
||||
return resultsActive.filter(r => r).length === resultsActive.length;
|
||||
}
|
||||
}
|
||||
|
||||
addItem () { throw new Error(`Cannot add item to MultiFilter! Add the item to a child filter instead.`); }
|
||||
|
||||
handleSearch (searchTerm) {
|
||||
const isHeaderMatch = this.header.toLowerCase().includes(searchTerm);
|
||||
|
||||
if (isHeaderMatch) {
|
||||
if (this.__$wrpFilter) this.__$wrpFilter.toggleClass("fltr__hidden--search", false);
|
||||
// Force-display the children if the parent is visible
|
||||
this._filters.forEach(it => it.handleSearch(""));
|
||||
return true;
|
||||
}
|
||||
|
||||
const numVisible = this._filters.map(it => it.handleSearch(searchTerm)).reduce((a, b) => a + b, 0);
|
||||
if (!this.__$wrpFilter) return;
|
||||
this.__$wrpFilter.toggleClass("fltr__hidden--search", numVisible === 0);
|
||||
}
|
||||
|
||||
_doTeardown () { this._filters.forEach(it => it._doTeardown()); }
|
||||
trimState_ () { this._filters.forEach(it => it.trimState_()); }
|
||||
}
|
||||
MultiFilter._DETAULT_STATE = {
|
||||
mode: "and",
|
||||
};
|
||||
302
js/filter/filter/filter-filter-options.js
Normal file
302
js/filter/filter/filter-filter-options.js
Normal file
@@ -0,0 +1,302 @@
|
||||
import {FilterBase} from "./filter-filter-base.js";
|
||||
import {FilterBox} from "../filter-box.js";
|
||||
|
||||
export class OptionsFilter extends FilterBase {
|
||||
/**
|
||||
* A filter which has a selection of true/false options.
|
||||
* @param opts
|
||||
* @param opts.defaultState The default options.
|
||||
* @param opts.displayFn Display function which maps an option key to a user-friendly value.
|
||||
* @param [opts.displayFnMini] As per `displayFn`, but used for mini pills.
|
||||
*/
|
||||
constructor (opts) {
|
||||
super(opts);
|
||||
this._defaultState = opts.defaultState;
|
||||
this._displayFn = opts.displayFn;
|
||||
this._displayFnMini = opts.displayFnMini;
|
||||
|
||||
Object.assign(
|
||||
this.__state,
|
||||
MiscUtil.copy(opts.defaultState),
|
||||
);
|
||||
|
||||
this._filterBox = null;
|
||||
this.__$wrpMini = null;
|
||||
}
|
||||
|
||||
getSaveableState () {
|
||||
return {
|
||||
[this.header]: {
|
||||
...this.getBaseSaveableState(),
|
||||
state: {...this.__state},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setStateFromLoaded (filterState, {isUserSavedState = false} = {}) {
|
||||
if (!filterState?.[this.header]) return;
|
||||
|
||||
const toLoad = filterState[this.header];
|
||||
this._hasUserSavedState = this._hasUserSavedState || isUserSavedState;
|
||||
|
||||
this.setBaseStateFromLoaded(toLoad);
|
||||
|
||||
const toAssign = {};
|
||||
Object.keys(this._defaultState).forEach(k => {
|
||||
if (toLoad.state[k] == null) return;
|
||||
if (typeof toLoad.state[k] !== typeof this._defaultState[k]) return; // Sanity check
|
||||
toAssign[k] = toLoad.state[k];
|
||||
});
|
||||
|
||||
Object.assign(this._state, toAssign);
|
||||
}
|
||||
|
||||
_getStateNotDefault () {
|
||||
return Object.entries(this._state)
|
||||
.filter(([k, v]) => this._defaultState[k] !== v);
|
||||
}
|
||||
|
||||
getSubHashes () {
|
||||
const out = [];
|
||||
|
||||
const baseMeta = this.getMetaSubHashes();
|
||||
if (baseMeta) out.push(...baseMeta);
|
||||
|
||||
const serOptionState = [];
|
||||
Object.entries(this._defaultState)
|
||||
.forEach(([k, vDefault]) => {
|
||||
if (this._state[k] !== vDefault) serOptionState.push(`${k.toLowerCase()}=${UrlUtil.mini.compress(this._state[k])}`);
|
||||
});
|
||||
if (serOptionState.length) {
|
||||
out.push(UrlUtil.packSubHash(this.getSubHashPrefix("state", this.header), serOptionState));
|
||||
}
|
||||
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
// `meta` is not included, as it is used purely for UI
|
||||
getFilterTagPart () {
|
||||
const areNotDefaultState = this._getStateNotDefault();
|
||||
if (!areNotDefaultState.length) return null;
|
||||
|
||||
const pt = areNotDefaultState
|
||||
.map(([k, v]) => `${v ? "" : "!"}${k}`)
|
||||
.join(";").toLowerCase();
|
||||
|
||||
return `${this.header.toLowerCase()}=::${pt}::`;
|
||||
}
|
||||
|
||||
getDisplayStatePart ({nxtState = null} = {}) {
|
||||
/* Implement if required */
|
||||
return null;
|
||||
}
|
||||
|
||||
getNextStateFromSubhashState (state) {
|
||||
const nxtState = this._getNextState_base();
|
||||
|
||||
if (state == null) {
|
||||
this._mutNextState_reset(nxtState);
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
this._mutNextState_meta_fromSubHashState(nxtState, state);
|
||||
|
||||
let hasState = false;
|
||||
|
||||
Object.entries(state).forEach(([k, vals]) => {
|
||||
const prop = FilterBase.getProp(k);
|
||||
if (prop !== "state") return;
|
||||
|
||||
hasState = true;
|
||||
vals.forEach(v => {
|
||||
const [prop, valCompressed] = v.split("=");
|
||||
const val = UrlUtil.mini.decompress(valCompressed);
|
||||
|
||||
const casedProp = Object.keys(this._defaultState).find(k => k.toLowerCase() === prop);
|
||||
if (!casedProp) return;
|
||||
|
||||
if (this._defaultState[casedProp] != null && typeof val === typeof this._defaultState[casedProp]) nxtState[this.header].state[casedProp] = val;
|
||||
});
|
||||
});
|
||||
|
||||
if (!hasState) this._mutNextState_reset(nxtState);
|
||||
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
setFromValues (values) {
|
||||
if (!values[this.header]) return;
|
||||
const vals = values[this.header];
|
||||
Object.entries(vals).forEach(([k, v]) => {
|
||||
if (this._defaultState[k] && typeof this._defaultState[k] === typeof v) this._state[k] = v;
|
||||
});
|
||||
}
|
||||
|
||||
setValue (k, v) { this._state[k] = v; }
|
||||
|
||||
/**
|
||||
* @param opts Options.
|
||||
* @param opts.filterBox The FilterBox to which this filter is attached.
|
||||
* @param opts.isFirst True if this is visually the first filter in the box.
|
||||
* @param opts.$wrpMini The form mini-view element.
|
||||
* @param opts.isMulti The name of the MultiFilter this filter belongs to, if any.
|
||||
*/
|
||||
$render (opts) {
|
||||
this._filterBox = opts.filterBox;
|
||||
this.__$wrpMini = opts.$wrpMini;
|
||||
|
||||
const $wrpControls = opts.isMulti ? null : this._$getHeaderControls();
|
||||
|
||||
const $btns = Object.keys(this._defaultState)
|
||||
.map(k => this._$render_$getPill(k));
|
||||
const $wrpButtons = $$`<div>${$btns}</div>`;
|
||||
|
||||
if (opts.isMulti) {
|
||||
return this.__$wrpFilter = $$`<div class="ve-flex">
|
||||
<div class="fltr__range-inline-label mr-2">${this._getRenderedHeader()}</div>
|
||||
${$wrpButtons}
|
||||
</div>`;
|
||||
} else {
|
||||
return this.__$wrpFilter = $$`<div class="ve-flex-col">
|
||||
${opts.isFirst ? "" : `<div class="fltr__dropdown-divider mb-1"></div>`}
|
||||
<div class="split fltr__h ${this._minimalUi ? "fltr__minimal-hide" : ""} mb-1">
|
||||
<div class="fltr__h-text ve-flex-h-center">${this._getRenderedHeader()}</div>
|
||||
${$wrpControls}
|
||||
</div>
|
||||
${$wrpButtons}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
$renderMinis (opts) {
|
||||
if (!opts.$wrpMini) return;
|
||||
|
||||
this._filterBox = opts.filterBox;
|
||||
this.__$wrpMini = opts.$wrpMini;
|
||||
|
||||
const $btnsMini = Object.keys(this._defaultState)
|
||||
.map(k => this._$render_$getMiniPill(k));
|
||||
$btnsMini.forEach($btn => $btn.appendTo(this.__$wrpMini));
|
||||
}
|
||||
|
||||
_$render_$getPill (key) {
|
||||
const displayText = this._displayFn(key);
|
||||
|
||||
const $btnPill = $(`<div class="fltr__pill">${displayText}</div>`)
|
||||
.click(() => {
|
||||
this._state[key] = !this._state[key];
|
||||
})
|
||||
.contextmenu((evt) => {
|
||||
evt.preventDefault();
|
||||
this._state[key] = !this._state[key];
|
||||
});
|
||||
const hook = () => {
|
||||
const val = FilterBox._PILL_STATES[this._state[key] ? 1 : 2];
|
||||
$btnPill.attr("state", val);
|
||||
};
|
||||
this._addHook("state", key, hook);
|
||||
hook();
|
||||
|
||||
return $btnPill;
|
||||
}
|
||||
|
||||
_$render_$getMiniPill (key) {
|
||||
const displayTextFull = this._displayFnMini ? this._displayFn(key) : null;
|
||||
const displayText = this._displayFnMini ? this._displayFnMini(key) : this._displayFn(key);
|
||||
|
||||
const $btnMini = $(`<div class="fltr__mini-pill ${this._filterBox.isMinisHidden(this.header) ? "ve-hidden" : ""}" state="${FilterBox._PILL_STATES[this._defaultState[key] === this._state[key] ? 0 : this._state[key] ? 1 : 2]}">${displayText}</div>`)
|
||||
.title(`${displayTextFull ? `${displayTextFull} (` : ""}Filter: ${this.header}${displayTextFull ? ")" : ""}`)
|
||||
.click(() => {
|
||||
this._state[key] = this._defaultState[key];
|
||||
this._filterBox.fireChangeEvent();
|
||||
});
|
||||
|
||||
const hook = () => $btnMini.attr("state", FilterBox._PILL_STATES[this._defaultState[key] === this._state[key] ? 0 : this._state[key] ? 1 : 2]);
|
||||
this._addHook("state", key, hook);
|
||||
|
||||
const hideHook = () => $btnMini.toggleClass("ve-hidden", this._filterBox.isMinisHidden(this.header));
|
||||
this._filterBox.registerMinisHiddenHook(this.header, hideHook);
|
||||
|
||||
return $btnMini;
|
||||
}
|
||||
|
||||
_$getHeaderControls () {
|
||||
const $btnReset = $(`<button class="btn btn-default btn-xs">Reset</button>`).click(() => this.reset());
|
||||
const $wrpBtns = $$`<div class="ve-flex-v-center">${$btnReset}</div>`;
|
||||
|
||||
const $wrpSummary = $(`<div class="ve-flex-v-center fltr__summary_item fltr__summary_item--include"></div>`).hideVe();
|
||||
|
||||
const $btnShowHide = $(`<button class="btn btn-default btn-xs ml-2 ${this._meta.isHidden ? "active" : ""}">Hide</button>`)
|
||||
.click(() => this._meta.isHidden = !this._meta.isHidden);
|
||||
const hkIsHidden = () => {
|
||||
$btnShowHide.toggleClass("active", this._meta.isHidden);
|
||||
$wrpBtns.toggleVe(!this._meta.isHidden);
|
||||
$wrpSummary.toggleVe(this._meta.isHidden);
|
||||
|
||||
// render summary
|
||||
const cntNonDefault = Object.entries(this._defaultState).filter(([k, v]) => this._state[k] != null && this._state[k] !== v).length;
|
||||
|
||||
$wrpSummary
|
||||
.title(`${cntNonDefault} non-default option${cntNonDefault === 1 ? "" : "s"} selected`)
|
||||
.text(cntNonDefault);
|
||||
};
|
||||
this._addHook("meta", "isHidden", hkIsHidden);
|
||||
hkIsHidden();
|
||||
|
||||
return $$`
|
||||
<div class="ve-flex-v-center">
|
||||
${$wrpBtns}
|
||||
${$wrpSummary}
|
||||
${$btnShowHide}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
getValues ({nxtState = null} = {}) {
|
||||
const state = nxtState?.[this.header]?.state || this.__state;
|
||||
|
||||
const out = Object.entries(this._defaultState)
|
||||
.mergeMap(([k, v]) => ({[k]: state[k] == null ? v : state[k]}));
|
||||
out._isActive = Object.entries(this._defaultState).some(([k, v]) => state[k] != null && state[k] !== v);
|
||||
return {
|
||||
[this.header]: out,
|
||||
};
|
||||
}
|
||||
|
||||
_mutNextState_reset (nxtState, {isResetAll = false} = {}) {
|
||||
if (isResetAll) this._mutNextState_resetBase(nxtState, {isResetAll});
|
||||
Object.assign(nxtState[this.header].state, MiscUtil.copy(this._defaultState));
|
||||
}
|
||||
|
||||
update () { /* No-op */ }
|
||||
|
||||
toDisplay (boxState, entryVal) {
|
||||
const filterState = boxState[this.header];
|
||||
if (!filterState) return true; // discount any filters which were not rendered
|
||||
|
||||
if (entryVal == null) return true; // Never filter if a null object, i.e. "no data," is passed in
|
||||
|
||||
// If an object has a relevant value, display if the incoming value matches our state.
|
||||
return Object.entries(entryVal)
|
||||
.every(([k, v]) => this._state[k] === v);
|
||||
}
|
||||
|
||||
getDefaultMeta () {
|
||||
// Key order is important, as @filter tags depend on it
|
||||
return {
|
||||
...super.getDefaultMeta(),
|
||||
...OptionsFilter._DEFAULT_META,
|
||||
};
|
||||
}
|
||||
|
||||
handleSearch (searchTerm) {
|
||||
if (this.__$wrpFilter == null) return;
|
||||
|
||||
const isVisible = this.header.toLowerCase().includes(searchTerm)
|
||||
|| Object.keys(this._defaultState).map(it => this._displayFn(it).toLowerCase()).some(it => it.includes(searchTerm));
|
||||
|
||||
this.__$wrpFilter.toggleClass("fltr__hidden--search", !isVisible);
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
}
|
||||
OptionsFilter._DEFAULT_META = {};
|
||||
658
js/filter/filter/filter-filter-range.js
Normal file
658
js/filter/filter/filter-filter-range.js
Normal file
@@ -0,0 +1,658 @@
|
||||
import {FilterBase} from "./filter-filter-base.js";
|
||||
import {FilterBox} from "../filter-box.js";
|
||||
|
||||
export class RangeFilter extends FilterBase {
|
||||
/**
|
||||
* @param opts Options object.
|
||||
* @param opts.header Filter header (name)
|
||||
* @param [opts.headerHelp] Filter header help text (tooltip)
|
||||
* @param [opts.min] Minimum slider value.
|
||||
* @param [opts.max] Maximum slider value.
|
||||
* @param [opts.isSparse] If this slider should only display known values, rather than a continual range.
|
||||
* @param [opts.isLabelled] If this slider has labels.
|
||||
* @param [opts.labels] Initial labels to populate this filter with.
|
||||
* @param [opts.isAllowGreater] If this slider should allow all items greater than its max.
|
||||
* @param [opts.isRequireFullRangeMatch] If range values, e.g. `[1, 5]`, must be entirely within the slider's
|
||||
* selected range in order to be produce a positive `toDisplay` result.
|
||||
* @param [opts.suffix] Suffix to add to number displayed above slider.
|
||||
* @param [opts.labelSortFn] Function used to sort labels if new labels are added. Defaults to ascending alphabetical.
|
||||
* @param [opts.labelDisplayFn] Function which converts a label to a display value.
|
||||
* @param [opts.displayFn] Function which converts a (non-label) value to a display value.
|
||||
* @param [opts.displayFnTooltip] Function which converts a (non-label) value to a tooltip display value.
|
||||
*/
|
||||
constructor (opts) {
|
||||
super(opts);
|
||||
|
||||
if (opts.labels && opts.min == null) opts.min = 0;
|
||||
if (opts.labels && opts.max == null) opts.max = opts.labels.length - 1;
|
||||
|
||||
this._min = Number(opts.min || 0);
|
||||
this._max = Number(opts.max || 0);
|
||||
this._labels = opts.isLabelled ? opts.labels : null;
|
||||
this._isAllowGreater = !!opts.isAllowGreater;
|
||||
this._isRequireFullRangeMatch = !!opts.isRequireFullRangeMatch;
|
||||
this._sparseValues = opts.isSparse ? [] : null;
|
||||
this._suffix = opts.suffix;
|
||||
this._labelSortFn = opts.labelSortFn === undefined ? SortUtil.ascSort : opts.labelSortFn;
|
||||
this._labelDisplayFn = opts.labelDisplayFn;
|
||||
this._displayFn = opts.displayFn;
|
||||
this._displayFnTooltip = opts.displayFnTooltip;
|
||||
|
||||
this._filterBox = null;
|
||||
Object.assign(
|
||||
this.__state,
|
||||
{
|
||||
min: this._min,
|
||||
max: this._max,
|
||||
curMin: this._min,
|
||||
curMax: this._max,
|
||||
},
|
||||
);
|
||||
this.__$wrpFilter = null;
|
||||
this.__$wrpMini = null;
|
||||
this._slider = null;
|
||||
|
||||
this._labelSearchCache = null;
|
||||
|
||||
this._$btnMiniGt = null;
|
||||
this._$btnMiniLt = null;
|
||||
this._$btnMiniEq = null;
|
||||
|
||||
// region Trimming
|
||||
this._seenMin = this._min;
|
||||
this._seenMax = this._max;
|
||||
// endregion
|
||||
}
|
||||
|
||||
set isUseDropdowns (val) { this._meta.isUseDropdowns = !!val; }
|
||||
|
||||
getSaveableState () {
|
||||
return {
|
||||
[this.header]: {
|
||||
...this.getBaseSaveableState(),
|
||||
state: {...this.__state},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setStateFromLoaded (filterState, {isUserSavedState = false} = {}) {
|
||||
if (!filterState?.[this.header]) return;
|
||||
|
||||
const toLoad = filterState[this.header];
|
||||
this._hasUserSavedState = this._hasUserSavedState || isUserSavedState;
|
||||
|
||||
// region Ensure to-be-loaded state is populated with sensible data
|
||||
const tgt = (toLoad.state || {});
|
||||
|
||||
if (tgt.max == null) tgt.max = this._max;
|
||||
else if (this._max > tgt.max) {
|
||||
if (tgt.max === tgt.curMax) tgt.curMax = this._max; // If it's set to "max", respect this
|
||||
tgt.max = this._max;
|
||||
}
|
||||
|
||||
if (tgt.curMax == null) tgt.curMax = tgt.max;
|
||||
else if (tgt.curMax > tgt.max) tgt.curMax = tgt.max;
|
||||
|
||||
if (tgt.min == null) tgt.min = this._min;
|
||||
else if (this._min < tgt.min) {
|
||||
if (tgt.min === tgt.curMin) tgt.curMin = this._min; // If it's set to "min", respect this
|
||||
tgt.min = this._min;
|
||||
}
|
||||
|
||||
if (tgt.curMin == null) tgt.curMin = tgt.min;
|
||||
else if (tgt.curMin < tgt.min) tgt.curMin = tgt.min;
|
||||
// endregion
|
||||
|
||||
this.setBaseStateFromLoaded(toLoad);
|
||||
|
||||
Object.assign(this._state, toLoad.state);
|
||||
}
|
||||
|
||||
trimState_ () {
|
||||
if (this._seenMin <= this._state.min && this._seenMax >= this._state.max) return;
|
||||
|
||||
const nxtState = {min: this._seenMin, curMin: this._seenMin, max: this._seenMax, curMax: this._seenMax};
|
||||
this._proxyAssignSimple("state", nxtState);
|
||||
}
|
||||
|
||||
getSubHashes () {
|
||||
const out = [];
|
||||
|
||||
const baseMeta = this.getMetaSubHashes();
|
||||
if (baseMeta) out.push(...baseMeta);
|
||||
|
||||
const serSliderState = [
|
||||
this._state.min !== this._state.curMin ? `min=${this._state.curMin}` : null,
|
||||
this._state.max !== this._state.curMax ? `max=${this._state.curMax}` : null,
|
||||
].filter(Boolean);
|
||||
if (serSliderState.length) {
|
||||
out.push(UrlUtil.packSubHash(this.getSubHashPrefix("state", this.header), serSliderState));
|
||||
}
|
||||
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
_isAtDefaultPosition ({nxtState = null} = {}) {
|
||||
const state = nxtState?.[this.header]?.state || this.__state;
|
||||
return state.min === state.curMin && state.max === state.curMax;
|
||||
}
|
||||
|
||||
// `meta` is not included, as it is used purely for UI
|
||||
getFilterTagPart () {
|
||||
if (this._isAtDefaultPosition()) return null;
|
||||
|
||||
if (!this._labels) {
|
||||
if (this._state.curMin === this._state.curMax) return `${this.header}=[${this._state.curMin}]`;
|
||||
return `${this.header}=[${this._state.curMin};${this._state.curMax}]`;
|
||||
}
|
||||
|
||||
if (this._state.curMin === this._state.curMax) {
|
||||
const label = this._labels[this._state.curMin];
|
||||
return `${this.header}=[&${label}]`;
|
||||
}
|
||||
|
||||
const labelLow = this._labels[this._state.curMin];
|
||||
const labelHigh = this._labels[this._state.curMax];
|
||||
return `${this.header}=[&${labelLow};&${labelHigh}]`;
|
||||
}
|
||||
|
||||
getDisplayStatePart ({nxtState = null} = {}) {
|
||||
if (this._isAtDefaultPosition({nxtState})) return null;
|
||||
|
||||
const {summary} = this._getDisplaySummary({nxtState});
|
||||
|
||||
return `${this.header}: ${summary}`;
|
||||
}
|
||||
|
||||
getNextStateFromSubhashState (state) {
|
||||
const nxtState = this._getNextState_base();
|
||||
|
||||
if (state == null) {
|
||||
this._mutNextState_reset(nxtState);
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
this._mutNextState_meta_fromSubHashState(nxtState, state);
|
||||
|
||||
let hasState = false;
|
||||
|
||||
Object.entries(state).forEach(([k, vals]) => {
|
||||
const prop = FilterBase.getProp(k);
|
||||
if (prop === "state") {
|
||||
hasState = true;
|
||||
vals.forEach(v => {
|
||||
const [prop, val] = v.split("=");
|
||||
if (val.startsWith("&") && !this._labels) throw new Error(`Could not dereference label: "${val}"`);
|
||||
|
||||
let num;
|
||||
if (val.startsWith("&")) { // prefixed with "&" for "address (index) of..."
|
||||
const clean = val.replace("&", "").toLowerCase();
|
||||
num = this._labels.findIndex(it => String(it).toLowerCase() === clean);
|
||||
if (!~num) throw new Error(`Could not find index for label "${clean}"`);
|
||||
} else num = Number(val);
|
||||
|
||||
switch (prop) {
|
||||
case "min":
|
||||
if (num < nxtState[this.header].state.min) nxtState[this.header].state.min = num;
|
||||
nxtState[this.header].state.curMin = Math.max(nxtState[this.header].state.min, num);
|
||||
break;
|
||||
case "max":
|
||||
if (num > nxtState[this.header].state.max) nxtState[this.header].state.max = num;
|
||||
nxtState[this.header].state.curMax = Math.min(nxtState[this.header].state.max, num);
|
||||
break;
|
||||
default: throw new Error(`Unknown prop "${prop}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasState) this._mutNextState_reset(nxtState);
|
||||
|
||||
return nxtState;
|
||||
}
|
||||
|
||||
setFromValues (values) {
|
||||
if (!values[this.header]) return;
|
||||
|
||||
const vals = values[this.header];
|
||||
|
||||
if (vals.min != null) this._state.curMin = Math.max(this._state.min, vals.min);
|
||||
else this._state.curMin = this._state.min;
|
||||
|
||||
if (vals.max != null) this._state.curMax = Math.max(this._state.max, vals.max);
|
||||
else this._state.curMax = this._state.max;
|
||||
}
|
||||
|
||||
_$getHeaderControls () {
|
||||
const $btnForceMobile = ComponentUiUtil.$getBtnBool(
|
||||
this,
|
||||
"isUseDropdowns",
|
||||
{
|
||||
$ele: $(`<button class="btn btn-default btn-xs mr-2">Show as Dropdowns</button>`),
|
||||
stateName: "meta",
|
||||
stateProp: "_meta",
|
||||
},
|
||||
);
|
||||
const $btnReset = $(`<button class="btn btn-default btn-xs">Reset</button>`).click(() => this.reset());
|
||||
const $wrpBtns = $$`<div>${$btnForceMobile}${$btnReset}</div>`;
|
||||
|
||||
const $wrpSummary = $(`<div class="ve-flex-v-center fltr__summary_item fltr__summary_item--include"></div>`).hideVe();
|
||||
|
||||
const $btnShowHide = $(`<button class="btn btn-default btn-xs ml-2 ${this._meta.isHidden ? "active" : ""}">Hide</button>`)
|
||||
.click(() => this._meta.isHidden = !this._meta.isHidden);
|
||||
const hkIsHidden = () => {
|
||||
$btnShowHide.toggleClass("active", this._meta.isHidden);
|
||||
$wrpBtns.toggleVe(!this._meta.isHidden);
|
||||
$wrpSummary.toggleVe(this._meta.isHidden);
|
||||
|
||||
// render summary
|
||||
const {summaryTitle, summary} = this._getDisplaySummary();
|
||||
$wrpSummary
|
||||
.title(summaryTitle)
|
||||
.text(summary);
|
||||
};
|
||||
this._addHook("meta", "isHidden", hkIsHidden);
|
||||
hkIsHidden();
|
||||
|
||||
return $$`
|
||||
<div class="ve-flex-v-center">
|
||||
${$wrpBtns}
|
||||
${$wrpSummary}
|
||||
${$btnShowHide}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_getDisplaySummary ({nxtState = null} = {}) {
|
||||
const cur = this.getValues({nxtState})[this.header];
|
||||
|
||||
const isRange = !cur.isMinVal && !cur.isMaxVal;
|
||||
const isCapped = !cur.isMinVal || !cur.isMaxVal;
|
||||
|
||||
return {
|
||||
summaryTitle: isRange ? `Hidden range` : isCapped ? `Hidden limit` : "",
|
||||
summary: isRange ? `${this._getDisplayText(cur.min)}-${this._getDisplayText(cur.max)}` : !cur.isMinVal ? `≥ ${this._getDisplayText(cur.min)}` : !cur.isMaxVal ? `≤ ${this._getDisplayText(cur.max)}` : "",
|
||||
};
|
||||
}
|
||||
|
||||
_getDisplayText (value, {isBeyondMax = false, isTooltip = false} = {}) {
|
||||
value = `${this._labels ? this._labelDisplayFn ? this._labelDisplayFn(this._labels[value]) : this._labels[value] : (isTooltip && this._displayFnTooltip) ? this._displayFnTooltip(value) : this._displayFn ? this._displayFn(value) : value}${isBeyondMax ? "+" : ""}`;
|
||||
if (this._suffix) value += this._suffix;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param opts Options.
|
||||
* @param opts.filterBox The FilterBox to which this filter is attached.
|
||||
* @param opts.isFirst True if this is visually the first filter in the box.
|
||||
* @param opts.$wrpMini The form mini-view element.
|
||||
* @param opts.isMulti The name of the MultiFilter this filter belongs to, if any.
|
||||
*/
|
||||
$render (opts) {
|
||||
this._filterBox = opts.filterBox;
|
||||
this.__$wrpMini = opts.$wrpMini;
|
||||
|
||||
const $wrpControls = opts.isMulti ? null : this._$getHeaderControls();
|
||||
|
||||
const $wrpSlider = $$`<div class="fltr__wrp-pills fltr__wrp-pills--flex"></div>`;
|
||||
const $wrpDropdowns = $$`<div class="fltr__wrp-pills fltr__wrp-pills--flex"></div>`;
|
||||
const hookHidden = () => {
|
||||
$wrpSlider.toggleVe(!this._meta.isHidden && !this._meta.isUseDropdowns);
|
||||
$wrpDropdowns.toggleVe(!this._meta.isHidden && !!this._meta.isUseDropdowns);
|
||||
};
|
||||
this._addHook("meta", "isHidden", hookHidden);
|
||||
this._addHook("meta", "isUseDropdowns", hookHidden);
|
||||
hookHidden();
|
||||
|
||||
// region Slider
|
||||
// ensure sparse values are correctly constrained
|
||||
if (this._sparseValues?.length) {
|
||||
const sparseMin = this._sparseValues[0];
|
||||
if (this._state.min < sparseMin) {
|
||||
this._state.curMin = Math.max(this._state.curMin, sparseMin);
|
||||
this._state.min = sparseMin;
|
||||
}
|
||||
|
||||
const sparseMax = this._sparseValues.last();
|
||||
if (this._state.max > sparseMax) {
|
||||
this._state.curMax = Math.min(this._state.curMax, sparseMax);
|
||||
this._state.max = sparseMax;
|
||||
}
|
||||
}
|
||||
|
||||
// prepare slider options
|
||||
const getSliderOpts = () => {
|
||||
const fnDisplay = (val, {isTooltip = false} = {}) => {
|
||||
return this._getDisplayText(val, {isBeyondMax: this._isAllowGreater && val === this._state.max, isTooltip});
|
||||
};
|
||||
|
||||
return {
|
||||
propMin: "min",
|
||||
propMax: "max",
|
||||
propCurMin: "curMin",
|
||||
propCurMax: "curMax",
|
||||
fnDisplay: (val) => fnDisplay(val),
|
||||
fnDisplayTooltip: (val) => fnDisplay(val, {isTooltip: true}),
|
||||
sparseValues: this._sparseValues,
|
||||
};
|
||||
};
|
||||
|
||||
const hkUpdateLabelSearchCache = () => {
|
||||
if (this._labels) return this._doUpdateLabelSearchCache();
|
||||
this._labelSearchCache = null;
|
||||
};
|
||||
this._addHook("state", "curMin", hkUpdateLabelSearchCache);
|
||||
this._addHook("state", "curMax", hkUpdateLabelSearchCache);
|
||||
hkUpdateLabelSearchCache();
|
||||
|
||||
this._slider = new ComponentUiUtil.RangeSlider({comp: this, ...getSliderOpts()});
|
||||
$wrpSlider.append(this._slider.get());
|
||||
// endregion
|
||||
|
||||
// region Dropdowns
|
||||
const selMin = e_({
|
||||
tag: "select",
|
||||
clazz: `form-control mr-2`,
|
||||
change: () => {
|
||||
const nxtMin = Number(selMin.val());
|
||||
const [min, max] = [nxtMin, this._state.curMax].sort(SortUtil.ascSort);
|
||||
this._state.curMin = min;
|
||||
this._state.curMax = max;
|
||||
},
|
||||
});
|
||||
const selMax = e_({
|
||||
tag: "select",
|
||||
clazz: `form-control`,
|
||||
change: () => {
|
||||
const nxMax = Number(selMax.val());
|
||||
const [min, max] = [this._state.curMin, nxMax].sort(SortUtil.ascSort);
|
||||
this._state.curMin = min;
|
||||
this._state.curMax = max;
|
||||
},
|
||||
});
|
||||
$$`<div class="ve-flex-v-center w-100 px-3 py-1">${selMin}${selMax}</div>`.appendTo($wrpDropdowns);
|
||||
// endregion
|
||||
|
||||
const handleCurUpdate = () => {
|
||||
// Dropdowns
|
||||
selMin.val(`${this._state.curMin}`);
|
||||
selMax.val(`${this._state.curMax}`);
|
||||
};
|
||||
|
||||
const handleLimitUpdate = () => {
|
||||
// Dropdowns
|
||||
this._doPopulateDropdown(selMin, this._state.curMin);
|
||||
this._doPopulateDropdown(selMax, this._state.curMax);
|
||||
};
|
||||
|
||||
this._addHook("state", "min", handleLimitUpdate);
|
||||
this._addHook("state", "max", handleLimitUpdate);
|
||||
this._addHook("state", "curMin", handleCurUpdate);
|
||||
this._addHook("state", "curMax", handleCurUpdate);
|
||||
handleCurUpdate();
|
||||
handleLimitUpdate();
|
||||
|
||||
if (opts.isMulti) {
|
||||
this._slider.get().classList.add("ve-grow");
|
||||
$wrpSlider.addClass("ve-grow");
|
||||
$wrpDropdowns.addClass("ve-grow");
|
||||
|
||||
return this.__$wrpFilter = $$`<div class="ve-flex">
|
||||
<div class="fltr__range-inline-label mr-2">${this._getRenderedHeader()}</div>
|
||||
${$wrpSlider}
|
||||
${$wrpDropdowns}
|
||||
</div>`;
|
||||
} else {
|
||||
const btnMobToggleControls = this._getBtnMobToggleControls($wrpControls);
|
||||
|
||||
return this.__$wrpFilter = $$`<div class="ve-flex-col">
|
||||
${opts.isFirst ? "" : `<div class="fltr__dropdown-divider mb-1"></div>`}
|
||||
<div class="split fltr__h ${this._minimalUi ? "fltr__minimal-hide" : ""} mb-1">
|
||||
<div class="fltr__h-text ve-flex-h-center">${this._getRenderedHeader()}${btnMobToggleControls}</div>
|
||||
${$wrpControls}
|
||||
</div>
|
||||
${$wrpSlider}
|
||||
${$wrpDropdowns}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
$renderMinis (opts) {
|
||||
if (!opts.$wrpMini) return;
|
||||
|
||||
this._filterBox = opts.filterBox;
|
||||
this.__$wrpMini = opts.$wrpMini;
|
||||
|
||||
// region Mini pills
|
||||
this._$btnMiniGt = this._$btnMiniGt || $(`<div class="fltr__mini-pill" state="ignore"></div>`)
|
||||
.click(() => {
|
||||
this._state.curMin = this._state.min;
|
||||
this._filterBox.fireChangeEvent();
|
||||
});
|
||||
this._$btnMiniGt.appendTo(this.__$wrpMini);
|
||||
|
||||
this._$btnMiniLt = this._$btnMiniLt || $(`<div class="fltr__mini-pill" state="ignore"></div>`)
|
||||
.click(() => {
|
||||
this._state.curMax = this._state.max;
|
||||
this._filterBox.fireChangeEvent();
|
||||
});
|
||||
this._$btnMiniLt.appendTo(this.__$wrpMini);
|
||||
|
||||
this._$btnMiniEq = this._$btnMiniEq || $(`<div class="fltr__mini-pill" state="ignore"></div>`)
|
||||
.click(() => {
|
||||
this._state.curMin = this._state.min;
|
||||
this._state.curMax = this._state.max;
|
||||
this._filterBox.fireChangeEvent();
|
||||
});
|
||||
this._$btnMiniEq.appendTo(this.__$wrpMini);
|
||||
|
||||
const hideHook = () => {
|
||||
const isHidden = this._filterBox.isMinisHidden(this.header);
|
||||
this._$btnMiniGt.toggleClass("ve-hidden", isHidden);
|
||||
this._$btnMiniLt.toggleClass("ve-hidden", isHidden);
|
||||
this._$btnMiniEq.toggleClass("ve-hidden", isHidden);
|
||||
};
|
||||
this._filterBox.registerMinisHiddenHook(this.header, hideHook);
|
||||
hideHook();
|
||||
|
||||
const handleMiniUpdate = () => {
|
||||
if (this._state.curMin === this._state.curMax) {
|
||||
this._$btnMiniGt.attr("state", FilterBox._PILL_STATES[0]);
|
||||
this._$btnMiniLt.attr("state", FilterBox._PILL_STATES[0]);
|
||||
|
||||
this._$btnMiniEq
|
||||
.attr("state", this._isAtDefaultPosition() ? FilterBox._PILL_STATES[0] : FilterBox._PILL_STATES[1])
|
||||
.text(`${this.header} = ${this._getDisplayText(this._state.curMin, {isBeyondMax: this._isAllowGreater && this._state.curMin === this._state.max})}`);
|
||||
} else {
|
||||
if (this._state.min !== this._state.curMin) {
|
||||
this._$btnMiniGt.attr("state", FilterBox._PILL_STATES[1])
|
||||
.text(`${this.header} ≥ ${this._getDisplayText(this._state.curMin)}`);
|
||||
} else this._$btnMiniGt.attr("state", FilterBox._PILL_STATES[0]);
|
||||
|
||||
if (this._state.max !== this._state.curMax) {
|
||||
this._$btnMiniLt.attr("state", FilterBox._PILL_STATES[1])
|
||||
.text(`${this.header} ≤ ${this._getDisplayText(this._state.curMax)}`);
|
||||
} else this._$btnMiniLt.attr("state", FilterBox._PILL_STATES[0]);
|
||||
|
||||
this._$btnMiniEq.attr("state", FilterBox._PILL_STATES[0]);
|
||||
}
|
||||
};
|
||||
// endregion
|
||||
|
||||
const handleCurUpdate = () => {
|
||||
handleMiniUpdate();
|
||||
};
|
||||
|
||||
const handleLimitUpdate = () => {
|
||||
handleMiniUpdate();
|
||||
};
|
||||
|
||||
this._addHook("state", "min", handleLimitUpdate);
|
||||
this._addHook("state", "max", handleLimitUpdate);
|
||||
this._addHook("state", "curMin", handleCurUpdate);
|
||||
this._addHook("state", "curMax", handleCurUpdate);
|
||||
handleCurUpdate();
|
||||
handleLimitUpdate();
|
||||
}
|
||||
|
||||
_doPopulateDropdown (sel, curVal) {
|
||||
let tmp = "";
|
||||
for (let i = 0, len = this._state.max - this._state.min + 1; i < len; ++i) {
|
||||
const val = i + this._state.min;
|
||||
const label = `${this._getDisplayText(val)}`.qq();
|
||||
tmp += `<option value="${val}" ${curVal === val ? "selected" : ""}>${label}</option>`;
|
||||
}
|
||||
sel.innerHTML = tmp;
|
||||
return sel;
|
||||
}
|
||||
|
||||
getValues ({nxtState = null} = {}) {
|
||||
const state = nxtState?.[this.header]?.state || this.__state;
|
||||
|
||||
const out = {
|
||||
isMaxVal: state.max === state.curMax,
|
||||
isMinVal: state.min === state.curMin,
|
||||
max: state.curMax,
|
||||
min: state.curMin,
|
||||
};
|
||||
out._isActive = !(out.isMinVal && out.isMaxVal);
|
||||
return {[this.header]: out};
|
||||
}
|
||||
|
||||
_mutNextState_reset (nxtState, {isResetAll = false} = {}) {
|
||||
if (isResetAll) this._mutNextState_resetBase(nxtState, {isResetAll});
|
||||
nxtState[this.header].state.curMin = nxtState[this.header].state.min;
|
||||
nxtState[this.header].state.curMax = nxtState[this.header].state.max;
|
||||
}
|
||||
|
||||
update () {
|
||||
if (!this.__$wrpMini) return;
|
||||
|
||||
// (labels will be automatically updated by the slider handlers)
|
||||
// always render the mini-pills, to ensure the overall order in the grid stays correct (shared between multiple filters)
|
||||
if (this._$btnMiniGt) this.__$wrpMini.append(this._$btnMiniGt);
|
||||
if (this._$btnMiniLt) this.__$wrpMini.append(this._$btnMiniLt);
|
||||
if (this._$btnMiniEq) this.__$wrpMini.append(this._$btnMiniEq);
|
||||
}
|
||||
|
||||
toDisplay (boxState, entryVal) {
|
||||
const filterState = boxState[this.header];
|
||||
if (!filterState) return true; // discount any filters which were not rendered
|
||||
|
||||
// match everything if filter is set to complete range
|
||||
if (entryVal == null) return filterState.min === this._state.min && filterState.max === this._state.max;
|
||||
|
||||
if (this._labels) {
|
||||
const slice = this._labels.slice(filterState.min, filterState.max + 1);
|
||||
|
||||
// Special case for "isAllowGreater" filters, which assumes the labels are numerical values
|
||||
if (this._isAllowGreater) {
|
||||
if (filterState.max === this._state.max && entryVal > this._labels[filterState.max]) return true;
|
||||
|
||||
const sliceMin = Math.min(...slice);
|
||||
const sliceMax = Math.max(...slice);
|
||||
|
||||
if (entryVal instanceof Array) return entryVal.some(it => it >= sliceMin && it <= sliceMax);
|
||||
return entryVal >= sliceMin && entryVal <= sliceMax;
|
||||
}
|
||||
|
||||
if (entryVal instanceof Array) return entryVal.some(it => slice.includes(it));
|
||||
return slice.includes(entryVal);
|
||||
} else {
|
||||
if (entryVal instanceof Array) {
|
||||
// If we require a full match on the range, take the lowest/highest input and test them against our min/max
|
||||
if (this._isRequireFullRangeMatch) return filterState.min <= entryVal[0] && filterState.max >= entryVal.last();
|
||||
|
||||
// Otherwise, If any of the item's values are in the range, return true
|
||||
return entryVal.some(ev => this._toDisplay_isToDisplayEntry(filterState, ev));
|
||||
}
|
||||
return this._toDisplay_isToDisplayEntry(filterState, entryVal);
|
||||
}
|
||||
}
|
||||
|
||||
_toDisplay_isToDisplayEntry (filterState, ev) {
|
||||
const isGtMin = filterState.min <= ev;
|
||||
const isLtMax = filterState.max >= ev;
|
||||
if (this._isAllowGreater) return isGtMin && (isLtMax || filterState.max === this._state.max);
|
||||
return isGtMin && isLtMax;
|
||||
}
|
||||
|
||||
addItem (item) {
|
||||
if (item == null) return;
|
||||
|
||||
if (item instanceof Array) {
|
||||
const len = item.length;
|
||||
for (let i = 0; i < len; ++i) this.addItem(item[i]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._labels) {
|
||||
if (!this._labels.some(it => it === item)) this._labels.push(item);
|
||||
|
||||
this._doUpdateLabelSearchCache();
|
||||
|
||||
// Fake an update to trigger label handling
|
||||
this._addItem_addNumber(this._labels.length - 1);
|
||||
} else {
|
||||
this._addItem_addNumber(item);
|
||||
}
|
||||
}
|
||||
|
||||
_doUpdateLabelSearchCache () {
|
||||
this._labelSearchCache = [...new Array(Math.max(0, this._max - this._min))]
|
||||
.map((_, i) => i + this._min)
|
||||
.map(val => this._getDisplayText(val, {isBeyondMax: this._isAllowGreater && val === this._state.max, isTooltip: true}))
|
||||
.join(" -- ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
_addItem_addNumber (number) {
|
||||
if (number == null || isNaN(number)) return;
|
||||
|
||||
this._seenMin = Math.min(this._seenMin, number);
|
||||
this._seenMax = Math.max(this._seenMax, number);
|
||||
|
||||
if (this._sparseValues && !this._sparseValues.includes(number)) {
|
||||
this._sparseValues.push(number);
|
||||
this._sparseValues.sort(SortUtil.ascSort);
|
||||
}
|
||||
|
||||
if (number >= this._state.min && number <= this._state.max) return; // it's already in the range
|
||||
if (this._state.min == null && this._state.max == null) this._state.min = this._state.max = number;
|
||||
else {
|
||||
const old = {...this.__state};
|
||||
|
||||
if (number < old.min) this._state.min = number;
|
||||
if (number > old.max) this._state.max = number;
|
||||
|
||||
// if the slider was previously at the full extent of its range, maintain this
|
||||
if (old.curMin === old.min) this._state.curMin = this._state.min;
|
||||
if (old.curMax === old.max) this._state.curMax = this._state.max;
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultMeta () {
|
||||
// Key order is important, as @filter tags depend on it
|
||||
const out = {
|
||||
...super.getDefaultMeta(),
|
||||
...RangeFilter._DEFAULT_META,
|
||||
};
|
||||
if (Renderer.hover.isSmallScreen()) out.isUseDropdowns = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
handleSearch (searchTerm) {
|
||||
if (this.__$wrpFilter == null) return;
|
||||
|
||||
const isVisible = this.header.toLowerCase().includes(searchTerm)
|
||||
|| (this._labelSearchCache != null
|
||||
? this._labelSearchCache.includes(searchTerm)
|
||||
: [...new Array(this._state.max - this._state.min)].map((_, n) => n + this._state.min).join(" -- ").includes(searchTerm));
|
||||
|
||||
this.__$wrpFilter.toggleClass("fltr__hidden--search", !isVisible);
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
}
|
||||
RangeFilter._DEFAULT_META = {
|
||||
isUseDropdowns: false,
|
||||
};
|
||||
295
js/filter/filter/filter-filter-searchable.js
Normal file
295
js/filter/filter/filter-filter-searchable.js
Normal file
@@ -0,0 +1,295 @@
|
||||
import {Filter} from "./filter-filter-generic.js";
|
||||
|
||||
export class SearchableFilter extends Filter {
|
||||
constructor (opts) {
|
||||
super(opts);
|
||||
|
||||
this._compSearch = BaseComponent.fromObject({
|
||||
search: "",
|
||||
searchTermParent: "",
|
||||
});
|
||||
}
|
||||
|
||||
handleSearch (searchTerm) {
|
||||
const out = super.handleSearch(searchTerm);
|
||||
|
||||
this._compSearch._state.searchTermParent = searchTerm;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
_getPill (item) {
|
||||
const btnPill = super._getPill(item);
|
||||
|
||||
const hkIsVisible = () => {
|
||||
if (this._compSearch._state.searchTermParent) return btnPill.toggleClass("fltr__hidden--inactive", false);
|
||||
|
||||
btnPill.toggleClass("fltr__hidden--inactive", this._state[item.item] === 0);
|
||||
};
|
||||
this._addHook("state", item.item, hkIsVisible);
|
||||
this._compSearch._addHookBase("searchTermParent", hkIsVisible);
|
||||
hkIsVisible();
|
||||
|
||||
return btnPill;
|
||||
}
|
||||
|
||||
_getPill_handleClick ({evt, item}) {
|
||||
if (this._compSearch._state.searchTermParent) return super._getPill_handleClick({evt, item});
|
||||
|
||||
this._state[item.item] = 0;
|
||||
}
|
||||
|
||||
_getPill_handleContextmenu ({evt, item}) {
|
||||
if (this._compSearch._state.searchTermParent) return super._getPill_handleContextmenu({evt, item});
|
||||
|
||||
evt.preventDefault();
|
||||
this._state[item.item] = 0;
|
||||
}
|
||||
|
||||
_$render_getRowBtn ({fnsCleanup, $iptSearch, item, subtype, state}) {
|
||||
const handleClick = evt => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
|
||||
// Keep the dropdown open
|
||||
$iptSearch.focus();
|
||||
|
||||
if (evt.shiftKey) {
|
||||
this._doSetPillsClear();
|
||||
}
|
||||
|
||||
if (this._state[item.item] === state) this._state[item.item] = 0;
|
||||
else this._state[item.item] = state;
|
||||
};
|
||||
|
||||
const btn = e_({
|
||||
tag: "div",
|
||||
clazz: `no-shrink clickable fltr-search__btn-activate fltr-search__btn-activate--${subtype} ve-flex-vh-center`,
|
||||
click: evt => handleClick(evt),
|
||||
contextmenu: evt => handleClick(evt),
|
||||
mousedown: evt => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
},
|
||||
});
|
||||
|
||||
const hkIsActive = () => {
|
||||
btn.innerText = this._state[item.item] === state ? "×" : "";
|
||||
};
|
||||
this._addHookBase(item.item, hkIsActive);
|
||||
hkIsActive();
|
||||
fnsCleanup.push(() => this._removeHookBase(item.item, hkIsActive));
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
$render (opts) {
|
||||
const $out = super.$render(opts);
|
||||
|
||||
const $iptSearch = ComponentUiUtil.$getIptStr(
|
||||
this._compSearch,
|
||||
"search",
|
||||
{
|
||||
html: `<input class="form-control form-control--minimal input-xs" placeholder="Search...">`,
|
||||
},
|
||||
);
|
||||
|
||||
const wrpValues = e_({
|
||||
tag: "div",
|
||||
clazz: "ve-overflow-y-auto bt-0 absolute fltr-search__wrp-values",
|
||||
});
|
||||
|
||||
const fnsCleanup = [];
|
||||
const rowMetas = [];
|
||||
|
||||
this._$render_bindSearchHandler_keydown({$iptSearch, fnsCleanup, rowMetas});
|
||||
this._$render_bindSearchHandler_focus({$iptSearch, fnsCleanup, rowMetas, wrpValues});
|
||||
this._$render_bindSearchHandler_blur({$iptSearch});
|
||||
|
||||
const $wrp = $$`<div class="fltr-search__wrp-search ve-flex-col relative mt-1 mx-2p mb-1">
|
||||
${$iptSearch}
|
||||
${wrpValues}
|
||||
</div>`.prependTo(this.__wrpPills);
|
||||
|
||||
const hkParentSearch = () => {
|
||||
$wrp.toggleVe(!this._compSearch._state.searchTermParent);
|
||||
};
|
||||
this._compSearch._addHookBase("searchTermParent", hkParentSearch);
|
||||
hkParentSearch();
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
_$render_bindSearchHandler_keydown ({$iptSearch, rowMetas}) {
|
||||
$iptSearch
|
||||
.on("keydown", evt => {
|
||||
switch (evt.key) {
|
||||
case "Escape": evt.stopPropagation(); return $iptSearch.blur();
|
||||
|
||||
case "ArrowDown": {
|
||||
evt.preventDefault();
|
||||
const visibleRowMetas = rowMetas.filter(it => it.isVisible);
|
||||
if (!visibleRowMetas.length) return;
|
||||
visibleRowMetas[0].row.focus();
|
||||
break;
|
||||
}
|
||||
|
||||
case "Enter": {
|
||||
const visibleRowMetas = rowMetas.filter(it => it.isVisible);
|
||||
if (!visibleRowMetas.length) return;
|
||||
if (evt.shiftKey) this._doSetPillsClear();
|
||||
this._state[visibleRowMetas[0].item.item] = (EventUtil.isCtrlMetaKey(evt)) ? 2 : 1;
|
||||
$iptSearch.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_$render_bindSearchHandler_focus ({$iptSearch, fnsCleanup, rowMetas, wrpValues}) {
|
||||
$iptSearch
|
||||
.on("focus", () => {
|
||||
fnsCleanup
|
||||
.splice(0, fnsCleanup.length)
|
||||
.forEach(fn => fn());
|
||||
|
||||
rowMetas.splice(0, rowMetas.length);
|
||||
|
||||
wrpValues.innerHTML = "";
|
||||
|
||||
rowMetas.push(
|
||||
...this._items
|
||||
.map(item => this._$render_bindSearchHandler_focus_getRowMeta({$iptSearch, fnsCleanup, rowMetas, wrpValues, item})),
|
||||
);
|
||||
|
||||
this._$render_bindSearchHandler_focus_addHookSearch({rowMetas, fnsCleanup});
|
||||
|
||||
wrpValues.scrollIntoView({block: "nearest", inline: "nearest"});
|
||||
});
|
||||
}
|
||||
|
||||
_$render_bindSearchHandler_focus_getRowMeta ({$iptSearch, fnsCleanup, rowMetas, wrpValues, item}) {
|
||||
const dispName = this._getDisplayText(item);
|
||||
|
||||
const eleName = e_({
|
||||
tag: "div",
|
||||
clazz: "fltr-search__disp-name ml-2",
|
||||
});
|
||||
|
||||
const btnBlue = this._$render_getRowBtn({
|
||||
fnsCleanup,
|
||||
$iptSearch,
|
||||
item,
|
||||
subtype: "yes",
|
||||
state: 1,
|
||||
});
|
||||
btnBlue.addClass("br-0");
|
||||
btnBlue.addClass("btr-0");
|
||||
btnBlue.addClass("bbr-0");
|
||||
|
||||
const btnRed = this._$render_getRowBtn({
|
||||
fnsCleanup,
|
||||
$iptSearch,
|
||||
item,
|
||||
subtype: "no",
|
||||
state: 2,
|
||||
});
|
||||
btnRed.addClass("bl-0");
|
||||
btnRed.addClass("btl-0");
|
||||
btnRed.addClass("bbl-0");
|
||||
|
||||
const row = e_({
|
||||
tag: "div",
|
||||
clazz: "py-1p px-2 ve-flex-v-center fltr-search__wrp-row",
|
||||
children: [
|
||||
btnBlue,
|
||||
btnRed,
|
||||
eleName,
|
||||
],
|
||||
attrs: {
|
||||
tabindex: "0",
|
||||
},
|
||||
keydown: evt => {
|
||||
switch (evt.key) {
|
||||
case "Escape": evt.stopPropagation(); return row.blur();
|
||||
|
||||
case "ArrowDown": {
|
||||
evt.preventDefault();
|
||||
const visibleRowMetas = rowMetas.filter(it => it.isVisible);
|
||||
if (!visibleRowMetas.length) return;
|
||||
const ixCur = visibleRowMetas.indexOf(out);
|
||||
const nxt = visibleRowMetas[ixCur + 1];
|
||||
if (nxt) nxt.row.focus();
|
||||
break;
|
||||
}
|
||||
|
||||
case "ArrowUp": {
|
||||
evt.preventDefault();
|
||||
const visibleRowMetas = rowMetas.filter(it => it.isVisible);
|
||||
if (!visibleRowMetas.length) return;
|
||||
const ixCur = visibleRowMetas.indexOf(out);
|
||||
const prev = visibleRowMetas[ixCur - 1];
|
||||
if (prev) return prev.row.focus();
|
||||
$iptSearch.focus();
|
||||
break;
|
||||
}
|
||||
|
||||
case "Enter": {
|
||||
if (evt.shiftKey) this._doSetPillsClear();
|
||||
this._state[item.item] = (EventUtil.isCtrlMetaKey(evt)) ? 2 : 1;
|
||||
row.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
wrpValues.appendChild(row);
|
||||
|
||||
const out = {
|
||||
isVisible: true,
|
||||
item,
|
||||
row,
|
||||
dispName,
|
||||
eleName,
|
||||
};
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
_$render_bindSearchHandler_focus_addHookSearch ({rowMetas, fnsCleanup}) {
|
||||
const hkSearch = () => {
|
||||
const searchTerm = this._compSearch._state.search.toLowerCase();
|
||||
|
||||
rowMetas.forEach(({item, row}) => {
|
||||
row.isVisible = item.searchText.includes(searchTerm);
|
||||
row.toggleVe(row.isVisible);
|
||||
});
|
||||
|
||||
// region Underline matching part
|
||||
if (!this._compSearch._state.search) {
|
||||
rowMetas.forEach(({dispName, eleName}) => eleName.textContent = dispName);
|
||||
return;
|
||||
}
|
||||
|
||||
const re = new RegExp(this._compSearch._state.search.qq().escapeRegexp(), "gi");
|
||||
|
||||
rowMetas.forEach(({dispName, eleName}) => {
|
||||
eleName.innerHTML = dispName
|
||||
.qq()
|
||||
.replace(re, (...m) => `<u>${m[0]}</u>`);
|
||||
});
|
||||
// endregion
|
||||
};
|
||||
this._compSearch._addHookBase("search", hkSearch);
|
||||
hkSearch();
|
||||
fnsCleanup.push(() => this._compSearch._removeHookBase("search", hkSearch));
|
||||
}
|
||||
|
||||
_$render_bindSearchHandler_blur ({$iptSearch}) {
|
||||
$iptSearch
|
||||
.on("blur", () => {
|
||||
this._compSearch._state.search = "";
|
||||
});
|
||||
}
|
||||
}
|
||||
594
js/filter/filter/filter-filter-source.js
Normal file
594
js/filter/filter/filter-filter-source.js
Normal file
@@ -0,0 +1,594 @@
|
||||
import {FilterItem} from "../filter-item.js";
|
||||
import {Filter} from "./filter-filter-generic.js";
|
||||
import {SOURCE_HEADER} from "../filter-constants.js";
|
||||
import {PageFilterBase} from "../filter-page-filter-base.js";
|
||||
|
||||
export class SourceFilterItem extends FilterItem {
|
||||
/**
|
||||
* @param options
|
||||
* @param [options.isOtherSource] If this is not the primary source of the entity.
|
||||
*/
|
||||
constructor (options) {
|
||||
super(options);
|
||||
this.isOtherSource = options.isOtherSource;
|
||||
}
|
||||
}
|
||||
|
||||
export class SourceFilter extends Filter {
|
||||
static _SORT_ITEMS_MINI (a, b) {
|
||||
a = a.item ?? a;
|
||||
b = b.item ?? b;
|
||||
const valA = BrewUtil2.hasSourceJson(a) ? 2 : (SourceUtil.isNonstandardSource(a) || PrereleaseUtil.hasSourceJson(a)) ? 1 : 0;
|
||||
const valB = BrewUtil2.hasSourceJson(b) ? 2 : (SourceUtil.isNonstandardSource(b) || PrereleaseUtil.hasSourceJson(b)) ? 1 : 0;
|
||||
return SortUtil.ascSort(valA, valB) || SortUtil.ascSortLower(Parser.sourceJsonToFull(a), Parser.sourceJsonToFull(b));
|
||||
}
|
||||
|
||||
static _getDisplayHtmlMini (item) {
|
||||
item = item.item || item;
|
||||
return `${this._getDisplayHtmlMini_icon(item)} ${Parser.sourceJsonToAbv(item)}`;
|
||||
}
|
||||
|
||||
static _getDisplayHtmlMini_icon (item) {
|
||||
const group = SourceUtil.getFilterGroup(item);
|
||||
switch (group) {
|
||||
case SourceUtil.FILTER_GROUP_STANDARD: return `<span class="glyphicon glyphicon-book"></span>`;
|
||||
case SourceUtil.FILTER_GROUP_NON_STANDARD: return `<span title="(Other)" class="glyphicon glyphicon-file"></span>`;
|
||||
case SourceUtil.FILTER_GROUP_PARTNERED: return `<span title="(Partnered)" class="glyphicon glyphicon-star-empty"></span>`;
|
||||
case SourceUtil.FILTER_GROUP_PRERELEASE: return `<span title="(Prerelease)" class="glyphicon glyphicon-wrench"></span>`;
|
||||
case SourceUtil.FILTER_GROUP_HOMEBREW: return `<span title="(Homebrew)" class="glyphicon glyphicon-glass"></span>`;
|
||||
default: throw new Error(`Unhandled source filter group "${group}"`);
|
||||
}
|
||||
}
|
||||
|
||||
constructor (opts) {
|
||||
opts = opts || {};
|
||||
|
||||
opts.header = opts.header === undefined ? SOURCE_HEADER : opts.header;
|
||||
opts.displayFn = opts.displayFn === undefined ? item => Parser.sourceJsonToFullCompactPrefix(item.item || item) : opts.displayFn;
|
||||
opts.displayFnMini = opts.displayFnMini === undefined ? SourceFilter._getDisplayHtmlMini.bind(SourceFilter) : opts.displayFnMini;
|
||||
opts.displayFnTitle = opts.displayFnTitle === undefined ? item => Parser.sourceJsonToFull(item.item || item) : opts.displayFnTitle;
|
||||
opts.itemSortFnMini = opts.itemSortFnMini === undefined ? SourceFilter._SORT_ITEMS_MINI.bind(SourceFilter) : opts.itemSortFnMini;
|
||||
opts.itemSortFn = opts.itemSortFn === undefined ? (a, b) => SortUtil.ascSortLower(Parser.sourceJsonToFull(a.item), Parser.sourceJsonToFull(b.item)) : opts.itemSortFn;
|
||||
opts.groupFn = opts.groupFn === undefined ? SourceUtil.getFilterGroup : opts.groupFn;
|
||||
opts.groupNameFn = opts.groupNameFn === undefined ? SourceUtil.getFilterGroupName : opts.groupNameFn;
|
||||
opts.selFn = opts.selFn === undefined ? PageFilterBase.defaultSourceSelFn : opts.selFn;
|
||||
|
||||
super(opts);
|
||||
|
||||
this.__tmpState = {ixAdded: 0};
|
||||
this._tmpState = this._getProxy("tmpState", this.__tmpState);
|
||||
}
|
||||
|
||||
doSetPillsClear () { return this._doSetPillsClear(); }
|
||||
|
||||
_getFilterItem (item) {
|
||||
return item instanceof FilterItem ? item : new SourceFilterItem({item});
|
||||
}
|
||||
|
||||
addItem (item) {
|
||||
const out = super.addItem(item);
|
||||
this._tmpState.ixAdded++;
|
||||
return out;
|
||||
}
|
||||
|
||||
trimState_ () {
|
||||
if (!this._items?.length) return;
|
||||
|
||||
const sourcesLoaded = new Set(this._items.map(itm => itm.item));
|
||||
const nxtState = MiscUtil.copyFast(this.__state);
|
||||
Object.keys(nxtState)
|
||||
.filter(k => !sourcesLoaded.has(k))
|
||||
.forEach(k => delete nxtState[k]);
|
||||
|
||||
this._proxyAssignSimple("state", nxtState, true);
|
||||
}
|
||||
|
||||
_getHeaderControls_addExtraStateBtns (opts, wrpStateBtnsOuter) {
|
||||
const btnSupplements = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default w-100 ${opts.isMulti ? "btn-xxs" : "btn-xs"}`,
|
||||
title: `SHIFT to add to existing selection; CTRL to include UA/etc.`,
|
||||
html: `Core/Supplements`,
|
||||
click: evt => this._doSetPinsSupplements({isIncludeUnofficial: EventUtil.isCtrlMetaKey(evt), isAdditive: evt.shiftKey}),
|
||||
});
|
||||
|
||||
const btnAdventures = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default w-100 ${opts.isMulti ? "btn-xxs" : "btn-xs"}`,
|
||||
title: `SHIFT to add to existing selection; CTRL to include UA`,
|
||||
html: `Adventures`,
|
||||
click: evt => this._doSetPinsAdventures({isIncludeUnofficial: EventUtil.isCtrlMetaKey(evt), isAdditive: evt.shiftKey}),
|
||||
});
|
||||
|
||||
const btnPartnered = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default w-100 ${opts.isMulti ? "btn-xxs" : "btn-xs"}`,
|
||||
title: `SHIFT to add to existing selection`,
|
||||
html: `Partnered`,
|
||||
click: evt => this._doSetPinsPartnered({isAdditive: evt.shiftKey}),
|
||||
});
|
||||
|
||||
const btnHomebrew = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default w-100 ${opts.isMulti ? "btn-xxs" : "btn-xs"}`,
|
||||
title: `SHIFT to add to existing selection`,
|
||||
html: `Homebrew`,
|
||||
click: evt => this._doSetPinsHomebrew({isAdditive: evt.shiftKey}),
|
||||
});
|
||||
|
||||
const hkIsButtonsActive = () => {
|
||||
const hasPartnered = Object.keys(this.__state).some(src => SourceUtil.getFilterGroup(src) === SourceUtil.FILTER_GROUP_PARTNERED);
|
||||
btnPartnered.toggleClass("ve-hidden", !hasPartnered);
|
||||
|
||||
const hasBrew = Object.keys(this.__state).some(src => SourceUtil.getFilterGroup(src) === SourceUtil.FILTER_GROUP_HOMEBREW);
|
||||
btnHomebrew.toggleClass("ve-hidden", !hasBrew);
|
||||
};
|
||||
this._addHook("tmpState", "ixAdded", hkIsButtonsActive);
|
||||
hkIsButtonsActive();
|
||||
|
||||
const actionSelectDisplayMode = new ContextUtil.ActionSelect({
|
||||
values: Object.keys(SourceFilter._PILL_DISPLAY_MODE_LABELS).map(Number),
|
||||
fnGetDisplayValue: val => SourceFilter._PILL_DISPLAY_MODE_LABELS[val] || SourceFilter._PILL_DISPLAY_MODE_LABELS[0],
|
||||
fnOnChange: val => this._meta.pillDisplayMode = val,
|
||||
});
|
||||
this._addHook("meta", "pillDisplayMode", () => {
|
||||
actionSelectDisplayMode.setValue(this._meta.pillDisplayMode);
|
||||
})();
|
||||
|
||||
const menu = ContextUtil.getMenu([
|
||||
new ContextUtil.Action(
|
||||
"Select All Standard Sources",
|
||||
() => this._doSetPinsStandard(),
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Select All Partnered Sources",
|
||||
() => this._doSetPinsPartnered(),
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Select All Non-Standard Sources",
|
||||
() => this._doSetPinsNonStandard(),
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Select All Prerelease Sources",
|
||||
() => this._doSetPinsPrerelease(),
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Select All Homebrew Sources",
|
||||
() => this._doSetPinsHomebrew(),
|
||||
),
|
||||
null,
|
||||
new ContextUtil.Action(
|
||||
`Select "Vanilla" Sources`,
|
||||
() => this._doSetPinsVanilla(),
|
||||
{title: `Select a baseline set of sources suitable for any campaign.`},
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Select All Non-UA Sources",
|
||||
() => this._doSetPinsNonUa(),
|
||||
),
|
||||
null,
|
||||
new ContextUtil.Action(
|
||||
"Select SRD Sources",
|
||||
() => this._doSetPinsSrd(),
|
||||
{title: `Select System Reference Document Sources.`},
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Select Basic Rules Sources",
|
||||
() => this._doSetPinsBasicRules(),
|
||||
),
|
||||
null,
|
||||
new ContextUtil.Action(
|
||||
"Invert Selection",
|
||||
() => this._doInvertPins(),
|
||||
),
|
||||
null,
|
||||
actionSelectDisplayMode,
|
||||
]);
|
||||
const btnBurger = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"}`,
|
||||
html: `<span class="glyphicon glyphicon-option-vertical"></span>`,
|
||||
click: evt => ContextUtil.pOpenMenu(evt, menu),
|
||||
title: "Other Options",
|
||||
});
|
||||
|
||||
const btnOnlyPrimary = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default w-100 ${opts.isMulti ? "btn-xxs" : "btn-xs"}`,
|
||||
html: `Include References`,
|
||||
title: `Consider entities as belonging to every source they appear in (i.e. reprints) as well as their primary source`,
|
||||
click: () => this._meta.isIncludeOtherSources = !this._meta.isIncludeOtherSources,
|
||||
});
|
||||
const hkIsIncludeOtherSources = () => {
|
||||
btnOnlyPrimary.toggleClass("active", !!this._meta.isIncludeOtherSources);
|
||||
};
|
||||
hkIsIncludeOtherSources();
|
||||
this._addHook("meta", "isIncludeOtherSources", hkIsIncludeOtherSources);
|
||||
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `btn-group mr-2 w-100 ve-flex-v-center mobile__m-1 mobile__mb-2`,
|
||||
children: [
|
||||
btnSupplements,
|
||||
btnAdventures,
|
||||
btnPartnered,
|
||||
btnHomebrew,
|
||||
btnBurger,
|
||||
btnOnlyPrimary,
|
||||
],
|
||||
}).prependTo(wrpStateBtnsOuter);
|
||||
}
|
||||
|
||||
_doSetPinsStandard () {
|
||||
Object.keys(this._state).forEach(k => this._state[k] = SourceUtil.getFilterGroup(k) === SourceUtil.FILTER_GROUP_STANDARD ? 1 : 0);
|
||||
}
|
||||
|
||||
_doSetPinsPartnered ({isAdditive = false} = {}) {
|
||||
this._proxyAssignSimple(
|
||||
"state",
|
||||
Object.keys(this._state)
|
||||
.mergeMap(k => ({[k]: SourceUtil.getFilterGroup(k) === SourceUtil.FILTER_GROUP_PARTNERED ? 1 : isAdditive ? this._state[k] : 0})),
|
||||
);
|
||||
}
|
||||
|
||||
_doSetPinsNonStandard () {
|
||||
Object.keys(this._state).forEach(k => this._state[k] = SourceUtil.getFilterGroup(k) === SourceUtil.FILTER_GROUP_NON_STANDARD ? 1 : 0);
|
||||
}
|
||||
|
||||
_doSetPinsPrerelease () {
|
||||
Object.keys(this._state).forEach(k => this._state[k] = SourceUtil.getFilterGroup(k) === SourceUtil.FILTER_GROUP_PRERELEASE ? 1 : 0);
|
||||
}
|
||||
|
||||
_doSetPinsSupplements ({isIncludeUnofficial = false, isAdditive = false} = {}) {
|
||||
this._proxyAssignSimple(
|
||||
"state",
|
||||
Object.keys(this._state)
|
||||
.mergeMap(k => ({[k]: SourceUtil.isCoreOrSupplement(k) && (isIncludeUnofficial || !SourceUtil.isNonstandardSource(k)) ? 1 : isAdditive ? this._state[k] : 0})),
|
||||
);
|
||||
}
|
||||
|
||||
_doSetPinsAdventures ({isIncludeUnofficial = false, isAdditive = false} = {}) {
|
||||
this._proxyAssignSimple(
|
||||
"state",
|
||||
Object.keys(this._state)
|
||||
.mergeMap(k => ({[k]: SourceUtil.isAdventure(k) && (isIncludeUnofficial || !SourceUtil.isNonstandardSource(k)) ? 1 : isAdditive ? this._state[k] : 0})),
|
||||
);
|
||||
}
|
||||
|
||||
_doSetPinsHomebrew ({isAdditive = false} = {}) {
|
||||
this._proxyAssignSimple(
|
||||
"state",
|
||||
Object.keys(this._state)
|
||||
.mergeMap(k => ({[k]: SourceUtil.getFilterGroup(k) === SourceUtil.FILTER_GROUP_HOMEBREW ? 1 : isAdditive ? this._state[k] : 0})),
|
||||
);
|
||||
}
|
||||
|
||||
_doSetPinsVanilla () {
|
||||
Object.keys(this._state).forEach(k => this._state[k] = Parser.SOURCES_VANILLA.has(k) ? 1 : 0);
|
||||
}
|
||||
|
||||
_doSetPinsNonUa () {
|
||||
Object.keys(this._state).forEach(k => this._state[k] = !SourceUtil.isPrereleaseSource(k) ? 1 : 0);
|
||||
}
|
||||
|
||||
_doSetPinsSrd () {
|
||||
SourceFilter._SRD_SOURCES = SourceFilter._SRD_SOURCES || new Set([Parser.SRC_PHB, Parser.SRC_MM, Parser.SRC_DMG]);
|
||||
|
||||
Object.keys(this._state).forEach(k => this._state[k] = SourceFilter._SRD_SOURCES.has(k) ? 1 : 0);
|
||||
|
||||
const srdFilter = this._filterBox.filters.find(it => it.isSrdFilter);
|
||||
if (srdFilter) srdFilter.setValue("SRD", 1);
|
||||
|
||||
const basicRulesFilter = this._filterBox.filters.find(it => it.isBasicRulesFilter);
|
||||
if (basicRulesFilter) basicRulesFilter.setValue("Basic Rules", 0);
|
||||
|
||||
// also disable "Reprinted" otherwise some Deities are missing
|
||||
const reprintedFilter = this._filterBox.filters.find(it => it.isReprintedFilter);
|
||||
if (reprintedFilter) reprintedFilter.setValue("Reprinted", 0);
|
||||
}
|
||||
|
||||
_doSetPinsBasicRules () {
|
||||
SourceFilter._BASIC_RULES_SOURCES = SourceFilter._BASIC_RULES_SOURCES || new Set([Parser.SRC_PHB, Parser.SRC_MM, Parser.SRC_DMG]);
|
||||
|
||||
Object.keys(this._state).forEach(k => this._state[k] = SourceFilter._BASIC_RULES_SOURCES.has(k) ? 1 : 0);
|
||||
|
||||
const basicRulesFilter = this._filterBox.filters.find(it => it.isBasicRulesFilter);
|
||||
if (basicRulesFilter) basicRulesFilter.setValue("Basic Rules", 1);
|
||||
|
||||
const srdFilter = this._filterBox.filters.find(it => it.isSrdFilter);
|
||||
if (srdFilter) srdFilter.setValue("SRD", 0);
|
||||
|
||||
// also disable "Reprinted" otherwise some Deities are missing
|
||||
const reprintedFilter = this._filterBox.filters.find(it => it.isReprintedFilter);
|
||||
if (reprintedFilter) reprintedFilter.setValue("Reprinted", 0);
|
||||
}
|
||||
|
||||
static getCompleteFilterSources (ent) {
|
||||
if (!ent.otherSources) return ent.source;
|
||||
|
||||
const otherSourcesFilt = ent.otherSources
|
||||
// Avoid `otherSources` from e.g. homebrews which are not loaded, and so lack their metadata
|
||||
.filter(src => !ExcludeUtil.isExcluded("*", "*", src.source, {isNoCount: true}) && SourceUtil.isKnownSource(src.source));
|
||||
if (!otherSourcesFilt.length) return ent.source;
|
||||
|
||||
return [ent.source].concat(otherSourcesFilt.map(src => new SourceFilterItem({item: src.source, isIgnoreRed: true, isOtherSource: true})));
|
||||
}
|
||||
|
||||
_doRenderPills_doRenderWrpGroup_getDividerHeaders (group) {
|
||||
switch (group) {
|
||||
case SourceUtil.FILTER_GROUP_PRERELEASE: return this._doRenderPills_doRenderWrpGroup_getDividerHeaders_groupPrerelease(group);
|
||||
case SourceUtil.FILTER_GROUP_HOMEBREW: return this._doRenderPills_doRenderWrpGroup_getDividerHeaders_groupBrew(group);
|
||||
default: return super._doRenderPills_doRenderWrpGroup_getDividerHeaders(group);
|
||||
}
|
||||
}
|
||||
|
||||
_doRenderPills_doRenderWrpGroup_getDividerHeaders_groupPrerelease (group) {
|
||||
let dates = [];
|
||||
const comp = BaseComponent.fromObject({
|
||||
min: 0,
|
||||
max: 0,
|
||||
curMin: 0,
|
||||
curMax: 0,
|
||||
});
|
||||
|
||||
const wrpSlider = new ComponentUiUtil.RangeSlider({
|
||||
comp,
|
||||
propMin: "min",
|
||||
propMax: "max",
|
||||
propCurMin: "curMin",
|
||||
propCurMax: "curMax",
|
||||
fnDisplay: val => dates[val]?.str,
|
||||
}).get();
|
||||
|
||||
const wrpWrpSlider = e_({
|
||||
tag: "div",
|
||||
clazz: `"w-100 ve-flex pt-2 pb-5 mb-2 mt-1 fltr-src__wrp-slider`,
|
||||
children: [
|
||||
wrpSlider,
|
||||
],
|
||||
}).hideVe();
|
||||
|
||||
const btnCancel = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xs btn-default px-1`,
|
||||
html: "Cancel",
|
||||
click: () => {
|
||||
grpBtnsInactive.showVe();
|
||||
wrpWrpSlider.hideVe();
|
||||
grpBtnsActive.hideVe();
|
||||
},
|
||||
});
|
||||
|
||||
const btnConfirm = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xs btn-default px-1`,
|
||||
html: "Confirm",
|
||||
click: () => {
|
||||
grpBtnsInactive.showVe();
|
||||
wrpWrpSlider.hideVe();
|
||||
grpBtnsActive.hideVe();
|
||||
|
||||
const min = comp._state.curMin;
|
||||
const max = comp._state.curMax;
|
||||
|
||||
const allowedDateSet = new Set(dates.slice(min, max + 1).map(it => it.str));
|
||||
const nxtState = {};
|
||||
Object.keys(this._state)
|
||||
.filter(k => SourceUtil.isNonstandardSource(k))
|
||||
.forEach(k => {
|
||||
const sourceDate = Parser.sourceJsonToDate(k);
|
||||
nxtState[k] = allowedDateSet.has(sourceDate) ? 1 : 0;
|
||||
});
|
||||
this._proxyAssign("state", "_state", "__state", nxtState);
|
||||
},
|
||||
});
|
||||
|
||||
const btnShowSlider = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xxs btn-default px-1`,
|
||||
html: "Select by Date",
|
||||
click: () => {
|
||||
grpBtnsInactive.hideVe();
|
||||
wrpWrpSlider.showVe();
|
||||
grpBtnsActive.showVe();
|
||||
|
||||
dates = Object.keys(this._state)
|
||||
.filter(it => SourceUtil.isPrereleaseSource(it))
|
||||
.map(it => Parser.sourceJsonToDate(it))
|
||||
.filter(Boolean)
|
||||
.unique()
|
||||
.map(it => ({str: it, date: new Date(it)}))
|
||||
.sort((a, b) => SortUtil.ascSortDate(a.date, b.date))
|
||||
.reverse();
|
||||
|
||||
comp._proxyAssignSimple(
|
||||
"state",
|
||||
{
|
||||
min: 0,
|
||||
max: dates.length - 1,
|
||||
curMin: 0,
|
||||
curMax: dates.length - 1,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const btnClear = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xxs btn-default px-1`,
|
||||
html: "Clear",
|
||||
click: () => {
|
||||
const nxtState = {};
|
||||
Object.keys(this._state)
|
||||
.filter(k => SourceUtil.isPrereleaseSource(k))
|
||||
.forEach(k => nxtState[k] = 0);
|
||||
this._proxyAssign("state", "_state", "__state", nxtState);
|
||||
},
|
||||
});
|
||||
|
||||
const grpBtnsActive = e_({
|
||||
tag: "div",
|
||||
clazz: `ve-flex-v-center btn-group`,
|
||||
children: [
|
||||
btnCancel,
|
||||
btnConfirm,
|
||||
],
|
||||
}).hideVe();
|
||||
|
||||
const grpBtnsInactive = e_({
|
||||
tag: "div",
|
||||
clazz: `ve-flex-v-center btn-group`,
|
||||
children: [
|
||||
btnClear,
|
||||
btnShowSlider,
|
||||
],
|
||||
});
|
||||
|
||||
const elesDividerHeaders = super._doRenderPills_doRenderWrpGroup_getDividerHeaders(group);
|
||||
if (!elesDividerHeaders.length) elesDividerHeaders.push(e_({clazz: "div"}));
|
||||
if (elesDividerHeaders.length > 1) throw new Error("Unimplemented!");
|
||||
|
||||
return [
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `split-v-center w-100`,
|
||||
children: [
|
||||
...elesDividerHeaders,
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `mb-1 ve-flex-h-right`,
|
||||
children: [
|
||||
grpBtnsActive,
|
||||
grpBtnsInactive,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
wrpWrpSlider,
|
||||
];
|
||||
}
|
||||
|
||||
_doRenderPills_doRenderWrpGroup_getDividerHeaders_groupBrew (group) {
|
||||
const btnClear = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xxs btn-default px-1`,
|
||||
html: "Clear",
|
||||
click: () => {
|
||||
const nxtState = {};
|
||||
Object.keys(this._state)
|
||||
.filter(k => BrewUtil2.hasSourceJson(k))
|
||||
.forEach(k => nxtState[k] = 0);
|
||||
this._proxyAssign("state", "_state", "__state", nxtState);
|
||||
},
|
||||
});
|
||||
|
||||
const elesDividerHeaders = super._doRenderPills_doRenderWrpGroup_getDividerHeaders(group);
|
||||
if (!elesDividerHeaders.length) elesDividerHeaders.push(e_({clazz: "div"}));
|
||||
if (elesDividerHeaders.length > 1) throw new Error("Unimplemented!");
|
||||
|
||||
return [
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `split-v-center w-100`,
|
||||
children: [
|
||||
...elesDividerHeaders,
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `mb-1 ve-flex-h-right`,
|
||||
children: [
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `ve-flex-v-center btn-group`,
|
||||
children: [
|
||||
btnClear,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
_toDisplay_getMappedEntryVal (entryVal) {
|
||||
entryVal = super._toDisplay_getMappedEntryVal(entryVal);
|
||||
if (!this._meta.isIncludeOtherSources) entryVal = entryVal.filter(it => !it.isOtherSource);
|
||||
return entryVal;
|
||||
}
|
||||
|
||||
_getPill (item) {
|
||||
const displayText = this._getDisplayText(item);
|
||||
const displayTextMini = this._getDisplayTextMini(item);
|
||||
|
||||
const dispName = e_({
|
||||
tag: "span",
|
||||
html: displayText,
|
||||
});
|
||||
|
||||
const spc = e_({
|
||||
tag: "span",
|
||||
clazz: "px-2 fltr-src__spc-pill",
|
||||
text: "|",
|
||||
});
|
||||
|
||||
const dispAbbreviation = e_({
|
||||
tag: "span",
|
||||
html: displayTextMini,
|
||||
});
|
||||
|
||||
const btnPill = e_({
|
||||
tag: "div",
|
||||
clazz: "fltr__pill",
|
||||
children: [
|
||||
dispAbbreviation,
|
||||
spc,
|
||||
dispName,
|
||||
],
|
||||
click: evt => this._getPill_handleClick({evt, item}),
|
||||
contextmenu: evt => this._getPill_handleContextmenu({evt, item}),
|
||||
});
|
||||
|
||||
this._getPill_bindHookState({btnPill, item});
|
||||
|
||||
this._addHook("meta", "pillDisplayMode", () => {
|
||||
dispAbbreviation.toggleVe(this._meta.pillDisplayMode !== 0);
|
||||
spc.toggleVe(this._meta.pillDisplayMode === 2);
|
||||
dispName.toggleVe(this._meta.pillDisplayMode !== 1);
|
||||
})();
|
||||
|
||||
item.searchText = `${Parser.sourceJsonToAbv(item.item || item).toLowerCase()} -- ${displayText.toLowerCase()}`;
|
||||
|
||||
return btnPill;
|
||||
}
|
||||
|
||||
getSources () {
|
||||
const out = {
|
||||
all: [],
|
||||
};
|
||||
this._items.forEach(it => {
|
||||
out.all.push(it.item);
|
||||
const group = this._groupFn(it);
|
||||
(out[group] ||= []).push(it.item);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
getDefaultMeta () {
|
||||
// Key order is important, as @filter tags depend on it
|
||||
return {
|
||||
...super.getDefaultMeta(),
|
||||
...SourceFilter._DEFAULT_META,
|
||||
};
|
||||
}
|
||||
}
|
||||
SourceFilter._DEFAULT_META = {
|
||||
isIncludeOtherSources: false,
|
||||
pillDisplayMode: 0,
|
||||
};
|
||||
SourceFilter._PILL_DISPLAY_MODE_LABELS = {
|
||||
"0": "As Names",
|
||||
"1": "As Abbreviations",
|
||||
"2": "As Names Plus Abbreviations",
|
||||
};
|
||||
SourceFilter._SRD_SOURCES = null;
|
||||
SourceFilter._BASIC_RULES_SOURCES = null;
|
||||
102
js/hist.js
102
js/hist.js
@@ -1,44 +1,56 @@
|
||||
"use strict";
|
||||
|
||||
class Hist {
|
||||
static lastLoadedLink = null;
|
||||
static _lastUnknownLink = null;
|
||||
static lastLoadedId = null;
|
||||
static initialLoad = true;
|
||||
static isHistorySuppressed = false;
|
||||
static _pLoadingUnknownHash = null;
|
||||
|
||||
static _pHandleUnknownHash = null;
|
||||
static _pLoadHash = null;
|
||||
static _pLoadSubHash = null;
|
||||
|
||||
static setFnHandleUnknownHash (fn) { this._pHandleUnknownHash = fn; }
|
||||
static setFnLoadHash (fn) { this._pLoadHash = fn; }
|
||||
static setFnLoadSubhash (fn) { this._pLoadSubHash = fn; }
|
||||
|
||||
static hashChange ({isForceLoad, isBlankFilterLoad = false} = {}) {
|
||||
if (Hist.isHistorySuppressed) {
|
||||
Hist.setSuppressHistory(false);
|
||||
return;
|
||||
}
|
||||
if (this.isHistorySuppressed) return this.setSuppressHistory(false);
|
||||
|
||||
const [link, ...sub] = Hist.getHashParts();
|
||||
const [link, ...sub] = this.getHashParts();
|
||||
|
||||
if (link !== Hist.lastLoadedLink || sub.length === 0 || isForceLoad) {
|
||||
Hist.lastLoadedLink = link;
|
||||
if (link !== this.lastLoadedLink || sub.length === 0 || isForceLoad) {
|
||||
this.lastLoadedLink = link;
|
||||
if (link === HASH_BLANK) {
|
||||
isBlankFilterLoad = true;
|
||||
} else {
|
||||
const listItem = Hist.getActiveListItem(link);
|
||||
const listItem = this.getActiveListItem(link);
|
||||
|
||||
if (listItem == null) {
|
||||
if (typeof pHandleUnknownHash === "function" && window.location.hash.length && Hist._lastUnknownLink !== link) {
|
||||
Hist._lastUnknownLink = link;
|
||||
pHandleUnknownHash(link, sub);
|
||||
if (typeof this._pHandleUnknownHash === "function" && window.location.hash.length && this._lastUnknownLink !== link) {
|
||||
this._lastUnknownLink = link;
|
||||
this._pLoadingUnknownHash = this._pHandleUnknownHash(link, sub);
|
||||
return;
|
||||
} else {
|
||||
Hist._freshLoad();
|
||||
this._freshLoad();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const toLoad = listItem.ix;
|
||||
if (toLoad === undefined) Hist._freshLoad();
|
||||
if (toLoad === undefined) this._freshLoad();
|
||||
else {
|
||||
Hist.lastLoadedId = listItem.ix;
|
||||
loadHash(listItem.ix);
|
||||
this.lastLoadedId = listItem.ix;
|
||||
this._pLoadHash(listItem.ix);
|
||||
document.title = `${listItem.name ? `${listItem.name} - ` : ""}5etools`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof loadSubHash === "function" && (sub.length > 0 || isForceLoad)) loadSubHash(sub);
|
||||
if (isBlankFilterLoad) Hist._freshLoad();
|
||||
if (typeof this._pLoadSubHash === "function" && (sub.length > 0 || isForceLoad)) this._pLoadSubHash(sub);
|
||||
if (isBlankFilterLoad) this._freshLoad();
|
||||
}
|
||||
|
||||
static init (initialLoadComplete) {
|
||||
@@ -93,15 +105,48 @@ class Hist {
|
||||
}
|
||||
|
||||
static _freshLoad () {
|
||||
// defer this, in case the list needs to filter first
|
||||
setTimeout(() => {
|
||||
const goTo = $("#listcontainer").find(".list a").attr("href");
|
||||
if (goTo) {
|
||||
const parts = location.hash.split(HASH_PART_SEP);
|
||||
const fullHash = `${goTo}${parts.length > 1 ? `${HASH_PART_SEP}${parts.slice(1).join(HASH_PART_SEP)}` : ""}`;
|
||||
location.replace(fullHash);
|
||||
}
|
||||
}, 1);
|
||||
// Wait for any unknown hash handling to resolve. This avoids the case where an async homebrew load
|
||||
// fails to reload the page, as the hash was over-eagerly reset while the load took place.
|
||||
(this._pLoadingUnknownHash || Promise.resolve())
|
||||
.then(() => {
|
||||
// defer this, in case the list needs to filter first
|
||||
setTimeout(() => {
|
||||
const goTo = $("#listcontainer").find(".list a").attr("href");
|
||||
if (goTo) {
|
||||
const parts = location.hash.split(HASH_PART_SEP);
|
||||
const fullHash = `${goTo}${parts.length > 1 ? `${HASH_PART_SEP}${parts.slice(1).join(HASH_PART_SEP)}` : ""}`;
|
||||
location.replace(fullHash);
|
||||
}
|
||||
}, 1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Avoid "stuck brew" loops which can occur via:
|
||||
* - user is viewing a homebrew statblock; hash has homebrew source
|
||||
* - user deletes that homebrew; page reloads
|
||||
* - "unknown hash" flow triggers for that hash; deleted homebrew is re-loaded by source; page reloads
|
||||
* - user is presented with the same statblock, from the source they just tried to delete.
|
||||
*/
|
||||
static doPreLocationReload () {
|
||||
const [link] = this.getHashParts();
|
||||
if (link === HASH_BLANK) return;
|
||||
|
||||
const {source} = UrlUtil.autoDecodeHash(link);
|
||||
if (!source) return;
|
||||
|
||||
// If the hash has a site source, do nothing; site data is always present...
|
||||
if (Parser.hasSourceJson(source)) return;
|
||||
|
||||
// ...if the hash has a prerelease/homebrew source, and that source exists, do nothing...
|
||||
if (
|
||||
[PrereleaseUtil, BrewUtil2]
|
||||
.some(brewUtil => brewUtil.hasSourceJson(source))
|
||||
) return;
|
||||
|
||||
// ...otherwise, the hash must be from a prerelease/homebrew source which does not exist (i.e. the user just deleted it); wipe the hash.
|
||||
// If the source does not exist for some other reason, this is still fine, as we assume that the hash is un-loadable anyway.
|
||||
window.location.hash = "";
|
||||
}
|
||||
|
||||
static cleanSetHash (toSet) {
|
||||
@@ -141,11 +186,6 @@ class Hist {
|
||||
);
|
||||
}
|
||||
}
|
||||
Hist.lastLoadedLink = null;
|
||||
Hist._lastUnknownLink = null;
|
||||
Hist.lastLoadedId = null;
|
||||
Hist.initialLoad = true;
|
||||
Hist.isHistorySuppressed = false;
|
||||
|
||||
Hist.util = class {
|
||||
static getCleanHash (hash) {
|
||||
|
||||
@@ -213,8 +213,6 @@ class ItemsPage extends ListPage {
|
||||
_entries: {name: "Text", transform: (it) => Renderer.item.getRenderedEntries(it, {isCompact: true}), flex: 3},
|
||||
},
|
||||
},
|
||||
|
||||
isMarkdownPopout: true,
|
||||
propEntryData: "item",
|
||||
|
||||
listSyntax: new ListSyntaxItems({fnGetDataList: () => this._dataList, pFnGetFluff}),
|
||||
@@ -264,7 +262,7 @@ class ItemsPage extends ListPage {
|
||||
e_({tag: "span", clazz: `ve-col-1-5 ve-text-center`, text: Parser.itemWeightToFull(item, true) || "\u2014"}),
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: `ve-col-1 ve-text-center ${Parser.sourceJsonToColor(item.source)} pr-0`,
|
||||
clazz: `ve-col-1 ve-text-center ${Parser.sourceJsonToSourceClassname(item.source)} pr-0`,
|
||||
style: Parser.sourceJsonToStylePart(item.source),
|
||||
title: `${Parser.sourceJsonToFull(item.source)}${Renderer.utils.getSourceSubText(item)}`,
|
||||
text: source,
|
||||
@@ -310,7 +308,7 @@ class ItemsPage extends ListPage {
|
||||
e_({tag: "span", clazz: `ve-col-1-4 ve-text-center`, text: (item.rarity || "").toTitleCase()}),
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: `ve-col-1 ve-text-center ${Parser.sourceJsonToColor(item.source)} pr-0`,
|
||||
clazz: `ve-col-1 ve-text-center ${Parser.sourceJsonToSourceClassname(item.source)} pr-0`,
|
||||
style: Parser.sourceJsonToStylePart(item.source),
|
||||
title: `${Parser.sourceJsonToFull(item.source)}${Renderer.utils.getSourceSubText(item)}`,
|
||||
text: source,
|
||||
|
||||
@@ -65,8 +65,6 @@ class LanguagesPage extends ListPage {
|
||||
pageFilter,
|
||||
|
||||
dataProps: ["language"],
|
||||
|
||||
isMarkdownPopout: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,7 +81,7 @@ class LanguagesPage extends ListPage {
|
||||
<span class="ve-col-6 bold pl-0">${it.name}</span>
|
||||
<span class="ve-col-2 ve-text-center">${(it.type || "\u2014").uppercaseFirst()}</span>
|
||||
<span class="ve-col-2 ve-text-center">${(it.script || "\u2014").toTitleCase()}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
</a>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
|
||||
@@ -295,6 +295,10 @@ class SublistManager {
|
||||
"Download JSON Data",
|
||||
() => this._pHandleJsonDownload(),
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Download Markdown Data",
|
||||
() => this._pHandleMarkdownDownload(),
|
||||
),
|
||||
null,
|
||||
new ContextUtil.Action(
|
||||
"Copy as Markdown Table",
|
||||
@@ -463,6 +467,27 @@ class SublistManager {
|
||||
DataUtil.userDownload(`${this._getDownloadName()}-data`, entities);
|
||||
}
|
||||
|
||||
async _pHandleMarkdownDownload () {
|
||||
const entities = await this.getPinnedEntities();
|
||||
|
||||
const markdown = entities
|
||||
.map(ent => {
|
||||
return RendererMarkdown.get().render({
|
||||
entries: [
|
||||
{
|
||||
type: "statblockInline",
|
||||
dataType: ent.__prop,
|
||||
data: ent,
|
||||
},
|
||||
],
|
||||
})
|
||||
.trim();
|
||||
})
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
DataUtil.userDownloadText(`${this._getDownloadName()}.md`, markdown);
|
||||
}
|
||||
|
||||
async _pHandleCopyAsMarkdownTable () {
|
||||
await MiscUtil.pCopyTextToClipboard(
|
||||
RendererMarkdown.get()
|
||||
@@ -978,7 +1003,7 @@ class ListPage {
|
||||
* @param [opts.prereleaseDataSource] Function to fetch prerelease data.
|
||||
* @param [opts.brewDataSource] Function to fetch brew data.
|
||||
* @param [opts.pFnGetFluff] Function to fetch fluff for a given entity.
|
||||
* @param [opts.pageFilter] PageFilter implementation for this page. (Either `filters` and `filterSource` or
|
||||
* @param [opts.pageFilter] PageFilterBase implementation for this page. (Either `filters` and `filterSource` or
|
||||
* `pageFilter` must be specified.)
|
||||
* @param opts.listOptions Other list options.
|
||||
* @param opts.dataProps JSON data propert(y/ies).
|
||||
@@ -994,8 +1019,6 @@ class ListPage {
|
||||
* @param [opts.hasAudio] True if the entities have pronunciation audio.
|
||||
* @param [opts.isPreviewable] True if the entities can be previewed in-line as part of the list.
|
||||
* @param [opts.isLoadDataAfterFilterInit] If the order of data loading and filter-state loading should be flipped.
|
||||
* @param [opts.isBindHashHandlerUnknown] If the "unknown hash" handler function should be bound.
|
||||
* @param [opts.isMarkdownPopout] If the sublist Popout button supports Markdown on CTRL.
|
||||
* @param [opts.propEntryData]
|
||||
* @param [opts.listSyntax]
|
||||
* @param [opts.compSettings]
|
||||
@@ -1012,9 +1035,7 @@ class ListPage {
|
||||
this._tableViewOptions = opts.tableViewOptions;
|
||||
this._hasAudio = opts.hasAudio;
|
||||
this._isPreviewable = opts.isPreviewable;
|
||||
this._isMarkdownPopout = !!opts.isMarkdownPopout;
|
||||
this._isLoadDataAfterFilterInit = !!opts.isLoadDataAfterFilterInit;
|
||||
this._isBindHashHandlerUnknown = !!opts.isBindHashHandlerUnknown;
|
||||
this._propEntryData = opts.propEntryData;
|
||||
this._listSyntax = opts.listSyntax || new ListUiUtil.ListSyntax({fnGetDataList: () => this._dataList, pFnGetFluff: opts.pFnGetFluff});
|
||||
this._compSettings = opts.compSettings ? opts.compSettings : null;
|
||||
@@ -1076,7 +1097,7 @@ class ListPage {
|
||||
|
||||
this._pOnLoad_initVisibleItemsDisplay();
|
||||
|
||||
if (this._filterBox) this._filterBox.on(FilterBox.EVNT_VALCHANGE, this.handleFilterChange.bind(this));
|
||||
if (this._filterBox) this._filterBox.on(FILTER_BOX_EVNT_VALCHANGE, this.handleFilterChange.bind(this));
|
||||
|
||||
if (this._sublistManager) {
|
||||
if (this._sublistManager.isSublistItemsCountable) {
|
||||
@@ -1103,10 +1124,9 @@ class ListPage {
|
||||
this._pOnLoad_bookView();
|
||||
this._pOnLoad_tableView();
|
||||
|
||||
// bind hash-change functions for hist.js to use
|
||||
window.loadHash = this.pDoLoadHash.bind(this);
|
||||
window.loadSubHash = this.pDoLoadSubHash.bind(this);
|
||||
if (this._isBindHashHandlerUnknown) window.pHandleUnknownHash = this.pHandleUnknownHash.bind(this);
|
||||
Hist.setFnLoadHash(this.pDoLoadHash.bind(this));
|
||||
Hist.setFnLoadSubhash(this.pDoLoadSubHash.bind(this));
|
||||
Hist.setFnHandleUnknownHash(this.pHandleUnknownHash.bind(this));
|
||||
|
||||
this.primaryLists.forEach(list => list.init());
|
||||
if (this._sublistManager) this._sublistManager.init();
|
||||
@@ -1170,7 +1190,11 @@ class ListPage {
|
||||
|
||||
_pOnLoad_bindMiscButtons () {
|
||||
const $btnReset = $("#reset");
|
||||
ManageBrewUi.bindBtnOpen($(`#manage-brew`));
|
||||
// TODO(MODULES) refactor
|
||||
import("./utils-brew/utils-brew-ui-manage.js")
|
||||
.then(({ManageBrewUi}) => {
|
||||
ManageBrewUi.bindBtngroupManager(e_({id: "btngroup-manager"}));
|
||||
});
|
||||
this._renderListFeelingLucky({$btnReset});
|
||||
this._renderListShowHide({
|
||||
$wrpList: $(`#listcontainer`),
|
||||
@@ -1406,13 +1430,13 @@ class ListPage {
|
||||
_bindPopoutButton () {
|
||||
this._getOrTabRightButton(`popout`, `new-window`)
|
||||
.off("click")
|
||||
.title(`Popout Window (SHIFT for Source Data${this._isMarkdownPopout ? `; CTRL for Markdown Render` : ""})`)
|
||||
.title(`Popout Window (SHIFT for Source Data; CTRL for Markdown Render)`)
|
||||
.on(
|
||||
"click",
|
||||
(evt) => {
|
||||
if (Hist.lastLoadedId === null) return;
|
||||
|
||||
if (this._isMarkdownPopout && (EventUtil.isCtrlMetaKey(evt))) return this._bindPopoutButton_doShowMarkdown(evt);
|
||||
if (EventUtil.isCtrlMetaKey(evt)) return this._bindPopoutButton_doShowMarkdown(evt);
|
||||
return this._bindPopoutButton_doShowStatblock(evt);
|
||||
},
|
||||
);
|
||||
@@ -1861,7 +1885,41 @@ class ListPage {
|
||||
}
|
||||
|
||||
getListItem () { throw new Error(`Unimplemented!`); }
|
||||
pHandleUnknownHash () { throw new Error(`Unimplemented!`); }
|
||||
|
||||
async pHandleUnknownHash (link, sub) {
|
||||
const locStart = window.location.hash;
|
||||
|
||||
const {source} = UrlUtil.autoDecodeHash(link);
|
||||
|
||||
// If the source is from prerelease/homebrew which has been loaded in the background but is
|
||||
// not yet displayed, reload to refresh the list.
|
||||
if (this._pHandleUnknownHash_doSourceReload({source})) return true;
|
||||
|
||||
// Otherwise, try to find the source in prerelease/homebrew, load it, and reload
|
||||
const loaded = await DataLoader.pCacheAndGetHash(UrlUtil.getCurrentPage(), link, {isSilent: true});
|
||||
if (!loaded) return false;
|
||||
|
||||
// If navigation has occurred while we were loading the hash, bail out
|
||||
if (locStart !== window.location.hash) return false;
|
||||
|
||||
return this._pHandleUnknownHash_doSourceReload({source});
|
||||
}
|
||||
|
||||
_pHandleUnknownHash_doSourceReload ({source}) {
|
||||
return [
|
||||
PrereleaseUtil,
|
||||
BrewUtil2,
|
||||
]
|
||||
.some(brewUtil => {
|
||||
if (
|
||||
brewUtil.hasSourceJson(source)
|
||||
&& brewUtil.isReloadRequired()
|
||||
) {
|
||||
brewUtil.doLocationReload();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async pDoLoadSubHash (sub, {lockToken} = {}) {
|
||||
try {
|
||||
|
||||
@@ -163,11 +163,11 @@ class LootGenUi extends BaseComponent {
|
||||
});
|
||||
|
||||
const hkFilterChangeSpells = () => this._handleFilterChangeSpells();
|
||||
this._modalFilterSpells.pageFilter.filterBox.on(FilterBox.EVNT_VALCHANGE, hkFilterChangeSpells);
|
||||
this._modalFilterSpells.pageFilter.filterBox.on(FILTER_BOX_EVNT_VALCHANGE, hkFilterChangeSpells);
|
||||
hkFilterChangeSpells();
|
||||
|
||||
const hkFilterChangeItems = () => this._handleFilterChangeItems({tablesMagicItems});
|
||||
this._modalFilterItems.pageFilter.filterBox.on(FilterBox.EVNT_VALCHANGE, hkFilterChangeItems);
|
||||
this._modalFilterItems.pageFilter.filterBox.on(FILTER_BOX_EVNT_VALCHANGE, hkFilterChangeItems);
|
||||
hkFilterChangeItems();
|
||||
}
|
||||
|
||||
@@ -1125,7 +1125,7 @@ class LootGenUi extends BaseComponent {
|
||||
JqueryUtil.doToast(`Reset${evt.shiftKey ? " all" : ""}!`);
|
||||
},
|
||||
textAlt: `<span class="glyphicon glyphicon-refresh"></span>`,
|
||||
titleAlt: FilterBox.TITLE_BTN_RESET,
|
||||
titleAlt: FILTER_BOX_TITLE_BTN_RESET,
|
||||
},
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
@@ -1140,7 +1140,7 @@ class LootGenUi extends BaseComponent {
|
||||
JqueryUtil.doToast(`Reset${evt.shiftKey ? " all" : ""}!`);
|
||||
},
|
||||
textAlt: `<span class="glyphicon glyphicon-refresh"></span>`,
|
||||
titleAlt: FilterBox.TITLE_BTN_RESET,
|
||||
titleAlt: FILTER_BOX_TITLE_BTN_RESET,
|
||||
},
|
||||
),
|
||||
null,
|
||||
|
||||
@@ -220,7 +220,7 @@ class PageUi {
|
||||
this._$selSource = $$`
|
||||
<select class="form-control input-xs">
|
||||
<option disabled>Select</option>
|
||||
${this._allSources.map(s => `<option value="${s.escapeQuotes()}">${Parser.sourceJsonToFull(s).escapeQuotes()}</option>`)}
|
||||
${this._allSources.map(s => `<option value="${s.qq()}">${Parser.sourceJsonToFull(s).qq()}</option>`)}
|
||||
</select>`
|
||||
.appendTo($wrpSource)
|
||||
.change(async () => {
|
||||
|
||||
@@ -381,7 +381,7 @@ class MakeCards extends BaseComponent {
|
||||
const $ele = $$`<label class="ve-flex-v-center my-1 w-100 lst__row lst--border lst__row-inner">
|
||||
<div class="ve-col-1 mr-2 ve-flex-vh-center">${$cbSel}</div>
|
||||
<div class="ve-col-3 mr-2 ve-flex-v-center">${loaded.name}</div>
|
||||
<div class="ve-col-1-5 mr-2 ve-flex-vh-center ${Parser.sourceJsonToColor(loaded.source)}" title="${Parser.sourceJsonToFull(loaded.source)}" ${Parser.sourceJsonToStyle(loaded.source)}>${Parser.sourceJsonToAbv(loaded.source)}</div>
|
||||
<div class="ve-col-1-5 mr-2 ve-flex-vh-center ${Parser.sourceJsonToSourceClassname(loaded.source)}" title="${Parser.sourceJsonToFull(loaded.source)}" ${Parser.sourceJsonToStyle(loaded.source)}>${Parser.sourceJsonToAbv(loaded.source)}</div>
|
||||
<div class="ve-col-1-5 mr-2 ve-flex-vh-center">${Parser.getPropDisplayName(cardMeta.entityType)}</div>
|
||||
<div class="ve-col-1-1 mr-2 ve-flex-vh-center">${$iptRgb}</div>
|
||||
<div class="ve-col-1-1 mr-2 ve-flex-vh-center">${$btnIcon}</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use strict";
|
||||
import {ManageBrewUi} from "./utils-brew/utils-brew-ui-manage.js";
|
||||
|
||||
class ManageBrew {
|
||||
static async pInitialise () {
|
||||
|
||||
@@ -81,6 +81,3 @@ export class ManageExternalUtils {
|
||||
return `${location.origin}/index.html#${hash}`;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(Future) refactor usage to module
|
||||
globalThis.ManageExternalUtils = ManageExternalUtils;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use strict";
|
||||
import {ManageBrewUi} from "./utils-brew/utils-brew-ui-manage.js";
|
||||
|
||||
class ManagePrerelease {
|
||||
static async pInitialise () {
|
||||
|
||||
@@ -6,7 +6,6 @@ class ListPageMultiSource extends ListPage {
|
||||
super({
|
||||
...rest,
|
||||
isLoadDataAfterFilterInit: true,
|
||||
isBindHashHandlerUnknown: true,
|
||||
});
|
||||
|
||||
this._propLoader = propLoader;
|
||||
@@ -36,7 +35,7 @@ class ListPageMultiSource extends ListPage {
|
||||
|
||||
async _pForceLoadDefaultSources () {
|
||||
const defaultSources = Object.keys(this._loadedSources)
|
||||
.filter(s => PageFilter.defaultSourceSelFn(s));
|
||||
.filter(s => PageFilterBase.defaultSourceSelFn(s));
|
||||
await Promise.all(defaultSources.map(src => this._pLoadSource(src, "yes")));
|
||||
}
|
||||
|
||||
@@ -97,7 +96,7 @@ class ListPageMultiSource extends ListPage {
|
||||
.forEach(src => this._loadedSources[src] = {source: src, loaded: false});
|
||||
|
||||
// collect a list of sources to load
|
||||
const defaultSel = [...siteSourcesAvail].filter(s => PageFilter.defaultSourceSelFn(s));
|
||||
const defaultSel = [...siteSourcesAvail].filter(s => PageFilterBase.defaultSourceSelFn(s));
|
||||
|
||||
const userSel = [
|
||||
// Selected in filter
|
||||
@@ -174,4 +173,19 @@ class ListPageMultiSource extends ListPage {
|
||||
? [...siteSourcesAvail].find(it => it.toLowerCase() === hashSourceRaw.toLowerCase())
|
||||
: null;
|
||||
}
|
||||
|
||||
async pHandleUnknownHash (link, sub) {
|
||||
const {source: srcLink} = UrlUtil.autoDecodeHash(link);
|
||||
|
||||
const src = Object.keys(this._loadedSources)
|
||||
.find(src => src.toLowerCase() === srcLink);
|
||||
|
||||
if (src) {
|
||||
await this._pLoadSource(src, "yes");
|
||||
Hist.hashChange();
|
||||
return;
|
||||
}
|
||||
|
||||
await super.pHandleUnknownHash(link, sub);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,33 @@ class NavBar {
|
||||
this._addElement_li(NavBar._CAT_UTILITIES, "manageprerelease.html", "Prerelease Content Manager");
|
||||
this._addElement_li(NavBar._CAT_UTILITIES, "makebrew.html", "Homebrew Builder");
|
||||
this._addElement_li(NavBar._CAT_UTILITIES, "managebrew.html", "Homebrew Manager");
|
||||
this._addElement_buttonSplit(
|
||||
NavBar._CAT_UTILITIES,
|
||||
{
|
||||
metas: [
|
||||
{
|
||||
html: "Load All Partnered Content",
|
||||
click: async evt => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const {ManageBrewUi} = await import("./utils-brew/utils-brew-ui-manage.js");
|
||||
await ManageBrewUi.pOnClickBtnLoadAllPartnered();
|
||||
},
|
||||
},
|
||||
{
|
||||
html: `<span class="glyphicon glyphicon-link"></span>`,
|
||||
title: `Export Prerelease Content/Homebrew List as URL`,
|
||||
click: async evt => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const ele = evt.currentTarget;
|
||||
const {ManageBrewUi} = await import("./utils-brew/utils-brew-ui-manage.js");
|
||||
await ManageBrewUi.pOnClickBtnExportListAsUrl({ele});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
this._addElement_divider(NavBar._CAT_UTILITIES);
|
||||
this._addElement_li(NavBar._CAT_UTILITIES, "inittrackerplayerview.html", "Initiative Tracker Player View");
|
||||
this._addElement_divider(NavBar._CAT_UTILITIES);
|
||||
@@ -455,7 +482,7 @@ class NavBar {
|
||||
}
|
||||
|
||||
static _addElement_getDatePrefix ({date, isAddDateSpacer}) { return `${(date != null || isAddDateSpacer) ? `<div class="ve-small mr-2 page__nav-date inline-block text-right inline-block">${date || ""}</div>` : ""}`; }
|
||||
static _addElement_getSourcePrefix ({source}) { return `${source != null ? `<div class="nav2-list__disp-source ${Parser.sourceJsonToColor(source)}" ${Parser.sourceJsonToStyle(source)}></div>` : ""}`; }
|
||||
static _addElement_getSourcePrefix ({source}) { return `${source != null ? `<div class="nav2-list__disp-source ${Parser.sourceJsonToSourceClassname(source)}" ${Parser.sourceJsonToStyle(source)}></div>` : ""}`; }
|
||||
|
||||
static _addElement_divider (parentCategory) {
|
||||
const parentNode = this._getNode(parentCategory);
|
||||
@@ -539,17 +566,53 @@ class NavBar {
|
||||
const li = document.createElement("li");
|
||||
li.setAttribute("role", "presentation");
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = "#";
|
||||
if (options.className) a.className = options.className;
|
||||
a.onclick = options.click;
|
||||
a.innerHTML = options.html;
|
||||
const eleSpan = document.createElement("span");
|
||||
if (options.className) eleSpan.className = options.className;
|
||||
eleSpan.onclick = options.click;
|
||||
eleSpan.innerHTML = options.html;
|
||||
|
||||
if (options.context) a.oncontextmenu = options.context;
|
||||
if (options.context) eleSpan.oncontextmenu = options.context;
|
||||
|
||||
if (options.title) li.setAttribute("title", options.title);
|
||||
|
||||
li.appendChild(a);
|
||||
li.appendChild(eleSpan);
|
||||
parentNode.body.appendChild(li);
|
||||
}
|
||||
|
||||
/**
|
||||
* Special LI for button
|
||||
* @param parentCategory The element to append to.
|
||||
* @param options Options.
|
||||
* @param options.html
|
||||
* @param options.metas
|
||||
*/
|
||||
static _addElement_buttonSplit (parentCategory, options) {
|
||||
const parentNode = this._getNode(parentCategory);
|
||||
|
||||
const li = document.createElement("li");
|
||||
li.setAttribute("role", "presentation");
|
||||
li.className = "ve-flex-v-center";
|
||||
|
||||
options.metas
|
||||
.forEach(({className, click, html, title}, i) => {
|
||||
const eleSpan = document.createElement("span");
|
||||
|
||||
eleSpan.className = [
|
||||
className,
|
||||
"inline-block",
|
||||
i ? null : "w-100 min-w-0",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
eleSpan.onclick = click;
|
||||
eleSpan.innerHTML = html;
|
||||
|
||||
if (title) eleSpan.setAttribute("title", title);
|
||||
|
||||
li.appendChild(eleSpan);
|
||||
});
|
||||
|
||||
parentNode.body.appendChild(li);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,8 +60,6 @@ class ObjectsPage extends ListPage {
|
||||
dataProps: ["object"],
|
||||
|
||||
listSyntax: new ListSyntaxObjects({fnGetDataList: () => this._dataList, pFnGetFluff}),
|
||||
|
||||
isMarkdownPopout: true,
|
||||
});
|
||||
|
||||
this._$dispToken = null;
|
||||
@@ -80,7 +78,7 @@ class ObjectsPage extends ListPage {
|
||||
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
|
||||
<span class="bold ve-col-8 pl-0">${obj.name}</span>
|
||||
<span class="ve-col-2 ve-text-center">${size}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(obj.source)} pr-0" title="${Parser.sourceJsonToFull(obj.source)}" ${Parser.sourceJsonToStyle(obj.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(obj.source)} pr-0" title="${Parser.sourceJsonToFull(obj.source)}" ${Parser.sourceJsonToStyle(obj.source)}>${source}</span>
|
||||
</a>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
|
||||
@@ -6,14 +6,18 @@ class Omnidexer {
|
||||
* Produces index of the form:
|
||||
* {
|
||||
* n: "Display Name",
|
||||
* b: "Base Name" // Optional; name is used if not specified
|
||||
* [b: "Base Name"] // name is used if not specified
|
||||
* s: "PHB", // source
|
||||
* [sA: "PHB"], // source abbreviation
|
||||
* [sF: "Player's Handbook"], // source full
|
||||
* [sC: "ff00ff"], // source color
|
||||
* u: "spell name_phb, // hash
|
||||
* uh: "spell name_phb, // Optional; hash for href if the link should be different from the hover lookup hash.
|
||||
* p: 110, // page number
|
||||
* [q: "bestiary.html", // page; synthetic property only used by search widget]
|
||||
* h: 1 // if isHover enabled, otherwise undefined
|
||||
* r: 1 // if SRD
|
||||
* [dP: 1] // if partnered
|
||||
* c: 10, // category ID
|
||||
* id: 123, // index ID
|
||||
* [t: "spell"], // tag
|
||||
@@ -46,13 +50,16 @@ class Omnidexer {
|
||||
Object.entries(metadata[k]).forEach(([kk, vv]) => (lookup[k] = lookup[k] || {})[vv] = kk);
|
||||
});
|
||||
|
||||
index.forEach(it => Object.keys(it).filter(k => props.has(k))
|
||||
.forEach(k => it[k] = lookup[k][it[k]] ?? it[k]));
|
||||
index.forEach(it => {
|
||||
Object.keys(it).filter(k => props.has(k))
|
||||
.forEach(k => it[k] = lookup[k][it[k]] ?? it[k]);
|
||||
});
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
static getProperty (obj, withDots) {
|
||||
return withDots.split(".").reduce((o, i) => o[i], obj);
|
||||
return MiscUtil.get(obj, ...withDots.split("."));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +72,7 @@ class Omnidexer {
|
||||
* @param [options.isIncludeTag]
|
||||
* @param [options.isIncludeUid]
|
||||
* @param [options.isIncludeImg]
|
||||
* @param [options.isIncludeExtendedSourceInfo]
|
||||
*/
|
||||
async pAddToIndex (arbiter, json, options) {
|
||||
options = options || {};
|
||||
@@ -140,6 +148,8 @@ class Omnidexer {
|
||||
if (ent.srd) indexDoc.r = 1;
|
||||
|
||||
if (src) {
|
||||
if (SourceUtil.isPartneredSourceWotc(src)) indexDoc.dP = 1;
|
||||
|
||||
if (options.isIncludeTag) {
|
||||
indexDoc.t = this.getMetaId("t", Parser.getPropTag(arbiter.listProp));
|
||||
}
|
||||
@@ -167,6 +177,15 @@ class Omnidexer {
|
||||
indexDoc.m = indexDoc.m.replace(/^img\//, "");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.isIncludeExtendedSourceInfo) {
|
||||
indexDoc.sA = this.getMetaId("sA", Parser.sourceJsonToAbv(src));
|
||||
|
||||
indexDoc.sF = this.getMetaId("sF", Parser.sourceJsonToFull(src));
|
||||
|
||||
const color = Parser.sourceJsonToColor(src);
|
||||
if (color) indexDoc.sC = this.getMetaId("sC", color);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.alt) {
|
||||
@@ -862,7 +881,6 @@ class IndexableFileAdventures extends IndexableFile {
|
||||
super({
|
||||
category: Parser.CAT_ID_ADVENTURE,
|
||||
file: "adventures.json",
|
||||
source: "id",
|
||||
listProp: "adventure",
|
||||
baseUrl: "adventure.html",
|
||||
});
|
||||
@@ -874,7 +892,6 @@ class IndexableFileBooks extends IndexableFile {
|
||||
super({
|
||||
category: Parser.CAT_ID_BOOK,
|
||||
file: "books.json",
|
||||
source: "id",
|
||||
listProp: "book",
|
||||
baseUrl: "book.html",
|
||||
});
|
||||
|
||||
118
js/omnisearch.js
118
js/omnisearch.js
@@ -1,6 +1,19 @@
|
||||
"use strict";
|
||||
import {UtilsOmnisearch} from "./utils-omnisearch.js";
|
||||
|
||||
class Omnisearch {
|
||||
static _PLACEHOLDER_TEXT = "Search everywhere...";
|
||||
static _searchIndex = null;
|
||||
static _adventureBookLookup = null; // A map of `<sourceLower>: (adventureCatId|bookCatId)`
|
||||
static _pLoadSearch = null;
|
||||
static _CATEGORY_COUNTS = {};
|
||||
|
||||
static _clickFirst = false;
|
||||
static _MAX_RESULTS = 15;
|
||||
|
||||
static _STORAGE_NAME = "search";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
static _sortResults (a, b) {
|
||||
const byScore = SortUtil.ascSort(b.score, a.score);
|
||||
if (byScore) return byScore;
|
||||
@@ -278,7 +291,7 @@ class Omnisearch {
|
||||
}
|
||||
|
||||
if (!this._state.isShowBrew) {
|
||||
results = results.filter(r => !r.doc.s || !BrewUtil2.hasSourceJson(r.doc.s));
|
||||
results = results.filter(r => !r.doc.s || (!BrewUtil2.hasSourceJson(r.doc.s) && !r.doc.dP));
|
||||
}
|
||||
|
||||
if (!this._state.isShowUa) {
|
||||
@@ -389,21 +402,21 @@ class Omnisearch {
|
||||
propState: "isShowBrew",
|
||||
propBtn: "_btnToggleBrew",
|
||||
title: "Include homebrew content results",
|
||||
text: "Include Homebrew",
|
||||
text: "Homebrew",
|
||||
});
|
||||
|
||||
this._doInitBtnToggleFilter({
|
||||
propState: "isShowUa",
|
||||
propBtn: "_btnToggleUa",
|
||||
title: "Include Unearthed Arcana and other unofficial source results",
|
||||
text: "Include UA/etc.",
|
||||
text: "UA/etc.",
|
||||
});
|
||||
|
||||
this._doInitBtnToggleFilter({
|
||||
propState: "isShowBlocklisted",
|
||||
propBtn: "_btnToggleBlocklisted",
|
||||
title: "Include blocklisted content results",
|
||||
text: "Include Blocklisted",
|
||||
text: "Blocklisted",
|
||||
});
|
||||
|
||||
this._doInitBtnToggleFilter({
|
||||
@@ -415,31 +428,24 @@ class Omnisearch {
|
||||
|
||||
this._dispSearchOutput.empty();
|
||||
|
||||
this._dispSearchOutput.appends(
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: "ve-flex-h-right ve-flex-v-center mb-2",
|
||||
children: [
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: "btn-group ve-flex-v-center",
|
||||
children: [
|
||||
this._btnToggleBrew,
|
||||
this._btnToggleUa,
|
||||
this._btnToggleBlocklisted,
|
||||
this._btnToggleSrd,
|
||||
],
|
||||
}),
|
||||
e_({
|
||||
tag: "button",
|
||||
clazz: "btn btn-default btn-xs ml-2",
|
||||
title: "Help",
|
||||
html: `<span class="glyphicon glyphicon-info-sign"></span>`,
|
||||
click: () => this.doShowHelp(),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const btnHelp = e_({
|
||||
tag: "button",
|
||||
clazz: "btn btn-default btn-xs ml-2",
|
||||
title: "Help",
|
||||
html: `<span class="glyphicon glyphicon-info-sign"></span>`,
|
||||
click: () => this.doShowHelp(),
|
||||
});
|
||||
|
||||
ee(this._dispSearchOutput)`<div class="ve-flex-h-right ve-flex-v-center mb-2">
|
||||
<span class="mr-2 italic relative top-1p">Include</span>
|
||||
<div class="btn-group ve-flex-v-center mr-2">
|
||||
${this._btnToggleBrew}
|
||||
${this._btnToggleUa}
|
||||
${this._btnToggleBlocklisted}
|
||||
</div>
|
||||
${this._btnToggleSrd}
|
||||
${btnHelp}
|
||||
</div>`;
|
||||
|
||||
const base = page * this._MAX_RESULTS;
|
||||
for (let i = base; i < Math.max(Math.min(results.length, this._MAX_RESULTS + base), base); ++i) {
|
||||
@@ -448,7 +454,16 @@ class Omnisearch {
|
||||
const $link = this.$getResultLink(r)
|
||||
.keydown(evt => this.handleLinkKeyDown(evt, $link));
|
||||
|
||||
const {s: source, p: page, r: isSrd} = r;
|
||||
const {
|
||||
source,
|
||||
page,
|
||||
isSrd,
|
||||
|
||||
ptStyle,
|
||||
sourceAbv,
|
||||
sourceFull,
|
||||
} = UtilsOmnisearch.getUnpackedSearchResult(r);
|
||||
|
||||
const ptPageInner = page ? `p${page}` : "";
|
||||
const adventureBookSourceHref = SourceUtil.getAdventureBookSourceHref(source, page);
|
||||
const ptPage = ptPageInner && adventureBookSourceHref
|
||||
@@ -456,7 +471,7 @@ class Omnisearch {
|
||||
: ptPageInner;
|
||||
|
||||
const ptSourceInner = source
|
||||
? `<span class="${Parser.sourceJsonToColor(source)}" ${Parser.sourceJsonToStyle(source)} title="${Parser.sourceJsonToFull(source)}">${Parser.sourceJsonToAbv(source)}</span>`
|
||||
? `<span class="${Parser.sourceJsonToSourceClassname(source)}" ${ptStyle} title="${sourceFull.qq()}">${sourceAbv.qq()}</span>`
|
||||
: `<span></span>`;
|
||||
const ptSource = ptPage || !adventureBookSourceHref
|
||||
? ptSourceInner
|
||||
@@ -558,8 +573,6 @@ class Omnisearch {
|
||||
static get isSrdOnly () { return this._state.isSrdOnly; }
|
||||
|
||||
static async _pDoSearchLoad () {
|
||||
const data = Omnidexer.decompressIndex(await DataUtil.loadJSON(`${Renderer.get().baseUrl}search/index.json`));
|
||||
|
||||
elasticlunr.clearStopWords();
|
||||
this._searchIndex = elasticlunr(function () {
|
||||
this.addField("n");
|
||||
@@ -569,7 +582,8 @@ class Omnisearch {
|
||||
});
|
||||
SearchUtil.removeStemmer(this._searchIndex);
|
||||
|
||||
data.forEach(it => this._addToIndex(it));
|
||||
const siteIndex = Omnidexer.decompressIndex(await DataUtil.loadJSON(`${Renderer.get().baseUrl}search/index.json`));
|
||||
siteIndex.forEach(it => this._addToIndex(it));
|
||||
|
||||
const prereleaseIndex = await PrereleaseUtil.pGetSearchIndex({id: this._maxId + 1});
|
||||
prereleaseIndex.forEach(it => this._addToIndex(it));
|
||||
@@ -577,8 +591,26 @@ class Omnisearch {
|
||||
const brewIndex = await BrewUtil2.pGetSearchIndex({id: this._maxId + 1});
|
||||
brewIndex.forEach(it => this._addToIndex(it));
|
||||
|
||||
// region Partnered homebrew
|
||||
// Note that we filter out anything which is already in the user's homebrew, to avoid double-indexing
|
||||
const sourcesBrew = new Set(
|
||||
BrewUtil2.getSources()
|
||||
.map(src => src.json),
|
||||
);
|
||||
|
||||
const partneredIndexRaw = Omnidexer.decompressIndex(await DataUtil.loadJSON(`${Renderer.get().baseUrl}search/index-partnered.json`));
|
||||
const partneredIndex = partneredIndexRaw
|
||||
.filter(it => !sourcesBrew.has(it.s));
|
||||
// Re-ID, to:
|
||||
// - override the base partnered index IDs (which has statically-generated IDs starting at 0)
|
||||
// - avoid any holes
|
||||
partneredIndex
|
||||
.forEach((it, i) => it.id = this._maxId + 1 + i);
|
||||
partneredIndex.forEach(it => this._addToIndex(it));
|
||||
// endregion
|
||||
|
||||
this._adventureBookLookup = {};
|
||||
[brewIndex, data].forEach(index => {
|
||||
[prereleaseIndex, brewIndex, siteIndex, partneredIndex].forEach(index => {
|
||||
index.forEach(it => {
|
||||
if (it.c === Parser.CAT_ID_ADVENTURE || it.c === Parser.CAT_ID_BOOK) this._adventureBookLookup[it.s.toLowerCase()] = it.c;
|
||||
});
|
||||
@@ -675,17 +707,7 @@ class Omnisearch {
|
||||
`);
|
||||
}
|
||||
}
|
||||
Omnisearch._PLACEHOLDER_TEXT = "Search everywhere...";
|
||||
Omnisearch._searchIndex = null;
|
||||
Omnisearch._adventureBookLookup = null; // A map of `<sourceLower>: (adventureCatId|bookCatId)`
|
||||
Omnisearch._pLoadSearch = null;
|
||||
Omnisearch._CATEGORY_COUNTS = {};
|
||||
|
||||
Omnisearch._clickFirst = false;
|
||||
Omnisearch._MAX_RESULTS = 15;
|
||||
Omnisearch._showUaEtc = false;
|
||||
Omnisearch._hideBlocklisted = false;
|
||||
|
||||
Omnisearch._STORAGE_NAME = "search";
|
||||
|
||||
window.addEventListener("load", () => Omnisearch.init());
|
||||
|
||||
globalThis.Omnisearch = Omnisearch;
|
||||
|
||||
@@ -94,8 +94,6 @@ class OptionalFeaturesPage extends ListPage {
|
||||
},
|
||||
|
||||
isPreviewable: true,
|
||||
|
||||
isMarkdownPopout: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,7 +114,7 @@ class OptionalFeaturesPage extends ListPage {
|
||||
<span class="ve-col-1-5 ve-text-center" title="${it._dFeatureType}">${it._lFeatureType}</span>
|
||||
<span class="ve-col-4-7">${prerequisite}</span>
|
||||
<span class="ve-col-1 ve-text-center">${level}</span>
|
||||
<span class="ve-col-1-5 ${Parser.sourceJsonToColor(it.source)} ve-text-center pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
<span class="ve-col-1-5 ${Parser.sourceJsonToSourceClassname(it.source)} ve-text-center pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
</a>
|
||||
<div class="ve-flex ve-hidden relative lst__wrp-preview">
|
||||
<div class="vr-0 absolute lst__vr-preview"></div>
|
||||
|
||||
12
js/parser.js
12
js/parser.js
@@ -643,8 +643,15 @@ Parser.sourceJsonToDate = function (source) {
|
||||
if (typeof BrewUtil2 !== "undefined" && BrewUtil2.hasSourceJson(source)) return BrewUtil2.sourceJsonToDate(source);
|
||||
return Parser._parse_aToB(Parser.SOURCE_JSON_TO_DATE, source, null);
|
||||
};
|
||||
|
||||
Parser.sourceJsonToColor = function (source) {
|
||||
source = Parser._getSourceStringFromSource(source);
|
||||
if (Parser.hasSourceAbv(source)) return "";
|
||||
if (typeof PrereleaseUtil !== "undefined" && PrereleaseUtil.hasSourceJson(source)) return PrereleaseUtil.sourceJsonToColor(source);
|
||||
if (typeof BrewUtil2 !== "undefined" && BrewUtil2.hasSourceJson(source)) return BrewUtil2.sourceJsonToColor(source);
|
||||
return "";
|
||||
};
|
||||
|
||||
Parser.sourceJsonToSourceClassname = function (source) {
|
||||
const sourceCased = Parser.sourceJsonToJson(source);
|
||||
return `source__${sourceCased}`;
|
||||
};
|
||||
@@ -872,6 +879,7 @@ Parser.itemRechargeToFull = function (recharge) {
|
||||
|
||||
Parser.ITEM_MISC_TAG_TO_FULL = {
|
||||
"CF/W": "Creates Food/Water",
|
||||
"CNS": "Consumable",
|
||||
"TT": "Trinket Table",
|
||||
};
|
||||
Parser.itemMiscTagToFull = function (type) {
|
||||
@@ -991,7 +999,7 @@ Parser._spSchoolAbvToStylePart_prereleaseBrew = function ({school, brewUtil}) {
|
||||
const rawColor = brewUtil.getMetaLookup("spellSchools")?.[school]?.color;
|
||||
if (!rawColor || !rawColor.trim()) return "";
|
||||
const validColor = BrewUtilShared.getValidColor(rawColor);
|
||||
if (validColor.length) return `color: #${validColor};`;
|
||||
if (validColor.length) return MiscUtil.getColorStylePart(validColor);
|
||||
};
|
||||
|
||||
Parser.getOrdinalForm = function (i) {
|
||||
|
||||
@@ -61,8 +61,6 @@ class PsionicsPage extends ListPage {
|
||||
|
||||
dataProps: ["psionic"],
|
||||
|
||||
isMarkdownPopout: true,
|
||||
|
||||
bookViewOptions: {
|
||||
namePlural: "psionics",
|
||||
pageTitle: "Psionics Book View",
|
||||
|
||||
@@ -68,8 +68,6 @@ class RacesPage extends ListPage {
|
||||
|
||||
dataProps: ["race"],
|
||||
|
||||
isMarkdownPopout: true,
|
||||
|
||||
bookViewOptions: {
|
||||
namePlural: "races",
|
||||
pageTitle: "Races Book View",
|
||||
@@ -106,7 +104,7 @@ class RacesPage extends ListPage {
|
||||
<span class="bold ve-col-4 pl-0">${race.name}</span>
|
||||
<span class="ve-col-4 ${race._slAbility === "Lineage (choose)" ? "italic" : ""}">${race._slAbility}</span>
|
||||
<span class="ve-col-2 ve-text-center">${size}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(race.source)} pr-0" title="${Parser.sourceJsonToFull(race.source)}" ${Parser.sourceJsonToStyle(race.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(race.source)} pr-0" title="${Parser.sourceJsonToFull(race.source)}" ${Parser.sourceJsonToStyle(race.source)}>${source}</span>
|
||||
</a>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
|
||||
@@ -67,8 +67,6 @@ class RecipesPage extends ListPage {
|
||||
|
||||
dataProps: ["recipe"],
|
||||
|
||||
isMarkdownPopout: true,
|
||||
|
||||
listSyntax: new ListSyntaxRecipes({fnGetDataList: () => this._dataList, pFnGetFluff}),
|
||||
});
|
||||
}
|
||||
@@ -85,7 +83,7 @@ class RecipesPage extends ListPage {
|
||||
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
|
||||
<span class="ve-col-6 bold pl-0">${ent.name}</span>
|
||||
<span class="ve-col-4 ve-text-center">${ent.type || "\u2014"}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(ent.source)} pr-0" title="${Parser.sourceJsonToFull(ent.source)}" ${Parser.sourceJsonToStyle(ent.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(ent.source)} pr-0" title="${Parser.sourceJsonToFull(ent.source)}" ${Parser.sourceJsonToStyle(ent.source)}>${source}</span>
|
||||
</a>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
|
||||
@@ -69,13 +69,10 @@ class RenderBestiary {
|
||||
<tr><td colspan="6"><strong>Senses</strong> ${Renderer.monster.getSensesPart(mon)}</td></tr>
|
||||
<tr><td colspan="6"><strong>Languages</strong> ${Renderer.monster.getRenderedLanguages(mon.languages)}</td></tr>
|
||||
|
||||
<tr class="relative">${Parser.crToNumber(mon.cr) < VeCt.CR_UNKNOWN ? $$`
|
||||
<td colspan="3"><strong>Challenge</strong>
|
||||
<span>${Parser.monCrToFull(mon.cr, {isMythic: !!mon.mythic})}</span>
|
||||
${options.$btnScaleCr || ""}
|
||||
${options.$btnResetScaleCr || ""}
|
||||
</td>
|
||||
` : `<td colspan="3"><strong>Challenge</strong> <span>\u2014</span></td>`}${mon.pbNote || Parser.crToNumber(mon.cr) < VeCt.CR_CUSTOM ? `<td colspan="3" class="text-right"><strong>Proficiency Bonus</strong> ${mon.pbNote ?? UiUtil.intToBonus(Parser.crToPb(mon.cr), {isPretty: true})}</td>` : ""}</tr>
|
||||
<tr class="relative">
|
||||
${this._$getRenderedCreature_$getTdChallenge(mon, options)}
|
||||
${this._$getRenderedCreature_getTdPb(mon, options)}
|
||||
</tr>
|
||||
|
||||
<tr>${options.selSummonSpellLevel ? $$`<td colspan="6"><strong>Spell Level</strong> ${options.selSummonSpellLevel}</td>` : ""}</tr>
|
||||
<tr>${options.selSummonClassLevel ? $$`<td colspan="6"><strong>Class Level</strong> ${options.selSummonClassLevel}</td>` : ""}</tr>
|
||||
@@ -160,6 +157,24 @@ class RenderBestiary {
|
||||
</button>`;
|
||||
}
|
||||
|
||||
static _$getRenderedCreature_$getTdChallenge (mon, options) {
|
||||
if (Parser.crToNumber(mon.cr) >= VeCt.CR_UNKNOWN) return `<td colspan="3"><strong>Challenge</strong> <span>\u2014</span></td>`;
|
||||
|
||||
return $$`<td colspan="3"><strong>Challenge</strong>
|
||||
<span>${Parser.monCrToFull(mon.cr, {isMythic: !!mon.mythic})}</span>
|
||||
${options.$btnScaleCr || ""}
|
||||
${options.$btnResetScaleCr || ""}
|
||||
</td>`;
|
||||
}
|
||||
|
||||
static _$getRenderedCreature_getTdPb (mon, options) {
|
||||
if (!mon.pbNote && Parser.crToNumber(mon.cr) >= VeCt.CR_CUSTOM) {
|
||||
return `<td colspan="3"></td>`;
|
||||
}
|
||||
|
||||
return `<td colspan="3" class="text-right"><strong>Proficiency Bonus</strong> ${mon.pbNote ?? UiUtil.intToBonus(Parser.crToPb(mon.cr), {isPretty: true})}</td>`;
|
||||
}
|
||||
|
||||
static _$getRenderedCreature_getHtmlSourceAndEnvironment (mon, legGroup) {
|
||||
const srcCpy = {
|
||||
source: mon.source,
|
||||
|
||||
@@ -216,11 +216,15 @@ class RenderDecks {
|
||||
const $wrpCardOuter = $$`<div class="ve-flex-col no-select relative">
|
||||
${metasSparkles.map(it => it.wrpSparkleSway)}
|
||||
${$wrpCardSway}
|
||||
</div>`;
|
||||
</div>`
|
||||
.on("mouseup", evt => {
|
||||
if (!EventUtil.isMiddleMouse(evt) || !imgBack) return;
|
||||
wrpCardFlip.classList.toggle("decks-draw__wrp-card-flip--flipped");
|
||||
});
|
||||
|
||||
const ptText = RenderDecks.getCardTextHtml({card, deck});
|
||||
|
||||
const $wrpInfo = $$`<div class="stats stats--book decks-draw__wrp-desc mobile__hidden px-2 ve-text-center mb-4">${ptText}</div>`
|
||||
const $wrpInfo = $$`<div class="stats stats--book decks-draw__wrp-desc mobile__hidden px-2 ve-text-center mb-4 ve-overflow-y-auto">${ptText}</div>`
|
||||
.click(evt => evt.stopPropagation());
|
||||
|
||||
Renderer.dice.bindOnclickListener($wrpInfo[0]);
|
||||
|
||||
@@ -1091,6 +1091,12 @@ ___
|
||||
}
|
||||
meta.depth = cacheDepth;
|
||||
|
||||
const fromClassList = Renderer.spell.getCombinedClasses(sp, "fromClassList");
|
||||
if (fromClassList.length) {
|
||||
const [current] = Parser.spClassesToCurrentAndLegacy(fromClassList);
|
||||
subStack[0] = `${subStack[0].trimEnd()}\n\n**Classes:** ${Parser.spMainClassesToFull(current, {isTextOnly: true})}`;
|
||||
}
|
||||
|
||||
const spellRender = subStack.join("").trim();
|
||||
return `\n${spellRender}\n\n`;
|
||||
}
|
||||
|
||||
@@ -2861,7 +2861,7 @@ Renderer.utils = class {
|
||||
${!IS_VTT && ExtensionUtil.ACTIVE && opts.page ? Renderer.utils.getBtnSendToFoundryHtml() : ""}
|
||||
</div>
|
||||
<div class="stats-source ve-flex-v-baseline">
|
||||
${tagPartSourceStart} class="help-subtle stats-source-abbreviation ${it.source ? `${Parser.sourceJsonToColor(it.source)}" title="${Parser.sourceJsonToFull(it.source)}${Renderer.utils.getSourceSubText(it)}` : ""}" ${Parser.sourceJsonToStyle(it.source)}>${it.source ? Parser.sourceJsonToAbv(it.source) : ""}${tagPartSourceEnd}
|
||||
${tagPartSourceStart} class="help-subtle stats-source-abbreviation ${it.source ? `${Parser.sourceJsonToSourceClassname(it.source)}" title="${Parser.sourceJsonToFull(it.source)}${Renderer.utils.getSourceSubText(it)}` : ""}" ${Parser.sourceJsonToStyle(it.source)}>${it.source ? Parser.sourceJsonToAbv(it.source) : ""}${tagPartSourceEnd}
|
||||
|
||||
${Renderer.utils.isDisplayPage(it.page) ? ` ${tagPartSourceStart} class="rd__stats-name-page ml-1" title="Page ${it.page}">p${it.page}${tagPartSourceEnd}` : ""}
|
||||
|
||||
@@ -12109,9 +12109,9 @@ Renderer.hover = class {
|
||||
</style>
|
||||
</head><body class="rd__body-popout">
|
||||
<div class="hwin hoverbox--popout hwin--popout"></div>
|
||||
<script type="text/javascript" src="js/parser.js"></script>
|
||||
<script type="text/javascript" src="js/utils.js"></script>
|
||||
<script type="text/javascript" src="lib/jquery.js"></script>
|
||||
<script type="text/javascript" defer src="js/parser.js"></script>
|
||||
<script type="text/javascript" defer src="js/utils.js"></script>
|
||||
<script type="text/javascript" defer src="lib/jquery.js"></script>
|
||||
</body></html>
|
||||
`);
|
||||
|
||||
|
||||
@@ -57,8 +57,6 @@ class RewardsPage extends ListPage {
|
||||
dataProps: ["reward"],
|
||||
|
||||
isPreviewable: true,
|
||||
|
||||
isMarkdownPopout: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +73,7 @@ class RewardsPage extends ListPage {
|
||||
<span class="ve-col-0-3 px-0 ve-flex-vh-center lst__btn-toggle-expand ve-self-flex-stretch">[+]</span>
|
||||
<span class="ve-col-2 ve-text-center px-1">${reward.type}</span>
|
||||
<span class="bold ve-col-7-7">${reward.name}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(reward.source)} pr-0" title="${Parser.sourceJsonToFull(reward.source)}" ${Parser.sourceJsonToStyle(reward.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(reward.source)} pr-0" title="${Parser.sourceJsonToFull(reward.source)}" ${Parser.sourceJsonToStyle(reward.source)}>${source}</span>
|
||||
</a>
|
||||
<div class="ve-flex ve-hidden relative lst__wrp-preview">
|
||||
<div class="vr-0 absolute lst__vr-preview"></div>
|
||||
|
||||
26
js/search.js
26
js/search.js
@@ -1,4 +1,4 @@
|
||||
"use strict";
|
||||
import {UtilsOmnisearch} from "./utils-omnisearch.js";
|
||||
|
||||
class SearchPage {
|
||||
static async pInit () {
|
||||
@@ -76,7 +76,7 @@ class SearchPage {
|
||||
propOmnisearch: "isShowBrew",
|
||||
fnAddHookOmnisearch: "addHookBrew",
|
||||
fnDoToggleOmnisearch: "doToggleBrew",
|
||||
title: "Filter Homebrew",
|
||||
title: "Include Homebrew",
|
||||
text: "Include Homebrew",
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ class SearchPage {
|
||||
propOmnisearch: "isShowUa",
|
||||
fnAddHookOmnisearch: "addHookUa",
|
||||
fnDoToggleOmnisearch: "doToggleUa",
|
||||
title: "Filter Unearthed Arcana and other unofficial source results",
|
||||
title: "Include Unearthed Arcana and other unofficial source results",
|
||||
text: "Include UA",
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ class SearchPage {
|
||||
propOmnisearch: "isShowBlocklisted",
|
||||
fnAddHookOmnisearch: "addHookBlocklisted",
|
||||
fnDoToggleOmnisearch: "doToggleBlocklisted",
|
||||
title: "Filter blocklisted content results",
|
||||
title: "Include blocklisted content results",
|
||||
text: "Include Blocklisted",
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ class SearchPage {
|
||||
propOmnisearch: "isSrdOnly",
|
||||
fnAddHookOmnisearch: "addHookSrdOnly",
|
||||
fnDoToggleOmnisearch: "doToggleSrdOnly",
|
||||
title: "Filter non- Systems Reference Document results",
|
||||
title: "Exclude non- Systems Reference Document results",
|
||||
text: "SRD Only",
|
||||
});
|
||||
|
||||
@@ -197,7 +197,19 @@ class SearchPage {
|
||||
|
||||
const $link = Omnisearch.$getResultLink(r);
|
||||
|
||||
const {s: source, p: page, h: isHoverable, c: category, u: hash, r: isSrd} = r;
|
||||
const {
|
||||
source,
|
||||
page,
|
||||
isHoverable,
|
||||
category,
|
||||
hash,
|
||||
isSrd,
|
||||
|
||||
ptStyle,
|
||||
sourceAbv,
|
||||
sourceFull,
|
||||
} = UtilsOmnisearch.getUnpackedSearchResult(r);
|
||||
|
||||
const ptPageInner = page ? `page ${page}` : "";
|
||||
const adventureBookSourceHref = SourceUtil.getAdventureBookSourceHref(source, page);
|
||||
const ptPage = ptPageInner && adventureBookSourceHref
|
||||
@@ -205,7 +217,7 @@ class SearchPage {
|
||||
: ptPageInner;
|
||||
|
||||
const ptSourceInner = source
|
||||
? `<i>${Parser.sourceJsonToFull(source)}</i> (<span class="${Parser.sourceJsonToColor(source)}" ${Parser.sourceJsonToStyle(source)}>${Parser.sourceJsonToAbv(source)}</span>)${isSrd ? `<span class="ve-muted relative help-subtle pg-search__disp-srd" title="Available in the Systems Reference Document">[SRD]</span>` : ""}${Parser.sourceJsonToMarkerHtml(source, {isList: false, additionalStyles: "pg-search__disp-source-marker"})}`
|
||||
? `<i>${sourceFull}</i> (<span class="${Parser.sourceJsonToSourceClassname(source)}" ${ptStyle}>${sourceAbv}</span>)${isSrd ? `<span class="ve-muted relative help-subtle pg-search__disp-srd" title="Available in the Systems Reference Document">[SRD]</span>` : ""}${Parser.sourceJsonToMarkerHtml(source, {isList: false, additionalStyles: "pg-search__disp-source-marker"})}`
|
||||
: `<span></span>`;
|
||||
const ptSource = ptPage || !adventureBookSourceHref
|
||||
? ptSourceInner
|
||||
|
||||
13
js/spells.js
13
js/spells.js
@@ -263,8 +263,6 @@ class SpellsPage extends ListPageMultiSource {
|
||||
},
|
||||
},
|
||||
|
||||
isMarkdownPopout: true,
|
||||
|
||||
propLoader: "spell",
|
||||
|
||||
listSyntax: new ListSyntaxSpells({fnGetDataList: () => this._dataList, pFnGetFluff}),
|
||||
@@ -336,7 +334,7 @@ class SpellsPage extends ListPageMultiSource {
|
||||
e_({tag: "span", clazz: `ve-col-2-4 text-right`, text: range}),
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: `ve-col-1-7 ve-text-center ${Parser.sourceJsonToColor(spell.source)} pr-0`,
|
||||
clazz: `ve-col-1-7 ve-text-center ${Parser.sourceJsonToSourceClassname(spell.source)} pr-0`,
|
||||
style: Parser.sourceJsonToStylePart(spell.source),
|
||||
title: `${Parser.sourceJsonToFull(spell.source)}${Renderer.utils.getSourceSubText(spell)}`,
|
||||
text: source,
|
||||
@@ -396,15 +394,6 @@ class SpellsPage extends ListPageMultiSource {
|
||||
await Promise.all(toLoad.map(src => this._pLoadSource(src, "yes")));
|
||||
}
|
||||
}
|
||||
|
||||
async pHandleUnknownHash (link, sub) {
|
||||
const src = Object.keys(this._loadedSources)
|
||||
.find(src => src.toLowerCase() === (UrlUtil.decodeHash(link)[1] || "").toLowerCase());
|
||||
if (src) {
|
||||
await this._pLoadSource(src, "yes");
|
||||
Hist.hashChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spellsPage = new SpellsPage();
|
||||
|
||||
@@ -1006,7 +1006,7 @@ class StatGenUi extends BaseComponent {
|
||||
});
|
||||
};
|
||||
|
||||
this._parent[this._propModalFilter].pageFilter.filterBox.on(FilterBox.EVNT_VALCHANGE, () => doApplyFilterToSelEntity());
|
||||
this._parent[this._propModalFilter].pageFilter.filterBox.on(FILTER_BOX_EVNT_VALCHANGE, () => doApplyFilterToSelEntity());
|
||||
doApplyFilterToSelEntity();
|
||||
|
||||
const $btnFilterForEntity = $(`<button class="btn btn-xs btn-default br-0 pr-2" title="Filter for ${this._title}"><span class="glyphicon glyphicon-filter"></span> Filter</button>`)
|
||||
|
||||
@@ -10,7 +10,7 @@ class _GroupHeaderManager {
|
||||
this._$btnHeader = $$`<div class="lst__item-group-header mt-3 split-v-center py-1 no-select clickable" title="SHIFT to Toggle All">
|
||||
<div class="split-v-center w-100 min-w-0 mr-2">
|
||||
<div class="bold">${ent.name}</div>
|
||||
<div class="${Parser.sourceJsonToColor(ent.source)}" title="${Parser.sourceJsonToFull(ent.source).qq()}" ${Parser.sourceJsonToStyle(ent.source)}>${Parser.sourceJsonToAbv(ent.source)}</div>
|
||||
<div class="${Parser.sourceJsonToSourceClassname(ent.source)}" title="${Parser.sourceJsonToFull(ent.source).qq()}" ${Parser.sourceJsonToStyle(ent.source)}>${Parser.sourceJsonToAbv(ent.source)}</div>
|
||||
</div>
|
||||
${this._$dispShowHide}
|
||||
</div>`
|
||||
|
||||
@@ -60,8 +60,6 @@ class TablesPage extends ListPage {
|
||||
|
||||
dataProps: ["table", "tableGroup"],
|
||||
|
||||
isMarkdownPopout: true,
|
||||
|
||||
listSyntax: new ListSyntaxTables({fnGetDataList: () => this._dataList}),
|
||||
});
|
||||
}
|
||||
@@ -118,7 +116,7 @@ class TablesPage extends ListPage {
|
||||
|
||||
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
|
||||
<span class="bold ve-col-10 pl-0">${it.name}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
</a>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
|
||||
@@ -58,8 +58,6 @@ class TrapsHazardsPage extends ListPage {
|
||||
|
||||
dataProps: ["trap", "hazard"],
|
||||
|
||||
isMarkdownPopout: true,
|
||||
|
||||
listSyntax: new ListSyntaxTrapsHazards({fnGetDataList: () => this._dataList}),
|
||||
});
|
||||
}
|
||||
@@ -77,7 +75,7 @@ class TrapsHazardsPage extends ListPage {
|
||||
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
|
||||
<span class="ve-col-3 pl-0 ve-text-center">${trapType}</span>
|
||||
<span class="bold ve-col-7">${it.name}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToColor(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
<span class="ve-col-2 ve-text-center ${Parser.sourceJsonToSourceClassname(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
|
||||
</a>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
|
||||
3587
js/utils-brew.js
3587
js/utils-brew.js
File diff suppressed because it is too large
Load Diff
1244
js/utils-brew/utils-brew-base.js
Normal file
1244
js/utils-brew/utils-brew-base.js
Normal file
File diff suppressed because it is too large
Load Diff
2
js/utils-brew/utils-brew-constants.js
Normal file
2
js/utils-brew/utils-brew-constants.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export const SOURCE_UNKNOWN_FULL = "(Unknown)";
|
||||
export const SOURCE_UNKNOWN_ABBREVIATION = "(UNK)";
|
||||
7
js/utils-brew/utils-brew-helpers.js
Normal file
7
js/utils-brew/utils-brew-helpers.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export class BrewUtilShared {
|
||||
/** Prevent any injection shenanigans */
|
||||
static getValidColor (color, {isExtended = false} = {}) {
|
||||
if (isExtended) return color.replace(/[^-a-zA-Z\d]/g, "");
|
||||
return color.replace(/[^a-fA-F\d]/g, "").slice(0, 8);
|
||||
}
|
||||
}
|
||||
263
js/utils-brew/utils-brew-impl-brew.js
Normal file
263
js/utils-brew/utils-brew-impl-brew.js
Normal file
@@ -0,0 +1,263 @@
|
||||
import {BrewUtil2Base} from "./utils-brew-base.js";
|
||||
import {BrewDoc} from "./utils-brew-models.js";
|
||||
|
||||
export class BrewUtil2_ extends BrewUtil2Base {
|
||||
_STORAGE_KEY_LEGACY = "HOMEBREW_STORAGE";
|
||||
_STORAGE_KEY_LEGACY_META = "HOMEBREW_META_STORAGE";
|
||||
|
||||
// Keep these distinct from the OG brew key, so users can recover their old brew if required.
|
||||
_STORAGE_KEY = "HOMEBREW_2_STORAGE";
|
||||
_STORAGE_KEY_META = "HOMEBREW_2_STORAGE_METAS";
|
||||
|
||||
_STORAGE_KEY_CUSTOM_URL = "HOMEBREW_CUSTOM_REPO_URL";
|
||||
_STORAGE_KEY_MIGRATION_VERSION = "HOMEBREW_2_STORAGE_MIGRATION";
|
||||
|
||||
_VERSION = 2;
|
||||
|
||||
_PATH_LOCAL_DIR = "homebrew";
|
||||
_PATH_LOCAL_INDEX = VeCt.JSON_BREW_INDEX;
|
||||
|
||||
IS_EDITABLE = true;
|
||||
PAGE_MANAGE = UrlUtil.PG_MANAGE_BREW;
|
||||
URL_REPO_DEFAULT = VeCt.URL_BREW;
|
||||
URL_REPO_ROOT_DEFAULT = VeCt.URL_ROOT_BREW;
|
||||
DISPLAY_NAME = "homebrew";
|
||||
DISPLAY_NAME_PLURAL = "homebrews";
|
||||
DEFAULT_AUTHOR = "";
|
||||
STYLE_BTN = "btn-info";
|
||||
IS_PREFER_DATE_ADDED = true;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_pInit_doBindDragDrop () {
|
||||
document.body.addEventListener("drop", async evt => {
|
||||
if (EventUtil.isInInput(evt)) return;
|
||||
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
|
||||
const files = evt.dataTransfer?.files;
|
||||
if (!files?.length) return;
|
||||
|
||||
const pFiles = [...files].map((file, i) => {
|
||||
if (!/\.json$/i.test(file.name)) return null;
|
||||
|
||||
return new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(reader.result);
|
||||
} catch (ignored) {
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
resolve({name: file.name, json});
|
||||
};
|
||||
|
||||
reader.readAsText(files[i]);
|
||||
});
|
||||
});
|
||||
|
||||
const fileMetas = (await Promise.allSettled(pFiles))
|
||||
.filter(({status}) => status === "fulfilled")
|
||||
.map(({value}) => value)
|
||||
.filter(Boolean);
|
||||
|
||||
await this.pAddBrewsFromFiles(fileMetas);
|
||||
|
||||
if (this.isReloadRequired()) this.doLocationReload();
|
||||
});
|
||||
|
||||
document.body.addEventListener("dragover", evt => {
|
||||
if (EventUtil.isInInput(evt)) return;
|
||||
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
async pGetSourceIndex (urlRoot) { return DataUtil.brew.pLoadSourceIndex(urlRoot); }
|
||||
|
||||
getFileUrl (path, urlRoot) { return DataUtil.brew.getFileUrl(path, urlRoot); }
|
||||
|
||||
pLoadTimestamps (urlRoot) { return DataUtil.brew.pLoadTimestamps(urlRoot); }
|
||||
|
||||
pLoadPropIndex (urlRoot) { return DataUtil.brew.pLoadPropIndex(urlRoot); }
|
||||
|
||||
pLoadMetaIndex (urlRoot) { return DataUtil.brew.pLoadMetaIndex(urlRoot); }
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
// region Editable
|
||||
async pGetEditableBrewDoc () {
|
||||
return this._findEditableBrewDoc({brewRaw: await this._pGetBrewRaw()});
|
||||
}
|
||||
|
||||
_findEditableBrewDoc ({brewRaw}) {
|
||||
return brewRaw.find(it => it.head.isEditable);
|
||||
}
|
||||
|
||||
async pGetOrCreateEditableBrewDoc () {
|
||||
const existing = await this.pGetEditableBrewDoc();
|
||||
if (existing) return existing;
|
||||
|
||||
const brew = this._getNewEditableBrewDoc();
|
||||
const brews = [...MiscUtil.copyFast(await this._pGetBrewRaw()), brew];
|
||||
await this.pSetBrew(brews);
|
||||
|
||||
return brew;
|
||||
}
|
||||
|
||||
async pSetEditableBrewDoc (brew) {
|
||||
if (!brew?.head?.docIdLocal || !brew?.body) throw new Error(`Invalid editable brew document!`); // Sanity check
|
||||
await this.pUpdateBrew(brew);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param prop
|
||||
* @param uniqueId
|
||||
* @param isDuplicate If the entity should be a duplicate, i.e. have a new `uniqueId`.
|
||||
*/
|
||||
async pGetEditableBrewEntity (prop, uniqueId, {isDuplicate = false} = {}) {
|
||||
if (!uniqueId) throw new Error(`A "uniqueId" must be provided!`);
|
||||
|
||||
const brew = await this.pGetOrCreateEditableBrewDoc();
|
||||
|
||||
const out = (brew.body?.[prop] || []).find(it => it.uniqueId === uniqueId);
|
||||
if (!out || !isDuplicate) return out;
|
||||
|
||||
if (isDuplicate) out.uniqueId = CryptUtil.uid();
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async pPersistEditableBrewEntity (prop, ent) {
|
||||
if (!ent.uniqueId) throw new Error(`Entity did not have a "uniqueId"!`);
|
||||
|
||||
const brew = await this.pGetOrCreateEditableBrewDoc();
|
||||
|
||||
const ixExisting = (brew.body?.[prop] || []).findIndex(it => it.uniqueId === ent.uniqueId);
|
||||
if (!~ixExisting) {
|
||||
const nxt = MiscUtil.copyFast(brew);
|
||||
MiscUtil.getOrSet(nxt.body, prop, []).push(ent);
|
||||
|
||||
await this.pUpdateBrew(nxt);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nxt = MiscUtil.copyFast(brew);
|
||||
nxt.body[prop][ixExisting] = ent;
|
||||
|
||||
await this.pUpdateBrew(nxt);
|
||||
}
|
||||
|
||||
async pRemoveEditableBrewEntity (prop, uniqueId) {
|
||||
if (!uniqueId) throw new Error(`A "uniqueId" must be provided!`);
|
||||
|
||||
const brew = await this.pGetOrCreateEditableBrewDoc();
|
||||
|
||||
if (!brew.body?.[prop]?.length) return;
|
||||
|
||||
const nxt = MiscUtil.copyFast(brew);
|
||||
nxt.body[prop] = nxt.body[prop].filter(it => it.uniqueId !== uniqueId);
|
||||
|
||||
if (nxt.body[prop].length === brew.body[prop]) return; // Silently allow no-op deletes
|
||||
|
||||
await this.pUpdateBrew(nxt);
|
||||
}
|
||||
|
||||
async pAddSource (sourceObj) {
|
||||
const existing = await this.pGetEditableBrewDoc();
|
||||
|
||||
if (existing) {
|
||||
const nxt = MiscUtil.copyFast(existing);
|
||||
const sources = MiscUtil.getOrSet(nxt.body, "_meta", "sources", []);
|
||||
sources.push(sourceObj);
|
||||
|
||||
await this.pUpdateBrew(nxt);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const json = {_meta: {sources: [sourceObj]}};
|
||||
const brew = this._getBrewDoc({json, isEditable: true});
|
||||
const brews = [...MiscUtil.copyFast(await this._pGetBrewRaw()), brew];
|
||||
await this.pSetBrew(brews);
|
||||
}
|
||||
|
||||
async pEditSource (sourceObj) {
|
||||
const existing = await this.pGetEditableBrewDoc();
|
||||
if (!existing) throw new Error(`Editable brew document does not exist!`);
|
||||
|
||||
const nxt = MiscUtil.copyFast(existing);
|
||||
const sources = MiscUtil.get(nxt.body, "_meta", "sources");
|
||||
if (!sources) throw new Error(`Source "${sourceObj.json}" does not exist in editable brew document!`);
|
||||
|
||||
const existingSourceObj = sources.find(it => it.json === sourceObj.json);
|
||||
if (!existingSourceObj) throw new Error(`Source "${sourceObj.json}" does not exist in editable brew document!`);
|
||||
Object.assign(existingSourceObj, sourceObj);
|
||||
|
||||
await this.pUpdateBrew(nxt);
|
||||
}
|
||||
|
||||
async pIsEditableSourceJson (sourceJson) {
|
||||
const brew = await this.pGetEditableBrewDoc();
|
||||
if (!brew) return false;
|
||||
|
||||
const sources = MiscUtil.get(brew.body, "_meta", "sources") || [];
|
||||
return sources.some(it => it.json === sourceJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the brews containing a given source to the editable document. If a brew cannot be moved to the editable
|
||||
* document, copy the source to the editable document instead.
|
||||
*/
|
||||
async pMoveOrCopyToEditableBySourceJson (sourceJson) {
|
||||
if (await this.pIsEditableSourceJson(sourceJson)) return;
|
||||
|
||||
// Fetch all candidate brews
|
||||
const brews = (await this._pGetBrewRaw()).filter(brew => (brew.body._meta?.sources || []).some(src => src.json === sourceJson));
|
||||
const brewsLocal = (await this._pGetBrew_pGetLocalBrew()).filter(brew => (brew.body._meta?.sources || []).some(src => src.json === sourceJson));
|
||||
|
||||
// Arbitrarily select one, preferring non-local
|
||||
let brew = brews.find(brew => BrewDoc.isOperationPermitted_moveToEditable({brew}));
|
||||
if (!brew) brew = brewsLocal.find(brew => BrewDoc.isOperationPermitted_moveToEditable({brew, isAllowLocal: true}));
|
||||
|
||||
if (!brew) return;
|
||||
|
||||
if (brew.head.isLocal) return this.pCopyToEditable({brews: [brew]});
|
||||
|
||||
return this.pMoveToEditable({brews: [brew]});
|
||||
}
|
||||
|
||||
async pMoveToEditable ({brews}) {
|
||||
const out = await this.pCopyToEditable({brews});
|
||||
await this.pDeleteBrews(brews);
|
||||
return out;
|
||||
}
|
||||
|
||||
async pCopyToEditable ({brews}) {
|
||||
const brewEditable = await this.pGetOrCreateEditableBrewDoc();
|
||||
|
||||
const cpyBrewEditableDoc = BrewDoc.fromObject(brewEditable, {isCopy: true});
|
||||
brews.forEach((brew, i) => cpyBrewEditableDoc.mutMerge({json: brew.body, isLazy: i !== brews.length - 1}));
|
||||
|
||||
await this.pSetEditableBrewDoc(cpyBrewEditableDoc.toObject());
|
||||
|
||||
return cpyBrewEditableDoc;
|
||||
}
|
||||
|
||||
async pHasEditableSourceJson () {
|
||||
const brewsStored = await this._pGetBrewRaw();
|
||||
if (!brewsStored?.length) return false;
|
||||
|
||||
return brewsStored
|
||||
.map(brew => BrewDoc.fromObject(brew))
|
||||
.some(brew => brew.head.isEditable && !brew.isEmpty());
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
63
js/utils-brew/utils-brew-impl-prerelease.js
Normal file
63
js/utils-brew/utils-brew-impl-prerelease.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import {BrewUtil2Base} from "./utils-brew-base.js";
|
||||
|
||||
export class PrereleaseUtil_ extends BrewUtil2Base {
|
||||
_STORAGE_KEY_LEGACY = null;
|
||||
_STORAGE_KEY_LEGACY_META = null;
|
||||
|
||||
_STORAGE_KEY = "PRERELEASE_STORAGE";
|
||||
_STORAGE_KEY_META = "PRERELEASE_META_STORAGE";
|
||||
|
||||
_STORAGE_KEY_CUSTOM_URL = "PRERELEASE_CUSTOM_REPO_URL";
|
||||
_STORAGE_KEY_MIGRATION_VERSION = "PRERELEASE_STORAGE_MIGRATION";
|
||||
|
||||
_PATH_LOCAL_DIR = "prerelease";
|
||||
_PATH_LOCAL_INDEX = VeCt.JSON_PRERELEASE_INDEX;
|
||||
|
||||
_VERSION = 1;
|
||||
|
||||
IS_EDITABLE = false;
|
||||
PAGE_MANAGE = UrlUtil.PG_MANAGE_PRERELEASE;
|
||||
URL_REPO_DEFAULT = VeCt.URL_PRERELEASE;
|
||||
URL_REPO_ROOT_DEFAULT = VeCt.URL_ROOT_PRERELEASE;
|
||||
DISPLAY_NAME = "prerelease content";
|
||||
DISPLAY_NAME_PLURAL = "prereleases";
|
||||
DEFAULT_AUTHOR = "Wizards of the Coast";
|
||||
STYLE_BTN = "btn-primary";
|
||||
IS_PREFER_DATE_ADDED = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_pInit_doBindDragDrop () { /* No-op */ }
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
async pGetSourceIndex (urlRoot) { return DataUtil.prerelease.pLoadSourceIndex(urlRoot); }
|
||||
|
||||
getFileUrl (path, urlRoot) { return DataUtil.prerelease.getFileUrl(path, urlRoot); }
|
||||
|
||||
pLoadTimestamps (urlRoot) { return DataUtil.prerelease.pLoadTimestamps(urlRoot); }
|
||||
|
||||
pLoadPropIndex (urlRoot) { return DataUtil.prerelease.pLoadPropIndex(urlRoot); }
|
||||
|
||||
pLoadMetaIndex (urlRoot) { return DataUtil.prerelease.pLoadMetaIndex(urlRoot); }
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
// region Editable
|
||||
|
||||
pGetEditableBrewDoc (brew) { return super.pGetEditableBrewDoc(brew); }
|
||||
pGetOrCreateEditableBrewDoc () { return super.pGetOrCreateEditableBrewDoc(); }
|
||||
pSetEditableBrewDoc () { return super.pSetEditableBrewDoc(); }
|
||||
pGetEditableBrewEntity (prop, uniqueId, {isDuplicate = false} = {}) { return super.pGetEditableBrewEntity(prop, uniqueId, {isDuplicate}); }
|
||||
pPersistEditableBrewEntity (prop, ent) { return super.pPersistEditableBrewEntity(prop, ent); }
|
||||
pRemoveEditableBrewEntity (prop, uniqueId) { return super.pRemoveEditableBrewEntity(prop, uniqueId); }
|
||||
pAddSource (sourceObj) { return super.pAddSource(sourceObj); }
|
||||
pEditSource (sourceObj) { return super.pEditSource(sourceObj); }
|
||||
pIsEditableSourceJson (sourceJson) { return super.pIsEditableSourceJson(sourceJson); }
|
||||
pMoveOrCopyToEditableBySourceJson (sourceJson) { return super.pMoveOrCopyToEditableBySourceJson(sourceJson); }
|
||||
pMoveToEditable ({brews}) { return super.pMoveToEditable({brews}); }
|
||||
pCopyToEditable ({brews}) { return super.pCopyToEditable({brews}); }
|
||||
async pHasEditableSourceJson () { return false; }
|
||||
|
||||
// endregion
|
||||
}
|
||||
261
js/utils-brew/utils-brew-models.js
Normal file
261
js/utils-brew/utils-brew-models.js
Normal file
@@ -0,0 +1,261 @@
|
||||
export class BrewDoc {
|
||||
// Things which are stored in "_meta", but are "content metadata" rather than "file metadata."
|
||||
static _META_KEYS_CONTENT_METADATA__OBJECT = [
|
||||
"skills",
|
||||
"senses",
|
||||
"spellSchools",
|
||||
"spellDistanceUnits",
|
||||
"optionalFeatureTypes",
|
||||
"psionicTypes",
|
||||
"currencyConversions",
|
||||
];
|
||||
|
||||
constructor (opts) {
|
||||
opts = opts || {};
|
||||
this.head = opts.head;
|
||||
this.body = opts.body;
|
||||
}
|
||||
|
||||
toObject () { return MiscUtil.copyFast({...this}); }
|
||||
|
||||
static fromValues ({head, body}) {
|
||||
return new this({
|
||||
head: _BrewDocHead.fromValues(head),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
static fromObject (obj, opts = {}) {
|
||||
const {isCopy = false} = opts;
|
||||
return new this({
|
||||
head: _BrewDocHead.fromObject(obj.head, opts),
|
||||
body: isCopy ? MiscUtil.copyFast(obj.body) : obj.body,
|
||||
});
|
||||
}
|
||||
|
||||
mutUpdate ({json}) {
|
||||
this.body = json;
|
||||
this.head.mutUpdate({json, body: this.body});
|
||||
return this;
|
||||
}
|
||||
|
||||
isEmpty () {
|
||||
if (
|
||||
Object.entries(this.body)
|
||||
.some(([k, v]) => {
|
||||
if (!(v instanceof Array)) return false;
|
||||
if (k === "_meta" || k === "_test") return false;
|
||||
return !!v.length;
|
||||
})
|
||||
) return false;
|
||||
|
||||
if (!this.body._meta) return false;
|
||||
|
||||
if (
|
||||
this.constructor._META_KEYS_CONTENT_METADATA__OBJECT
|
||||
.some(k => !!Object.keys(this.body._meta[k] || {}).length)
|
||||
) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// region Conditions
|
||||
static isOperationPermitted_moveToEditable ({brew, isAllowLocal = false} = {}) {
|
||||
return !brew.head.isEditable
|
||||
&& (isAllowLocal || !brew.head.isLocal);
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Merging
|
||||
mutMerge ({json, isLazy = false}) {
|
||||
this.body = this.constructor.mergeObjects({isCopy: !isLazy, isMutMakeCompatible: false}, this.body, json);
|
||||
this.head.mutMerge({json, body: this.body, isLazy});
|
||||
return this;
|
||||
}
|
||||
|
||||
static mergeObjects ({isCopy = true, isMutMakeCompatible = true} = {}, ...jsons) {
|
||||
const out = {};
|
||||
|
||||
jsons.forEach(json => {
|
||||
json = isCopy ? MiscUtil.copyFast(json) : json;
|
||||
|
||||
if (isMutMakeCompatible) this._mergeObjects_mutMakeCompatible(json);
|
||||
|
||||
Object.entries(json)
|
||||
.forEach(([prop, val]) => {
|
||||
switch (prop) {
|
||||
case "_meta": return this._mergeObjects_key__meta({out, prop, val});
|
||||
case "_test": return; // ignore; used for static testing
|
||||
default: return this._mergeObjects_default({out, prop, val});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
static _META_KEYS_MERGEABLE_OBJECTS = [
|
||||
...this._META_KEYS_CONTENT_METADATA__OBJECT,
|
||||
];
|
||||
|
||||
static _META_KEYS_MERGEABLE_SPECIAL = {
|
||||
"dateAdded": (a, b) => a != null && b != null ? Math.min(a, b) : a ?? b,
|
||||
"dateLastModified": (a, b) => a != null && b != null ? Math.max(a, b) : a ?? b,
|
||||
|
||||
"dependencies": (a, b) => this._metaMerge_dependenciesIncludes(a, b),
|
||||
"includes": (a, b) => this._metaMerge_dependenciesIncludes(a, b),
|
||||
"internalCopies": (a, b) => [...(a || []), ...(b || [])].unique(),
|
||||
|
||||
"otherSources": (a, b) => this._metaMerge_otherSources(a, b),
|
||||
|
||||
"status": (a, b) => this._metaMerge_status(a, b),
|
||||
};
|
||||
|
||||
static _metaMerge_dependenciesIncludes (a, b) {
|
||||
if (a != null && b != null) {
|
||||
Object.entries(b)
|
||||
.forEach(([prop, arr]) => a[prop] = [...(a[prop] || []), ...arr].unique());
|
||||
return a;
|
||||
}
|
||||
|
||||
return a ?? b;
|
||||
}
|
||||
|
||||
static _metaMerge_otherSources (a, b) {
|
||||
if (a != null && b != null) {
|
||||
// Note that this can clobber the values in the mapping, but we don't really care since they're not used.
|
||||
Object.entries(b)
|
||||
.forEach(([prop, obj]) => a[prop] = Object.assign(a[prop] || {}, obj));
|
||||
return a;
|
||||
}
|
||||
|
||||
return a ?? b;
|
||||
}
|
||||
|
||||
static _META_MERGE__STATUS_PRECEDENCE = [
|
||||
"invalid",
|
||||
"deprecated",
|
||||
"wip",
|
||||
"ready",
|
||||
];
|
||||
|
||||
static _metaMerge_status (a, b) {
|
||||
return [a || "ready", b || "ready"]
|
||||
.sort((a, b) => this._META_MERGE__STATUS_PRECEDENCE.indexOf(a) - this._META_MERGE__STATUS_PRECEDENCE.indexOf(b))[0];
|
||||
}
|
||||
|
||||
static _mergeObjects_key__meta ({out, val}) {
|
||||
out._meta = out._meta || {};
|
||||
|
||||
out._meta.sources = [...(out._meta.sources || []), ...(val.sources || [])];
|
||||
|
||||
Object.entries(val)
|
||||
.forEach(([metaProp, metaVal]) => {
|
||||
if (this._META_KEYS_MERGEABLE_SPECIAL[metaProp]) {
|
||||
out._meta[metaProp] = this._META_KEYS_MERGEABLE_SPECIAL[metaProp](out._meta[metaProp], metaVal);
|
||||
return;
|
||||
}
|
||||
if (!this._META_KEYS_MERGEABLE_OBJECTS.includes(metaProp)) return;
|
||||
Object.assign(out._meta[metaProp] = out._meta[metaProp] || {}, metaVal);
|
||||
});
|
||||
}
|
||||
|
||||
static _mergeObjects_default ({out, prop, val}) {
|
||||
// If we cannot merge a prop, use the first value found for it, as a best-effort fallback
|
||||
if (!(val instanceof Array)) return out[prop] === undefined ? out[prop] = val : null;
|
||||
|
||||
out[prop] = [...out[prop] || [], ...val];
|
||||
}
|
||||
|
||||
static _mergeObjects_mutMakeCompatible (json) {
|
||||
// region Item
|
||||
if (json.variant) {
|
||||
// 2022-07-09
|
||||
json.magicvariant = json.variant;
|
||||
delete json.variant;
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Race
|
||||
if (json.subrace) {
|
||||
json.subrace.forEach(sr => {
|
||||
if (!sr.race) return;
|
||||
sr.raceName = sr.race.name;
|
||||
sr.raceSource = sr.race.source || sr.source || Parser.SRC_PHB;
|
||||
});
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Creature (monster)
|
||||
if (json.monster) {
|
||||
json.monster.forEach(mon => {
|
||||
// 2022-03-22
|
||||
if (typeof mon.size === "string") mon.size = [mon.size];
|
||||
|
||||
// 2022=05-29
|
||||
if (mon.summonedBySpell && !mon.summonedBySpellLevel) mon.summonedBySpellLevel = 1;
|
||||
});
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Object
|
||||
if (json.object) {
|
||||
json.object.forEach(obj => {
|
||||
// 2023-10-07
|
||||
if (typeof obj.size === "string") obj.size = [obj.size];
|
||||
});
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
||||
class _BrewDocHead {
|
||||
constructor (opts) {
|
||||
opts = opts || {};
|
||||
|
||||
this.docIdLocal = opts.docIdLocal;
|
||||
this.timeAdded = opts.timeAdded;
|
||||
this.checksum = opts.checksum;
|
||||
this.url = opts.url;
|
||||
this.filename = opts.filename;
|
||||
this.isLocal = opts.isLocal;
|
||||
this.isEditable = opts.isEditable;
|
||||
}
|
||||
|
||||
toObject () { return MiscUtil.copyFast({...this}); }
|
||||
|
||||
static fromValues (
|
||||
{
|
||||
json,
|
||||
url = null,
|
||||
filename = null,
|
||||
isLocal = false,
|
||||
isEditable = false,
|
||||
},
|
||||
) {
|
||||
return new this({
|
||||
docIdLocal: CryptUtil.uid(),
|
||||
timeAdded: Date.now(),
|
||||
checksum: CryptUtil.md5(JSON.stringify(json)),
|
||||
url: url,
|
||||
filename: filename,
|
||||
isLocal: isLocal,
|
||||
isEditable: isEditable,
|
||||
});
|
||||
}
|
||||
|
||||
static fromObject (obj, {isCopy = false} = {}) {
|
||||
return new this(isCopy ? MiscUtil.copyFast(obj) : obj);
|
||||
}
|
||||
|
||||
mutUpdate ({json}) {
|
||||
this.checksum = CryptUtil.md5(JSON.stringify(json));
|
||||
return this;
|
||||
}
|
||||
|
||||
mutMerge ({json, body, isLazy}) {
|
||||
if (!isLazy) this.checksum = CryptUtil.md5(JSON.stringify(body ?? json));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
488
js/utils-brew/utils-brew-ui-get.js
Normal file
488
js/utils-brew/utils-brew-ui-get.js
Normal file
@@ -0,0 +1,488 @@
|
||||
import {EVNT_VALCHANGE} from "../filter/filter-constants.js";
|
||||
|
||||
export class GetBrewUi {
|
||||
static _RenderState = class {
|
||||
constructor () {
|
||||
this.pageFilter = null;
|
||||
this.list = null;
|
||||
this.listSelectClickHandler = null;
|
||||
this.cbAll = null;
|
||||
}
|
||||
};
|
||||
|
||||
static _TypeFilter = class extends Filter {
|
||||
constructor ({brewUtil}) {
|
||||
const pageProps = brewUtil.getPageProps({fallback: ["*"]});
|
||||
super({
|
||||
header: "Category",
|
||||
items: [],
|
||||
displayFn: brewUtil.getPropDisplayName.bind(brewUtil),
|
||||
selFn: prop => pageProps.includes("*") || pageProps.includes(prop),
|
||||
isSortByDisplayItems: true,
|
||||
});
|
||||
this._brewUtil = brewUtil;
|
||||
}
|
||||
|
||||
_getHeaderControls_addExtraStateBtns (opts, wrpStateBtnsOuter) {
|
||||
const menu = ContextUtil.getMenu(
|
||||
this._brewUtil.getPropPages()
|
||||
.map(page => ({page, displayPage: UrlUtil.pageToDisplayPage(page)}))
|
||||
.sort(SortUtil.ascSortProp.bind(SortUtil, "displayPage"))
|
||||
.map(({page, displayPage}) => {
|
||||
return new ContextUtil.Action(
|
||||
displayPage,
|
||||
() => {
|
||||
const propsActive = new Set(this._brewUtil.getPageProps({page, fallback: []}));
|
||||
Object.keys(this._state).forEach(prop => this._state[prop] = propsActive.has(prop) ? 1 : 0);
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const btnPage = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default w-100 btn-xs`,
|
||||
text: `Select for Page...`,
|
||||
click: evt => ContextUtil.pOpenMenu(evt, menu),
|
||||
});
|
||||
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `btn-group mr-2 w-100 ve-flex-v-center`,
|
||||
children: [
|
||||
btnPage,
|
||||
],
|
||||
}).prependTo(wrpStateBtnsOuter);
|
||||
}
|
||||
};
|
||||
|
||||
static _PageFilterGetBrew = class extends PageFilterBase {
|
||||
static _STATUS_FILTER_DEFAULT_DESELECTED = new Set(["wip", "deprecated", "invalid"]);
|
||||
|
||||
constructor ({brewUtil}) {
|
||||
super();
|
||||
|
||||
this._brewUtil = brewUtil;
|
||||
|
||||
this._typeFilter = new GetBrewUi._TypeFilter({brewUtil});
|
||||
this._statusFilter = new Filter({
|
||||
header: "Status",
|
||||
items: [
|
||||
"ready",
|
||||
"wip",
|
||||
"deprecated",
|
||||
"invalid",
|
||||
],
|
||||
displayFn: StrUtil.toTitleCase,
|
||||
itemSortFn: null,
|
||||
deselFn: it => this.constructor._STATUS_FILTER_DEFAULT_DESELECTED.has(it),
|
||||
});
|
||||
this._miscFilter = new Filter({
|
||||
header: "Miscellaneous",
|
||||
items: ["Sample"],
|
||||
deselFn: it => it === "Sample",
|
||||
});
|
||||
}
|
||||
|
||||
static mutateForFilters (brewInfo) {
|
||||
brewInfo._fMisc = [];
|
||||
if (brewInfo._brewAuthor && brewInfo._brewAuthor.toLowerCase().startsWith("sample -")) brewInfo._fMisc.push("Sample");
|
||||
if (brewInfo.sources?.some(ab => ab.startsWith(Parser.SRC_UA_ONE_PREFIX))) brewInfo._fMisc.push("One D&D");
|
||||
}
|
||||
|
||||
addToFilters (it, isExcluded) {
|
||||
if (isExcluded) return;
|
||||
|
||||
this._typeFilter.addItem(it.props);
|
||||
this._miscFilter.addItem(it._fMisc);
|
||||
}
|
||||
|
||||
async _pPopulateBoxOptions (opts) {
|
||||
opts.filters = [
|
||||
this._typeFilter,
|
||||
this._statusFilter,
|
||||
this._miscFilter,
|
||||
];
|
||||
}
|
||||
|
||||
toDisplay (values, it) {
|
||||
return this._filterBox.toDisplay(
|
||||
values,
|
||||
it.props,
|
||||
it._brewStatus,
|
||||
it._fMisc,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
static async pDoGetBrew ({brewUtil, isModal: isParentModal = false} = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ui = new this({brewUtil, isModal: true});
|
||||
const rdState = new this._RenderState();
|
||||
const {$modalInner} = UiUtil.getShowModal({
|
||||
isHeight100: true,
|
||||
title: `Get ${brewUtil.DISPLAY_NAME.toTitleCase()}`,
|
||||
isUncappedHeight: true,
|
||||
isWidth100: true,
|
||||
overlayColor: isParentModal ? "transparent" : undefined,
|
||||
isHeaderBorder: true,
|
||||
cbClose: async () => {
|
||||
await ui.pHandlePreCloseModal({rdState});
|
||||
resolve([...ui._brewsLoaded]);
|
||||
},
|
||||
});
|
||||
ui.pInit()
|
||||
.then(() => ui.pRender($modalInner, {rdState}))
|
||||
.catch(e => reject(e));
|
||||
});
|
||||
}
|
||||
|
||||
_sortUrlList (a, b, o) {
|
||||
a = this._dataList[a.ix];
|
||||
b = this._dataList[b.ix];
|
||||
|
||||
switch (o.sortBy) {
|
||||
case "name": return this.constructor._sortUrlList_byName(a, b);
|
||||
case "author": return this.constructor._sortUrlList_orFallback(a, b, SortUtil.ascSortLower, "_brewAuthor");
|
||||
case "category": return this.constructor._sortUrlList_orFallback(a, b, SortUtil.ascSortLower, "_brewPropDisplayName");
|
||||
case "added": return this.constructor._sortUrlList_orFallback(a, b, SortUtil.ascSort, "_brewAdded");
|
||||
case "modified": return this.constructor._sortUrlList_orFallback(a, b, SortUtil.ascSort, "_brewModified");
|
||||
case "published": return this.constructor._sortUrlList_orFallback(a, b, SortUtil.ascSort, "_brewPublished");
|
||||
default: throw new Error(`No sort order defined for property "${o.sortBy}"`);
|
||||
}
|
||||
}
|
||||
|
||||
static _sortUrlList_byName (a, b) { return SortUtil.ascSortLower(a._brewName, b._brewName); }
|
||||
static _sortUrlList_orFallback (a, b, fn, prop) { return fn(a[prop], b[prop]) || this._sortUrlList_byName(a, b); }
|
||||
|
||||
constructor ({brewUtil, isModal} = {}) {
|
||||
this._brewUtil = brewUtil;
|
||||
this._isModal = isModal;
|
||||
|
||||
this._dataList = null;
|
||||
|
||||
this._brewsLoaded = []; // Track the brews we load during our lifetime
|
||||
}
|
||||
|
||||
async pInit () {
|
||||
this._dataList = await this._brewUtil.pGetCombinedIndexes();
|
||||
}
|
||||
|
||||
async pHandlePreCloseModal ({rdState}) {
|
||||
// region If the user has selected list items, prompt to load them before closing the modal
|
||||
const cntSel = rdState.list.items.filter(it => it.data.cbSel.checked).length;
|
||||
if (!cntSel) return;
|
||||
|
||||
const isSave = await InputUiUtil.pGetUserBoolean({
|
||||
title: `Selected ${this._brewUtil.DISPLAY_NAME}`,
|
||||
htmlDescription: `You have ${cntSel} ${cntSel === 1 ? this._brewUtil.DISPLAY_NAME : this._brewUtil.DISPLAY_NAME_PLURAL} selected which ${cntSel === 1 ? "is" : "are"} not yet loaded. Would you like to load ${cntSel === 1 ? "it" : "them"}?`,
|
||||
textYes: "Load",
|
||||
textNo: "Discard",
|
||||
});
|
||||
if (!isSave) return;
|
||||
|
||||
await this._pHandleClick_btnAddSelected({rdState});
|
||||
// endregion
|
||||
}
|
||||
|
||||
async pRender ($wrp, {rdState} = {}) {
|
||||
rdState = rdState || new this.constructor._RenderState();
|
||||
|
||||
rdState.pageFilter = new this.constructor._PageFilterGetBrew({brewUtil: this._brewUtil});
|
||||
|
||||
const $btnAddSelected = $(`<button class="btn ${this._brewUtil.STYLE_BTN} btn-sm ve-col-0-5 ve-text-center" disabled title="Add Selected"><span class="glyphicon glyphicon-save"></button>`);
|
||||
|
||||
const $wrpRows = $$`<div class="list smooth-scroll max-h-unset"><div class="lst__row ve-flex-col"><div class="lst__wrp-cells lst--border lst__row-inner ve-flex w-100"><i>Loading...</i></div></div></div>`;
|
||||
|
||||
const $btnFilter = $(`<button class="btn btn-default btn-sm">Filter</button>`);
|
||||
|
||||
const $btnToggleSummaryHidden = $(`<button class="btn btn-default" title="Toggle Filter Summary Display"><span class="glyphicon glyphicon-resize-small"></span></button>`);
|
||||
|
||||
const $iptSearch = $(`<input type="search" class="search manbrew__search form-control w-100 lst__search lst__search--no-border-h" placeholder="Find ${this._brewUtil.DISPLAY_NAME}...">`)
|
||||
.keydown(evt => this._pHandleKeydown_iptSearch(evt, rdState));
|
||||
const $dispCntVisible = $(`<div class="lst__wrp-search-visible no-events ve-flex-vh-center"></div>`);
|
||||
|
||||
rdState.cbAll = e_({
|
||||
tag: "input",
|
||||
type: "checkbox",
|
||||
});
|
||||
|
||||
const $btnReset = $(`<button class="btn btn-default btn-sm">Reset</button>`);
|
||||
|
||||
const $wrpMiniPills = $(`<div class="fltr__mini-view btn-group"></div>`);
|
||||
|
||||
const btnSortAddedPublished = this._brewUtil.IS_PREFER_DATE_ADDED
|
||||
? `<button class="ve-col-1-4 sort btn btn-default btn-xs" data-sort="added">Added</button>`
|
||||
: `<button class="ve-col-1-4 sort btn btn-default btn-xs" data-sort="published">Published</button>`;
|
||||
|
||||
const $wrpSort = $$`<div class="filtertools manbrew__filtertools btn-group input-group input-group--bottom ve-flex no-shrink">
|
||||
<label class="ve-col-0-5 pr-0 btn btn-default btn-xs ve-flex-vh-center">${rdState.cbAll}</label>
|
||||
<button class="ve-col-3-5 sort btn btn-default btn-xs" data-sort="name">Name</button>
|
||||
<button class="ve-col-3 sort btn btn-default btn-xs" data-sort="author">Author</button>
|
||||
<button class="ve-col-1-2 sort btn btn-default btn-xs" data-sort="category">Category</button>
|
||||
<button class="ve-col-1-4 sort btn btn-default btn-xs" data-sort="modified">Modified</button>
|
||||
${btnSortAddedPublished}
|
||||
<button class="sort btn btn-default btn-xs ve-grow" disabled>Source</button>
|
||||
</div>`;
|
||||
|
||||
$$($wrp)`
|
||||
<div class="mt-1"><i>A list of ${this._brewUtil.DISPLAY_NAME} available in the public repository. Click a name to load the ${this._brewUtil.DISPLAY_NAME}, or view the source directly.${this._brewUtil.IS_EDITABLE ? `<br>
|
||||
Contributions are welcome; see the <a href="${this._brewUtil.URL_REPO_DEFAULT}/blob/master/README.md" target="_blank" rel="noopener noreferrer">README</a>, or stop by our <a href="https://discord.gg/5etools" target="_blank" rel="noopener noreferrer">Discord</a>.` : ""}</i></div>
|
||||
<hr class="hr-3">
|
||||
<div class="lst__form-top">
|
||||
${$btnAddSelected}
|
||||
${$btnFilter}
|
||||
${$btnToggleSummaryHidden}
|
||||
<div class="w-100 relative">
|
||||
${$iptSearch}
|
||||
<div id="lst__search-glass" class="lst__wrp-search-glass no-events ve-flex-vh-center"><span class="glyphicon glyphicon-search"></span></div>
|
||||
${$dispCntVisible}
|
||||
</div>
|
||||
${$btnReset}
|
||||
</div>
|
||||
${$wrpMiniPills}
|
||||
${$wrpSort}
|
||||
${$wrpRows}`;
|
||||
|
||||
rdState.list = new List({
|
||||
$iptSearch,
|
||||
$wrpList: $wrpRows,
|
||||
fnSort: this._sortUrlList.bind(this),
|
||||
isUseJquery: true,
|
||||
isFuzzy: true,
|
||||
isSkipSearchKeybindingEnter: true,
|
||||
});
|
||||
|
||||
rdState.list.on("updated", () => $dispCntVisible.html(`${rdState.list.visibleItems.length}/${rdState.list.items.length}`));
|
||||
|
||||
rdState.listSelectClickHandler = new ListSelectClickHandler({list: rdState.list});
|
||||
rdState.listSelectClickHandler.bindSelectAllCheckbox($(rdState.cbAll));
|
||||
SortUtil.initBtnSortHandlers($wrpSort, rdState.list);
|
||||
|
||||
this._dataList.forEach((brewInfo, ix) => {
|
||||
const {listItem} = this._pRender_getUrlRowMeta(rdState, brewInfo, ix);
|
||||
rdState.list.addItem(listItem);
|
||||
});
|
||||
|
||||
await rdState.pageFilter.pInitFilterBox({
|
||||
$iptSearch: $iptSearch,
|
||||
$btnReset: $btnReset,
|
||||
$btnOpen: $btnFilter,
|
||||
$btnToggleSummaryHidden,
|
||||
$wrpMiniPills,
|
||||
namespace: `get-homebrew-${UrlUtil.getCurrentPage()}`,
|
||||
});
|
||||
|
||||
this._dataList.forEach(it => rdState.pageFilter.mutateAndAddToFilters(it));
|
||||
|
||||
rdState.list.init();
|
||||
|
||||
rdState.pageFilter.trimState();
|
||||
rdState.pageFilter.filterBox.render();
|
||||
|
||||
rdState.pageFilter.filterBox.on(
|
||||
EVNT_VALCHANGE,
|
||||
this._handleFilterChange.bind(this, rdState),
|
||||
);
|
||||
|
||||
this._handleFilterChange(rdState);
|
||||
|
||||
$btnAddSelected
|
||||
.prop("disabled", false)
|
||||
.click(() => this._pHandleClick_btnAddSelected({rdState}));
|
||||
|
||||
$iptSearch.focus();
|
||||
}
|
||||
|
||||
_handleFilterChange (rdState) {
|
||||
const f = rdState.pageFilter.filterBox.getValues();
|
||||
rdState.list.filter(li => rdState.pageFilter.toDisplay(f, this._dataList[li.ix]));
|
||||
}
|
||||
|
||||
_pRender_getUrlRowMeta (rdState, brewInfo, ix) {
|
||||
const epochAddedPublished = this._brewUtil.IS_PREFER_DATE_ADDED ? brewInfo._brewAdded : brewInfo._brewPublished;
|
||||
const timestampAddedPublished = epochAddedPublished
|
||||
? DatetimeUtil.getDateStr({date: new Date(epochAddedPublished * 1000), isShort: true, isPad: true})
|
||||
: "";
|
||||
const timestampModified = brewInfo._brewModified
|
||||
? DatetimeUtil.getDateStr({date: new Date(brewInfo._brewModified * 1000), isShort: true, isPad: true})
|
||||
: "";
|
||||
|
||||
const cbSel = e_({
|
||||
tag: "input",
|
||||
clazz: "no-events",
|
||||
type: "checkbox",
|
||||
});
|
||||
|
||||
const btnAdd = e_({
|
||||
tag: "span",
|
||||
clazz: `ve-col-3-5 bold manbrew__load_from_url pl-0 clickable`,
|
||||
text: brewInfo._brewName,
|
||||
click: evt => this._pHandleClick_btnGetRemote({evt, btn: btnAdd, url: brewInfo.urlDownload}),
|
||||
});
|
||||
|
||||
const eleLi = e_({
|
||||
tag: "div",
|
||||
clazz: `lst__row lst__row-inner not-clickable lst--border lst__row--focusable no-select`,
|
||||
children: [
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `lst__wrp-cells ve-flex w-100`,
|
||||
children: [
|
||||
e_({
|
||||
tag: "label",
|
||||
clazz: `ve-col-0-5 ve-flex-vh-center ve-self-flex-stretch`,
|
||||
children: [cbSel],
|
||||
}),
|
||||
btnAdd,
|
||||
e_({tag: "span", clazz: "ve-col-3", text: brewInfo._brewAuthor}),
|
||||
e_({tag: "span", clazz: "ve-col-1-2 ve-text-center mobile__text-clip-ellipsis", text: brewInfo._brewPropDisplayName, title: brewInfo._brewPropDisplayName}),
|
||||
e_({tag: "span", clazz: "ve-col-1-4 ve-text-center code", text: timestampModified}),
|
||||
e_({tag: "span", clazz: "ve-col-1-4 ve-text-center code", text: timestampAddedPublished}),
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: "ve-col-1 manbrew__source ve-text-center pr-0",
|
||||
children: [
|
||||
e_({
|
||||
tag: "a",
|
||||
text: `View Raw`,
|
||||
})
|
||||
.attr("href", brewInfo.urlDownload)
|
||||
.attr("target", "_blank")
|
||||
.attr("rel", "noopener noreferrer"),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
keydown: evt => this._pHandleKeydown_row(evt, {rdState, btnAdd, url: brewInfo.urlDownload, listItem}),
|
||||
})
|
||||
.attr("tabindex", ix);
|
||||
|
||||
const listItem = new ListItem(
|
||||
ix,
|
||||
eleLi,
|
||||
brewInfo._brewName,
|
||||
{
|
||||
author: brewInfo._brewAuthor,
|
||||
// category: brewInfo._brewPropDisplayName, // Unwanted in search
|
||||
internalSources: brewInfo._brewInternalSources, // Used for search
|
||||
},
|
||||
{
|
||||
btnAdd,
|
||||
cbSel,
|
||||
pFnDoDownload: ({isLazy = false} = {}) => this._pHandleClick_btnGetRemote({btn: btnAdd, url: brewInfo.urlDownload, isLazy}),
|
||||
},
|
||||
);
|
||||
|
||||
eleLi.addEventListener("click", evt => rdState.listSelectClickHandler.handleSelectClick(listItem, evt, {isPassThroughEvents: true}));
|
||||
|
||||
return {
|
||||
listItem,
|
||||
};
|
||||
}
|
||||
|
||||
async _pHandleKeydown_iptSearch (evt, rdState) {
|
||||
switch (evt.key) {
|
||||
case "Enter": {
|
||||
const firstItem = rdState.list.visibleItems[0];
|
||||
if (!firstItem) return;
|
||||
await firstItem.data.pFnDoDownload();
|
||||
return;
|
||||
}
|
||||
|
||||
case "ArrowDown": {
|
||||
const firstItem = rdState.list.visibleItems[0];
|
||||
if (firstItem) {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
firstItem.ele.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _pHandleClick_btnAddSelected ({rdState}) {
|
||||
const listItems = rdState.list.items.filter(it => it.data.cbSel.checked);
|
||||
|
||||
if (!listItems.length) return JqueryUtil.doToast({type: "warning", content: `Please select some ${this._brewUtil.DISPLAY_NAME_PLURAL} first!`});
|
||||
|
||||
if (listItems.length > 25 && !await InputUiUtil.pGetUserBoolean({title: "Are you sure?", htmlDescription: `<div>You area about to load ${listItems.length} ${this._brewUtil.DISPLAY_NAME} files.<br>Loading large quantities of ${this._brewUtil.DISPLAY_NAME_PLURAL} can lead to performance and stability issues.</div>`, textYes: "Continue"})) return;
|
||||
|
||||
rdState.cbAll.checked = false;
|
||||
rdState.list.items.forEach(item => {
|
||||
item.data.cbSel.checked = false;
|
||||
item.ele.classList.remove("list-multi-selected");
|
||||
});
|
||||
|
||||
await Promise.allSettled(listItems.map(it => it.data.pFnDoDownload({isLazy: true})));
|
||||
const lazyDepsAdded = await this._brewUtil.pAddBrewsLazyFinalize();
|
||||
this._brewsLoaded.push(...lazyDepsAdded);
|
||||
JqueryUtil.doToast(`Finished loading selected ${this._brewUtil.DISPLAY_NAME}!`);
|
||||
}
|
||||
|
||||
async _pHandleClick_btnGetRemote ({evt, btn, url, isLazy}) {
|
||||
if (!(url || "").trim()) return JqueryUtil.doToast({type: "danger", content: `${this._brewUtil.DISPLAY_NAME.uppercaseFirst()} had no download URL!`});
|
||||
|
||||
if (evt) {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
}
|
||||
|
||||
const cachedHtml = btn.html();
|
||||
btn.txt("Loading...").attr("disabled", true);
|
||||
const brewsAdded = await this._brewUtil.pAddBrewFromUrl(url, {isLazy});
|
||||
this._brewsLoaded.push(...brewsAdded);
|
||||
btn.txt("Done!");
|
||||
setTimeout(() => btn.html(cachedHtml).attr("disabled", false), VeCt.DUR_INLINE_NOTIFY);
|
||||
}
|
||||
|
||||
async _pHandleKeydown_row (evt, {rdState, btnAdd, url, listItem}) {
|
||||
switch (evt.key) {
|
||||
case "Enter": return this._pHandleClick_btnGetRemote({evt, btn: btnAdd, url});
|
||||
|
||||
case "ArrowUp": {
|
||||
const ixCur = rdState.list.visibleItems.indexOf(listItem);
|
||||
|
||||
if (~ixCur) {
|
||||
const prevItem = rdState.list.visibleItems[ixCur - 1];
|
||||
if (prevItem) {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
prevItem.ele.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const firstItem = rdState.list.visibleItems[0];
|
||||
if (firstItem) {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
firstItem.ele.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case "ArrowDown": {
|
||||
const ixCur = rdState.list.visibleItems.indexOf(listItem);
|
||||
|
||||
if (~ixCur) {
|
||||
const nxtItem = rdState.list.visibleItems[ixCur + 1];
|
||||
if (nxtItem) {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
nxtItem.ele.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const lastItem = rdState.list.visibleItems.last();
|
||||
if (lastItem) {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
lastItem.ele.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
531
js/utils-brew/utils-brew-ui-manage-editable-contents.js
Normal file
531
js/utils-brew/utils-brew-ui-manage-editable-contents.js
Normal file
@@ -0,0 +1,531 @@
|
||||
import {SOURCE_UNKNOWN_ABBREVIATION, SOURCE_UNKNOWN_FULL} from "./utils-brew-constants.js";
|
||||
import {EVNT_VALCHANGE} from "../filter/filter-constants.js";
|
||||
|
||||
export class ManageEditableBrewContentsUi extends BaseComponent {
|
||||
static _RenderState = class {
|
||||
constructor () {
|
||||
this.tabMetaEntities = null;
|
||||
this.tabMetaSources = null;
|
||||
|
||||
this.listEntities = null;
|
||||
this.listEntitiesSelectClickHandler = null;
|
||||
this.listSources = null;
|
||||
this.listSourcesSelectClickHandler = null;
|
||||
|
||||
this.contentEntities = null;
|
||||
this.pageFilterEntities = new ManageEditableBrewContentsUi._PageFilter();
|
||||
}
|
||||
};
|
||||
|
||||
static _PageFilter = class extends PageFilterBase {
|
||||
constructor () {
|
||||
super();
|
||||
this._categoryFilter = new Filter({header: "Category"});
|
||||
}
|
||||
|
||||
static mutateForFilters (meta) {
|
||||
const {ent, prop} = meta;
|
||||
meta._fSource = SourceUtil.getEntitySource(ent);
|
||||
meta._fCategory = ManageEditableBrewContentsUi._getDisplayProp({ent, prop});
|
||||
}
|
||||
|
||||
addToFilters (meta) {
|
||||
this._sourceFilter.addItem(meta._fSource);
|
||||
this._categoryFilter.addItem(meta._fCategory);
|
||||
}
|
||||
|
||||
async _pPopulateBoxOptions (opts) {
|
||||
opts.filters = [
|
||||
this._sourceFilter,
|
||||
this._categoryFilter,
|
||||
];
|
||||
}
|
||||
|
||||
toDisplay (values, meta) {
|
||||
return this._filterBox.toDisplay(
|
||||
values,
|
||||
meta._fSource,
|
||||
meta._fCategory,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
static async pDoOpen ({brewUtil, brew, isModal: isParentModal = false}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ui = new this({brewUtil, brew, isModal: true});
|
||||
const rdState = new this._RenderState();
|
||||
const {$modalInner} = UiUtil.getShowModal({
|
||||
isHeight100: true,
|
||||
title: `Manage Document Contents`,
|
||||
isUncappedHeight: true,
|
||||
isWidth100: true,
|
||||
$titleSplit: $$`<div class="ve-flex-v-center btn-group">
|
||||
${ui._$getBtnDeleteSelected({rdState})}
|
||||
</div>`,
|
||||
overlayColor: isParentModal ? "transparent" : undefined,
|
||||
cbClose: () => {
|
||||
resolve(ui._getFormData());
|
||||
rdState.pageFilterEntities.filterBox.teardown();
|
||||
},
|
||||
});
|
||||
ui.pRender($modalInner, {rdState})
|
||||
.catch(e => reject(e));
|
||||
});
|
||||
}
|
||||
|
||||
constructor ({brewUtil, brew, isModal}) {
|
||||
super();
|
||||
|
||||
TabUiUtil.decorate(this, {isInitMeta: true});
|
||||
|
||||
this._brewUtil = brewUtil;
|
||||
this._brew = MiscUtil.copyFast(brew);
|
||||
this._isModal = isModal;
|
||||
|
||||
this._isDirty = false;
|
||||
}
|
||||
|
||||
_getFormData () {
|
||||
return {
|
||||
isDirty: this._isDirty,
|
||||
brew: this._brew,
|
||||
};
|
||||
}
|
||||
|
||||
_$getBtnDeleteSelected ({rdState}) {
|
||||
return $(`<button class="btn btn-danger btn-xs">Delete Selected</button>`)
|
||||
.click(() => this._handleClick_pButtonDeleteSelected({rdState}));
|
||||
}
|
||||
|
||||
async _handleClick_pButtonDeleteSelected ({rdState}) {
|
||||
if (this._getActiveTab() === rdState.tabMetaEntities) return this._handleClick_pButtonDeleteSelected_entities({rdState});
|
||||
if (this._getActiveTab() === rdState.tabMetaSources) return this._handleClick_pButtonDeleteSelected_sources({rdState});
|
||||
// (The metadata tab does not have any selectable elements, so, no-op)
|
||||
}
|
||||
|
||||
async _handleClick_pButtonDeleteSelected_entities ({rdState}) {
|
||||
const listItemsSel = rdState.listEntities.items
|
||||
.filter(it => it.data.cbSel.checked);
|
||||
|
||||
if (!listItemsSel.length) return;
|
||||
|
||||
if (!await InputUiUtil.pGetUserBoolean({title: "Delete Entities", htmlDescription: `Are you sure you want to delete the ${listItemsSel.length === 1 ? "selected entity" : `${listItemsSel.length} selected entities`}?`, textYes: "Yes", textNo: "Cancel"})) return;
|
||||
|
||||
this._isDirty = true;
|
||||
|
||||
// Remove the array items from our copy of the brew, and remove the corresponding list items
|
||||
listItemsSel
|
||||
.forEach(li => this._doEntityListDelete({rdState, li}));
|
||||
rdState.listEntities.update();
|
||||
}
|
||||
|
||||
_doEntityListDelete ({rdState, li}) {
|
||||
const ix = this._brew.body[li.data.prop].indexOf(li.data.ent);
|
||||
if (!~ix) return;
|
||||
this._brew.body[li.data.prop].splice(ix, 1);
|
||||
if (!this._brew.body[li.data.prop].length) delete this._brew.body[li.data.prop];
|
||||
rdState.listEntities.removeItem(li);
|
||||
}
|
||||
|
||||
async _handleClick_pButtonDeleteSelected_sources ({rdState}) {
|
||||
const listItemsSel = rdState.listSources.items
|
||||
.filter(it => it.data.cbSel.checked);
|
||||
|
||||
if (!listItemsSel.length) return;
|
||||
|
||||
if (
|
||||
!await InputUiUtil.pGetUserBoolean({
|
||||
title: "Delete Sources",
|
||||
htmlDescription: `<div>Are you sure you want to delete the ${listItemsSel.length === 1 ? "selected source" : `${listItemsSel.length} selected sources`}?<br><b>This will delete all entities with ${listItemsSel.length === 1 ? "that source" : `these sources`}</b>.</div>`,
|
||||
textYes: "Yes",
|
||||
textNo: "Cancel",
|
||||
})
|
||||
) return;
|
||||
|
||||
this._isDirty = true;
|
||||
|
||||
// Remove the sources from our copy of the brew, and remove the corresponding list items
|
||||
listItemsSel
|
||||
.forEach(li => {
|
||||
const ix = this._brew.body._meta.sources.indexOf(li.data.source);
|
||||
if (!~ix) return;
|
||||
this._brew.body._meta.sources.splice(ix, 1);
|
||||
rdState.listSources.removeItem(li);
|
||||
});
|
||||
rdState.listSources.update();
|
||||
|
||||
// Remove all entities with matching sources, and remove the corresponding list items
|
||||
const sourceSetRemoved = new Set(listItemsSel.map(li => li.data.source.json));
|
||||
rdState.listEntities.visibleItems
|
||||
.forEach(li => {
|
||||
const source = SourceUtil.getEntitySource(li.data.ent);
|
||||
if (!sourceSetRemoved.has(source)) return;
|
||||
|
||||
this._doEntityListDelete({rdState, li});
|
||||
});
|
||||
rdState.listEntities.update();
|
||||
}
|
||||
|
||||
async pRender ($wrp, {rdState = null} = {}) {
|
||||
rdState = rdState || new this.constructor._RenderState();
|
||||
|
||||
const iptTabMetas = [
|
||||
new TabUiUtil.TabMeta({name: "Entities", hasBorder: true}),
|
||||
new TabUiUtil.TabMeta({name: "Metadata", hasBorder: true}),
|
||||
new TabUiUtil.TabMeta({name: "Sources", hasBorder: true}),
|
||||
];
|
||||
|
||||
const tabMetas = this._renderTabs(iptTabMetas, {$parent: $wrp});
|
||||
const [tabMetaEntities, tabMetaMetadata, tabMetaSources] = tabMetas;
|
||||
|
||||
rdState.tabMetaEntities = tabMetaEntities;
|
||||
rdState.tabMetaSources = tabMetaSources;
|
||||
|
||||
this._pRender_tabEntities({tabMeta: tabMetaEntities, rdState});
|
||||
this._pRender_tabMetadata({tabMeta: tabMetaMetadata, rdState});
|
||||
this._pRender_tabSources({tabMeta: tabMetaSources, rdState});
|
||||
}
|
||||
|
||||
_pRender_tabEntities ({tabMeta, rdState}) {
|
||||
const $btnFilter = $(`<button class="btn btn-default">Filter</button>`);
|
||||
|
||||
const $btnToggleSummaryHidden = $(`<button class="btn btn-default" title="Toggle Filter Summary Display"><span class="glyphicon glyphicon-resize-small"></span></button>`);
|
||||
|
||||
const $btnReset = $(`<button class="btn btn-default">Reset</button>`);
|
||||
|
||||
const $wrpMiniPills = $(`<div class="fltr__mini-view btn-group"></div>`);
|
||||
|
||||
const $cbAll = $(`<input type="checkbox">`);
|
||||
const $wrpRows = $$`<div class="list ve-flex-col w-100 max-h-unset"></div>`;
|
||||
const $iptSearch = $(`<input type="search" class="search manbrew__search form-control w-100 lst__search lst__search--no-border-h" placeholder="Search entries...">`);
|
||||
const $dispCntVisible = $(`<div class="lst__wrp-search-visible no-events ve-flex-vh-center"></div>`);
|
||||
const $wrpBtnsSort = $$`<div class="filtertools manbrew__filtertools input-group input-group--bottom ve-flex no-shrink">
|
||||
<label class="btn btn-default btn-xs ve-col-1 pr-0 ve-flex-vh-center">${$cbAll}</label>
|
||||
<button class="ve-col-5 sort btn btn-default btn-xs" data-sort="name">Name</button>
|
||||
<button class="ve-col-1 sort btn btn-default btn-xs" data-sort="source">Source</button>
|
||||
<button class="ve-col-5 sort btn btn-default btn-xs" data-sort="category">Category</button>
|
||||
</div>`;
|
||||
|
||||
$$(tabMeta.$wrpTab)`
|
||||
<div class="ve-flex-v-stretch input-group input-group--top no-shrink mt-1">
|
||||
${$btnFilter}
|
||||
${$btnToggleSummaryHidden}
|
||||
<div class="w-100 relative">
|
||||
${$iptSearch}
|
||||
<div id="lst__search-glass" class="lst__wrp-search-glass no-events ve-flex-vh-center"><span class="glyphicon glyphicon-search"></span></div>
|
||||
${$dispCntVisible}
|
||||
</div>
|
||||
${$btnReset}
|
||||
</div>
|
||||
|
||||
${$wrpMiniPills}
|
||||
|
||||
${$wrpBtnsSort}
|
||||
${$wrpRows}`;
|
||||
|
||||
rdState.listEntities = new List({
|
||||
$iptSearch,
|
||||
$wrpList: $wrpRows,
|
||||
fnSort: SortUtil.listSort,
|
||||
});
|
||||
|
||||
rdState.listEntities.on("updated", () => $dispCntVisible.html(`${rdState.listEntities.visibleItems.length}/${rdState.listEntities.items.length}`));
|
||||
|
||||
rdState.listEntitiesSelectClickHandler = new ListSelectClickHandler({list: rdState.listEntities});
|
||||
rdState.listEntitiesSelectClickHandler.bindSelectAllCheckbox($cbAll);
|
||||
SortUtil.initBtnSortHandlers($wrpBtnsSort, rdState.listEntities);
|
||||
|
||||
let ixParent = 0;
|
||||
rdState.contentEntities = Object.entries(this._brew.body)
|
||||
.filter(([, v]) => v instanceof Array && v.length)
|
||||
.map(([prop, arr]) => arr.map(ent => ({ent, prop, ixParent: ixParent++})))
|
||||
.flat();
|
||||
|
||||
rdState.contentEntities.forEach(({ent, prop, ixParent}) => {
|
||||
const {listItem} = this._pRender_getEntityRowMeta({rdState, prop, ent, ixParent});
|
||||
rdState.listEntities.addItem(listItem);
|
||||
});
|
||||
|
||||
rdState.pageFilterEntities.pInitFilterBox({
|
||||
$iptSearch: $iptSearch,
|
||||
$btnReset: $btnReset,
|
||||
$btnOpen: $btnFilter,
|
||||
$btnToggleSummaryHidden: $btnToggleSummaryHidden,
|
||||
$wrpMiniPills: $wrpMiniPills,
|
||||
namespace: `${this.constructor.name}__tabEntities`,
|
||||
}).then(async () => {
|
||||
rdState.contentEntities.forEach(meta => rdState.pageFilterEntities.mutateAndAddToFilters(meta));
|
||||
|
||||
rdState.listEntities.init();
|
||||
|
||||
rdState.pageFilterEntities.trimState();
|
||||
rdState.pageFilterEntities.filterBox.render();
|
||||
|
||||
rdState.pageFilterEntities.filterBox.on(
|
||||
EVNT_VALCHANGE,
|
||||
this._handleFilterChange_entities.bind(this, {rdState}),
|
||||
);
|
||||
|
||||
this._handleFilterChange_entities({rdState});
|
||||
|
||||
$iptSearch.focus();
|
||||
});
|
||||
}
|
||||
|
||||
_handleFilterChange_entities ({rdState}) {
|
||||
const f = rdState.pageFilterEntities.filterBox.getValues();
|
||||
rdState.listEntities.filter(li => rdState.pageFilterEntities.toDisplay(f, rdState.contentEntities[li.ix]));
|
||||
}
|
||||
|
||||
_pRender_getEntityRowMeta ({rdState, prop, ent, ixParent}) {
|
||||
const eleLi = document.createElement("div");
|
||||
eleLi.className = "lst__row ve-flex-col px-0";
|
||||
|
||||
const dispName = this.constructor._getDisplayName({brew: this._brew, ent, prop});
|
||||
const sourceMeta = this.constructor._getSourceMeta({brew: this._brew, ent});
|
||||
const dispProp = this.constructor._getDisplayProp({ent, prop});
|
||||
|
||||
eleLi.innerHTML = `<label class="lst--border lst__row-inner no-select mb-0 ve-flex-v-center">
|
||||
<div class="pl-0 ve-col-1 ve-flex-vh-center"><input type="checkbox" class="no-events"></div>
|
||||
<div class="ve-col-5 bold">${dispName}</div>
|
||||
<div class="ve-col-1 ve-text-center" title="${(sourceMeta.full || "").qq()}" ${this._brewUtil.sourceToStyle(sourceMeta)}>${sourceMeta.abbreviation}</div>
|
||||
<div class="ve-col-5 ve-flex-vh-center pr-0">${dispProp}</div>
|
||||
</label>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
ixParent, // We identify the item in the list according to its position across all props
|
||||
eleLi,
|
||||
dispName,
|
||||
{
|
||||
source: sourceMeta.abbreviation,
|
||||
category: dispProp,
|
||||
},
|
||||
{
|
||||
cbSel: eleLi.firstElementChild.firstElementChild.firstElementChild,
|
||||
prop,
|
||||
ent,
|
||||
},
|
||||
);
|
||||
|
||||
eleLi.addEventListener("click", evt => rdState.listEntitiesSelectClickHandler.handleSelectClick(listItem, evt));
|
||||
|
||||
return {
|
||||
listItem,
|
||||
};
|
||||
}
|
||||
|
||||
_pRender_tabMetadata ({tabMeta, rdState}) {
|
||||
const infoTuples = Object.entries(this.constructor._PROP_INFOS_META).filter(([k]) => Object.keys(this._brew.body?._meta?.[k] || {}).length);
|
||||
|
||||
if (!infoTuples.length) {
|
||||
$$(tabMeta.$wrpTab)`
|
||||
<h4>Metadata</h4>
|
||||
<p><i>No metadata found.</i></p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const metasSections = infoTuples
|
||||
.map(([prop, info]) => this._pRender_getMetaRowMeta({prop, info}));
|
||||
|
||||
$$(tabMeta.$wrpTab)`
|
||||
<div class="pt-2"><i>Warning: deleting metadata may invalidate or otherwise corrupt homebrew which depends on it. Use with caution.</i></div>
|
||||
<hr class="hr-3">
|
||||
${metasSections.map(({$wrp}) => $wrp)}
|
||||
`;
|
||||
}
|
||||
|
||||
_pRender_getMetaRowMeta ({prop, info}) {
|
||||
const displayName = info.displayName || prop.toTitleCase();
|
||||
const displayFn = info.displayFn || ((...args) => args.last().toTitleCase());
|
||||
|
||||
const $rows = Object.keys(this._brew.body._meta[prop])
|
||||
.map(k => {
|
||||
const $btnDelete = $(`<button class="btn btn-danger btn-xs" title="Delete"><span class="glyphicon glyphicon-trash"></span></button>`)
|
||||
.click(() => {
|
||||
this._isDirty = true;
|
||||
MiscUtil.deleteObjectPath(this._brew.body._meta, prop, k);
|
||||
$row.remove();
|
||||
|
||||
// If we deleted the last key and the whole prop has therefore been cleaned up, delete the section
|
||||
if (this._brew.body._meta[prop]) return;
|
||||
|
||||
$wrp.remove();
|
||||
});
|
||||
|
||||
const $row = $$`<div class="lst__row ve-flex-col px-0">
|
||||
<div class="split-v-center lst--border lst__row-inner no-select mb-0 ve-flex-v-center">
|
||||
<div class="ve-col-10">${displayFn(this._brew, prop, k)}</div>
|
||||
<div class="ve-col-2 btn-group ve-flex-v-center ve-flex-h-right">
|
||||
${$btnDelete}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return $row;
|
||||
});
|
||||
|
||||
const $wrp = $$`<div class="ve-flex-col mb-4">
|
||||
<div class="bold mb-2">${displayName}:</div>
|
||||
<div class="ve-flex-col list-display-only">${$rows}</div>
|
||||
</div>`;
|
||||
|
||||
return {
|
||||
$wrp,
|
||||
};
|
||||
}
|
||||
|
||||
_pRender_tabSources ({tabMeta, rdState}) {
|
||||
const $cbAll = $(`<input type="checkbox">`);
|
||||
const $wrpRows = $$`<div class="list ve-flex-col w-100 max-h-unset"></div>`;
|
||||
const $iptSearch = $(`<input type="search" class="search manbrew__search form-control w-100 mt-1" placeholder="Search source...">`);
|
||||
const $wrpBtnsSort = $$`<div class="filtertools manbrew__filtertools input-group input-group--bottom ve-flex no-shrink">
|
||||
<label class="btn btn-default btn-xs ve-col-1 pr-0 ve-flex-vh-center">${$cbAll}</label>
|
||||
<button class="ve-col-5 sort btn btn-default btn-xs" data-sort="name">Name</button>
|
||||
<button class="ve-col-2 sort btn btn-default btn-xs" data-sort="abbreviation">Abbreviation</button>
|
||||
<button class="ve-col-4 sort btn btn-default btn-xs" data-sort="json">JSON</button>
|
||||
</div>`;
|
||||
|
||||
$$(tabMeta.$wrpTab)`
|
||||
${$iptSearch}
|
||||
${$wrpBtnsSort}
|
||||
${$wrpRows}`;
|
||||
|
||||
rdState.listSources = new List({
|
||||
$iptSearch,
|
||||
$wrpList: $wrpRows,
|
||||
fnSort: SortUtil.listSort,
|
||||
});
|
||||
|
||||
rdState.listSourcesSelectClickHandler = new ListSelectClickHandler({list: rdState.listSources});
|
||||
rdState.listSourcesSelectClickHandler.bindSelectAllCheckbox($cbAll);
|
||||
SortUtil.initBtnSortHandlers($wrpBtnsSort, rdState.listSources);
|
||||
|
||||
(this._brew.body?._meta?.sources || [])
|
||||
.forEach((source, ix) => {
|
||||
const {listItem} = this._pRender_getSourceRowMeta({rdState, source, ix});
|
||||
rdState.listSources.addItem(listItem);
|
||||
});
|
||||
|
||||
rdState.listSources.init();
|
||||
$iptSearch.focus();
|
||||
}
|
||||
|
||||
_pRender_getSourceRowMeta ({rdState, source, ix}) {
|
||||
const eleLi = document.createElement("div");
|
||||
eleLi.className = "lst__row ve-flex-col px-0";
|
||||
|
||||
const name = source.full || SOURCE_UNKNOWN_FULL;
|
||||
const abv = source.abbreviation || SOURCE_UNKNOWN_ABBREVIATION;
|
||||
|
||||
eleLi.innerHTML = `<label class="lst--border lst__row-inner no-select mb-0 ve-flex-v-center">
|
||||
<div class="pl-0 ve-col-1 ve-flex-vh-center"><input type="checkbox" class="no-events"></div>
|
||||
<div class="ve-col-5 bold">${name}</div>
|
||||
<div class="ve-col-2 ve-text-center">${abv}</div>
|
||||
<div class="ve-col-4 ve-flex-vh-center pr-0">${source.json}</div>
|
||||
</label>`;
|
||||
|
||||
const listItem = new ListItem(
|
||||
ix,
|
||||
eleLi,
|
||||
name,
|
||||
{
|
||||
abbreviation: abv,
|
||||
json: source.json,
|
||||
},
|
||||
{
|
||||
cbSel: eleLi.firstElementChild.firstElementChild.firstElementChild,
|
||||
source,
|
||||
},
|
||||
);
|
||||
|
||||
eleLi.addEventListener("click", evt => rdState.listSourcesSelectClickHandler.handleSelectClick(listItem, evt));
|
||||
|
||||
return {
|
||||
listItem,
|
||||
};
|
||||
}
|
||||
|
||||
static _NAME_UNKNOWN = "(Unknown)";
|
||||
|
||||
static _getDisplayName ({brew, ent, prop}) {
|
||||
switch (prop) {
|
||||
case "itemProperty": {
|
||||
if (ent.name) return ent.name || this._NAME_UNKNOWN;
|
||||
if (ent.entries) {
|
||||
const name = Renderer.findName(ent.entries);
|
||||
if (name) return name;
|
||||
}
|
||||
if (ent.entriesTemplate) {
|
||||
const name = Renderer.findName(ent.entriesTemplate);
|
||||
if (name) return name;
|
||||
}
|
||||
return ent.abbreviation || this._NAME_UNKNOWN;
|
||||
}
|
||||
|
||||
case "adventureData":
|
||||
case "bookData": {
|
||||
const propContents = prop === "adventureData" ? "adventure" : "book";
|
||||
|
||||
if (!brew[propContents]) return ent.id || this._NAME_UNKNOWN;
|
||||
|
||||
return brew[propContents].find(it => it.id === ent.id)?.name || ent.id || this._NAME_UNKNOWN;
|
||||
}
|
||||
|
||||
default: return ent.name || this._NAME_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
static _getSourceMeta ({brew, ent}) {
|
||||
const entSource = SourceUtil.getEntitySource(ent);
|
||||
if (!entSource) return {abbreviation: SOURCE_UNKNOWN_ABBREVIATION, full: SOURCE_UNKNOWN_FULL};
|
||||
const source = (brew.body?._meta?.sources || []).find(src => src.json === entSource);
|
||||
if (!source) return {abbreviation: SOURCE_UNKNOWN_ABBREVIATION, full: SOURCE_UNKNOWN_FULL};
|
||||
return source;
|
||||
}
|
||||
|
||||
static _getDisplayProp ({ent, prop}) {
|
||||
const out = [Parser.getPropDisplayName(prop)];
|
||||
|
||||
switch (prop) {
|
||||
case "subclass": out.push(` (${ent.className})`); break;
|
||||
case "subrace": out.push(` (${ent.raceName})`); break;
|
||||
case "psionic": out.push(` (${Parser.psiTypeToMeta(ent.type).short})`); break;
|
||||
}
|
||||
|
||||
return out.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/** These are props found in "_meta" sections of files */
|
||||
static _PROP_INFOS_META = {
|
||||
"spellDistanceUnits": {
|
||||
displayName: "Spell Distance Units",
|
||||
},
|
||||
"spellSchools": {
|
||||
displayName: "Spell Schools",
|
||||
displayFn: (brew, propMeta, k) => brew.body._meta[propMeta][k].full || k,
|
||||
},
|
||||
"currencyConversions": {
|
||||
displayName: "Currency Conversion Tables",
|
||||
displayFn: (brew, propMeta, k) => `${k}: ${brew.body._meta[propMeta][k].map(it => `${it.coin}=${it.mult}`).join(", ")}`,
|
||||
},
|
||||
"skills": {
|
||||
displayName: "Skills",
|
||||
},
|
||||
"senses": {
|
||||
displayName: "Senses",
|
||||
},
|
||||
"optionalFeatureTypes": {
|
||||
displayName: "Optional Feature Types",
|
||||
displayFn: (brew, propMeta, k) => brew.body._meta[propMeta][k] || k,
|
||||
},
|
||||
"charOption": {
|
||||
displayName: "Character Creation Option Types",
|
||||
displayFn: (brew, propMeta, k) => brew.body._meta[propMeta][k] || k,
|
||||
},
|
||||
"psionicTypes": {
|
||||
displayName: "Psionic Types",
|
||||
displayFn: (brew, propMeta, k) => brew.body._meta[propMeta][k].full || k,
|
||||
},
|
||||
};
|
||||
}
|
||||
981
js/utils-brew/utils-brew-ui-manage.js
Normal file
981
js/utils-brew/utils-brew-ui-manage.js
Normal file
@@ -0,0 +1,981 @@
|
||||
import {SOURCE_UNKNOWN_ABBREVIATION, SOURCE_UNKNOWN_FULL} from "./utils-brew-constants.js";
|
||||
import {BrewDoc} from "./utils-brew-models.js";
|
||||
import {GetBrewUi} from "./utils-brew-ui-get.js";
|
||||
import {ManageEditableBrewContentsUi} from "./utils-brew-ui-manage-editable-contents.js";
|
||||
import {ManageExternalUtils} from "../manageexternal/manageexternal-utils.js";
|
||||
|
||||
export class ManageBrewUi {
|
||||
static _RenderState = class {
|
||||
constructor () {
|
||||
this.$stgBrewList = null;
|
||||
this.list = null;
|
||||
this.listSelectClickHandler = null;
|
||||
this.brews = [];
|
||||
this.menuListMass = null;
|
||||
this.rowMetas = [];
|
||||
}
|
||||
};
|
||||
|
||||
constructor ({brewUtil, isModal = false} = {}) {
|
||||
this._brewUtil = brewUtil;
|
||||
this._isModal = isModal;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
static _CONTEXT_MENU_BTNGROUP_MANAGER = null;
|
||||
|
||||
static bindBtngroupManager (btngroup) {
|
||||
btngroup
|
||||
.first(`[name="manage-content"]`)
|
||||
.onn("click", evt => this._pOnClickBtnManageContent({evt}));
|
||||
|
||||
btngroup
|
||||
.first(`[name="manage-prerelease"]`)
|
||||
.onn("click", evt => this._onClickBtnManagePrereleaseBrew({brewUtil: PrereleaseUtil, isGoToPage: evt.shiftKey}));
|
||||
|
||||
btngroup
|
||||
.first(`[name="manage-brew"]`)
|
||||
.onn("click", evt => this._onClickBtnManagePrereleaseBrew({brewUtil: BrewUtil2, isGoToPage: evt.shiftKey}));
|
||||
}
|
||||
|
||||
static bindBtnOpen ($btn, {brewUtil = null} = {}) {
|
||||
brewUtil = brewUtil || BrewUtil2;
|
||||
|
||||
$btn.click(evt => this._onClickBtnManagePrereleaseBrew({brewUtil, isGoToPage: evt.shiftKey}));
|
||||
}
|
||||
|
||||
static _pOnClickBtnManageContent ({evt}) {
|
||||
this._CONTEXT_MENU_BTNGROUP_MANAGER ||= ContextUtil.getMenu([
|
||||
new ContextUtil.Action(
|
||||
"Manage Prerelease Content",
|
||||
async evt => {
|
||||
this._onClickBtnManagePrereleaseBrew({brewUtil: PrereleaseUtil, isGoToPage: evt.shiftKey});
|
||||
},
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
"Manage Homebrew",
|
||||
async evt => {
|
||||
this._onClickBtnManagePrereleaseBrew({brewUtil: BrewUtil2, isGoToPage: evt.shiftKey});
|
||||
},
|
||||
),
|
||||
null,
|
||||
new ContextUtil.Action(
|
||||
"Load All Partnered Content",
|
||||
async evt => {
|
||||
await this.pOnClickBtnLoadAllPartnered();
|
||||
},
|
||||
),
|
||||
null,
|
||||
new ContextUtil.Action(
|
||||
"Delete All Loaded Content",
|
||||
async evt => {
|
||||
await this._pOnClickBtnDeleteAllLoadedContent();
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
return ContextUtil.pOpenMenu(evt, this._CONTEXT_MENU_BTNGROUP_MANAGER);
|
||||
}
|
||||
|
||||
static _onClickBtnManagePrereleaseBrew ({brewUtil, isGoToPage}) {
|
||||
if (isGoToPage) return window.location = brewUtil.PAGE_MANAGE;
|
||||
return this.pDoManageBrew({brewUtil});
|
||||
}
|
||||
|
||||
static async pOnClickBtnLoadAllPartnered () {
|
||||
const brewDocs = [];
|
||||
try {
|
||||
const [brewDocsPrerelease, brewDocsHomebrew] = await Promise.all([
|
||||
PrereleaseUtil.pAddBrewsPartnered({isSilent: true}),
|
||||
BrewUtil2.pAddBrewsPartnered({isSilent: true}),
|
||||
]);
|
||||
brewDocs.push(
|
||||
...brewDocsPrerelease,
|
||||
...brewDocsHomebrew,
|
||||
);
|
||||
} catch (e) {
|
||||
JqueryUtil.doToast({type: "danger", content: `Failed to load partnered content! ${VeCt.STR_SEE_CONSOLE}`});
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (brewDocs.length) JqueryUtil.doToast(`Loaded partnered content!`);
|
||||
|
||||
if (PrereleaseUtil.isReloadRequired()) PrereleaseUtil.doLocationReload();
|
||||
if (BrewUtil2.isReloadRequired()) BrewUtil2.doLocationReload();
|
||||
}
|
||||
|
||||
static async _pOnClickBtnDeleteAllLoadedContent () {
|
||||
if (
|
||||
!await InputUiUtil.pGetUserBoolean({
|
||||
title: `Delete All Loaded ${PrereleaseUtil.DISPLAY_NAME.toTitleCase()} and ${BrewUtil2.DISPLAY_NAME.toTitleCase()}`,
|
||||
htmlDescription: `<div>
|
||||
<div>Are you sure?</div>
|
||||
<div class="ve-muted"><i>Note that this will <b>not</b> delete your Editable ${PrereleaseUtil.DISPLAY_NAME.toTitleCase()} and Editable ${BrewUtil2.DISPLAY_NAME.toTitleCase()}.</i></div>
|
||||
</div>`,
|
||||
textYes: "Yes",
|
||||
textNo: "Cancel",
|
||||
})
|
||||
) return;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
PrereleaseUtil.pDeleteUneditableBrews(),
|
||||
BrewUtil2.pDeleteUneditableBrews(),
|
||||
]);
|
||||
} catch (e) {
|
||||
JqueryUtil.doToast({type: "danger", content: `Failed to load partnered content! ${VeCt.STR_SEE_CONSOLE}`});
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (PrereleaseUtil.isReloadRequired()) PrereleaseUtil.doLocationReload();
|
||||
if (BrewUtil2.isReloadRequired()) BrewUtil2.doLocationReload();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
static async pOnClickBtnExportListAsUrl ({ele}) {
|
||||
const url = await ManageExternalUtils.pGetUrl();
|
||||
await MiscUtil.pCopyTextToClipboard(url);
|
||||
JqueryUtil.showCopiedEffect(ele);
|
||||
|
||||
if (
|
||||
!await PrereleaseUtil.pHasEditableSourceJson()
|
||||
&& !await BrewUtil2.pHasEditableSourceJson()
|
||||
) return;
|
||||
|
||||
JqueryUtil.doToast({type: "warning", content: `Note: you have Editable ${PrereleaseUtil.DISPLAY_NAME} or ${BrewUtil2.DISPLAY_NAME}. This cannot be exported as part of a URL, and so was not included.`});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
static async pDoManageBrew ({brewUtil = null} = {}) {
|
||||
brewUtil = brewUtil || BrewUtil2;
|
||||
|
||||
const ui = new this({isModal: true, brewUtil});
|
||||
const rdState = new this._RenderState();
|
||||
const {$modalInner} = UiUtil.getShowModal({
|
||||
isHeight100: true,
|
||||
isWidth100: true,
|
||||
title: `Manage ${brewUtil.DISPLAY_NAME.toTitleCase()}`,
|
||||
isUncappedHeight: true,
|
||||
$titleSplit: $$`<div class="ve-flex-v-center btn-group">
|
||||
${ui._$getBtnPullAll(rdState)}
|
||||
${ui._$getBtnDeleteAll(rdState)}
|
||||
</div>`,
|
||||
isHeaderBorder: true,
|
||||
cbClose: () => {
|
||||
if (!brewUtil.isReloadRequired()) return;
|
||||
brewUtil.doLocationReload();
|
||||
},
|
||||
});
|
||||
await ui.pRender($modalInner, {rdState});
|
||||
}
|
||||
|
||||
_$getBtnDeleteAll (rdState) {
|
||||
const brewUtilOther = this._brewUtil === PrereleaseUtil ? BrewUtil2 : PrereleaseUtil;
|
||||
|
||||
return $(`<button class="btn btn-danger" title="SHIFT to also delete all ${brewUtilOther.DISPLAY_NAME.toTitleCase()}">Delete All</button>`)
|
||||
.addClass(this._isModal ? "btn-xs" : "btn-sm")
|
||||
.click(async evt => {
|
||||
if (!evt.shiftKey) {
|
||||
if (!await InputUiUtil.pGetUserBoolean({title: `Delete All ${this._brewUtil.DISPLAY_NAME.toTitleCase()}`, htmlDescription: "Are you sure?", textYes: "Yes", textNo: "Cancel"})) return;
|
||||
|
||||
await this._pDoDeleteAll(rdState);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!await InputUiUtil.pGetUserBoolean({
|
||||
title: `Delete All ${this._brewUtil.DISPLAY_NAME.toTitleCase()} and ${brewUtilOther.DISPLAY_NAME.toTitleCase()}`,
|
||||
htmlDescription: "Are you sure?",
|
||||
textYes: "Yes",
|
||||
textNo: "Cancel",
|
||||
})
|
||||
) return;
|
||||
|
||||
await brewUtilOther.pSetBrew([]);
|
||||
await this._pDoDeleteAll(rdState);
|
||||
});
|
||||
}
|
||||
|
||||
_$getBtnPullAll (rdState) {
|
||||
const $btn = $(`<button class="btn btn-default">Update All</button>`)
|
||||
.addClass(this._isModal ? "btn-xs w-70p" : "btn-sm w-80p")
|
||||
.click(async () => {
|
||||
const cachedHtml = $btn.html();
|
||||
|
||||
try {
|
||||
$btn.text(`Updating...`).prop("disabled", true);
|
||||
await this._pDoPullAll({rdState});
|
||||
} catch (e) {
|
||||
$btn.text(`Failed!`);
|
||||
setTimeout(() => $btn.html(cachedHtml).prop("disabled", false), VeCt.DUR_INLINE_NOTIFY);
|
||||
throw e;
|
||||
}
|
||||
|
||||
$btn.text(`Done!`);
|
||||
setTimeout(() => $btn.html(cachedHtml).prop("disabled", false), VeCt.DUR_INLINE_NOTIFY);
|
||||
});
|
||||
return $btn;
|
||||
}
|
||||
|
||||
async _pDoDeleteAll (rdState) {
|
||||
await this._brewUtil.pSetBrew([]);
|
||||
|
||||
rdState.list.removeAllItems();
|
||||
rdState.list.update();
|
||||
}
|
||||
|
||||
async _pDoPullAll ({rdState, brews = null}) {
|
||||
if (brews && !brews.length) return;
|
||||
|
||||
let cntPulls;
|
||||
try {
|
||||
cntPulls = await this._brewUtil.pPullAllBrews({brews});
|
||||
} catch (e) {
|
||||
JqueryUtil.doToast({content: `Update failed! ${VeCt.STR_SEE_CONSOLE}`, type: "danger"});
|
||||
throw e;
|
||||
}
|
||||
if (!cntPulls) return JqueryUtil.doToast(`Update complete! No ${this._brewUtil.DISPLAY_NAME} was updated.`);
|
||||
|
||||
await this._pRender_pBrewList(rdState);
|
||||
JqueryUtil.doToast(`Update complete! ${cntPulls} ${cntPulls === 1 ? `${this._brewUtil.DISPLAY_NAME} was` : `${this._brewUtil.DISPLAY_NAME_PLURAL} were`} updated.`);
|
||||
}
|
||||
|
||||
async pRender ($wrp, {rdState = null} = {}) {
|
||||
rdState = rdState || new this.constructor._RenderState();
|
||||
|
||||
rdState.$stgBrewList = $(`<div class="manbrew__current_brew ve-flex-col h-100 mt-1 min-h-0"></div>`);
|
||||
|
||||
await this._pRender_pBrewList(rdState);
|
||||
|
||||
const btnLoadPartnered = ee`<button class="btn btn-default btn-sm">Load All Partnered</button>`
|
||||
.onn("click", () => this._pHandleClick_btnLoadPartnered(rdState));
|
||||
|
||||
const $btnLoadFromFile = $(`<button class="btn btn-default btn-sm">Load from File</button>`)
|
||||
.click(() => this._pHandleClick_btnLoadFromFile(rdState));
|
||||
|
||||
const $btnLoadFromUrl = $(`<button class="btn btn-default btn-sm">Load from URL</button>`)
|
||||
.click(() => this._pHandleClick_btnLoadFromUrl(rdState));
|
||||
|
||||
const $btnGet = $(`<button class="btn ${this._brewUtil.STYLE_BTN} btn-sm">Get ${this._brewUtil.DISPLAY_NAME.toTitleCase()}</button>`)
|
||||
.click(() => this._pHandleClick_btnGetBrew(rdState));
|
||||
|
||||
const $btnCustomUrl = $(`<button class="btn ${this._brewUtil.STYLE_BTN} btn-sm px-2" title="Set Custom Repository URL"><span class="glyphicon glyphicon-cog"></span></button>`)
|
||||
.click(() => this._pHandleClick_btnSetCustomRepo());
|
||||
|
||||
const $btnPullAll = this._isModal ? null : this._$getBtnPullAll(rdState);
|
||||
const $btnDeleteAll = this._isModal ? null : this._$getBtnDeleteAll(rdState);
|
||||
|
||||
const $btnSaveToUrl = $(`<button class="btn btn-default btn-sm" title="Note that this does not include "Editable" or "Local" content.">Export List as URL</button>`)
|
||||
.click(async evt => {
|
||||
await this.constructor.pOnClickBtnExportListAsUrl({ele: evt.originalEvent.currentTarget});
|
||||
});
|
||||
|
||||
const $wrpBtns = $$`<div class="ve-flex-v-center no-shrink mobile__ve-flex-col">
|
||||
<div class="ve-flex-v-center mobile__mb-2">
|
||||
<div class="ve-flex-v-center btn-group mr-2">
|
||||
${$btnGet}
|
||||
${$btnCustomUrl}
|
||||
</div>
|
||||
<div class="ve-flex-v-center btn-group mr-2">
|
||||
${btnLoadPartnered}
|
||||
</div>
|
||||
<div class="ve-flex-v-center btn-group mr-2">
|
||||
${$btnLoadFromFile}
|
||||
${$btnLoadFromUrl}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ve-flex-v-center">
|
||||
<a href="${this._brewUtil.URL_REPO_DEFAULT}" class="ve-flex-v-center" target="_blank" rel="noopener noreferrer"><button class="btn btn-default btn-sm mr-2">Browse Source Repository</button></a>
|
||||
|
||||
<div class="ve-flex-v-center btn-group mr-2">
|
||||
${$btnSaveToUrl}
|
||||
</div>
|
||||
|
||||
<div class="ve-flex-v-center btn-group">
|
||||
${$btnPullAll}
|
||||
${$btnDeleteAll}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (this._isModal) {
|
||||
$$($wrp)`
|
||||
${rdState.$stgBrewList}
|
||||
${$wrpBtns.addClass("mb-2")}`;
|
||||
} else {
|
||||
$$($wrp)`
|
||||
${$wrpBtns.addClass("mb-3")}
|
||||
${rdState.$stgBrewList}`;
|
||||
}
|
||||
}
|
||||
|
||||
async _pHandleClick_btnLoadPartnered (rdState) {
|
||||
await this._brewUtil.pAddBrewsPartnered();
|
||||
await this._pRender_pBrewList(rdState);
|
||||
}
|
||||
|
||||
async _pHandleClick_btnLoadFromFile (rdState) {
|
||||
const {files, errors} = await InputUiUtil.pGetUserUploadJson({isMultiple: true});
|
||||
|
||||
DataUtil.doHandleFileLoadErrorsGeneric(errors);
|
||||
|
||||
await this._brewUtil.pAddBrewsFromFiles(files);
|
||||
await this._pRender_pBrewList(rdState);
|
||||
}
|
||||
|
||||
async _pHandleClick_btnLoadFromUrl (rdState) {
|
||||
const enteredUrl = await InputUiUtil.pGetUserString({title: `${this._brewUtil.DISPLAY_NAME.toTitleCase()} URL`});
|
||||
if (!enteredUrl || !enteredUrl.trim()) return;
|
||||
|
||||
const parsedUrl = this.constructor._getParsedCustomUrl(enteredUrl);
|
||||
if (!parsedUrl) {
|
||||
return JqueryUtil.doToast({
|
||||
content: `The URL was not valid!`,
|
||||
type: "danger",
|
||||
});
|
||||
}
|
||||
|
||||
await this._brewUtil.pAddBrewFromUrl(parsedUrl.href);
|
||||
await this._pRender_pBrewList(rdState);
|
||||
}
|
||||
|
||||
static _getParsedCustomUrl (enteredUrl) {
|
||||
try {
|
||||
return new URL(enteredUrl);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async _pHandleClick_btnGetBrew (rdState) {
|
||||
await GetBrewUi.pDoGetBrew({brewUtil: this._brewUtil, isModal: this._isModal});
|
||||
await this._pRender_pBrewList(rdState);
|
||||
}
|
||||
|
||||
async _pHandleClick_btnSetCustomRepo () {
|
||||
const customBrewUtl = await this._brewUtil.pGetCustomUrl();
|
||||
|
||||
const nxtUrl = await InputUiUtil.pGetUserString({
|
||||
title: `${this._brewUtil.DISPLAY_NAME.toTitleCase()} Repository URL`,
|
||||
$elePre: $(`<div>
|
||||
<p>Leave blank to use the <a href="${this._brewUtil.URL_REPO_DEFAULT}" rel="noopener noreferrer" target="_blank">default ${this._brewUtil.DISPLAY_NAME} repo</a>.</p>
|
||||
<div>Note that for GitHub URLs, the <code>raw.</code> URL must be used. For example, <code>${this._brewUtil.URL_REPO_ROOT_DEFAULT.replace(/TheGiddyLimit/g, "YourUsernameHere")}</code></div>
|
||||
<hr class="hr-3">
|
||||
</div>`),
|
||||
default: customBrewUtl,
|
||||
});
|
||||
if (nxtUrl == null) return;
|
||||
|
||||
await this._brewUtil.pSetCustomUrl(nxtUrl);
|
||||
}
|
||||
|
||||
async _pRender_pBrewList (rdState) {
|
||||
rdState.$stgBrewList.empty();
|
||||
rdState.rowMetas.splice(0, rdState.rowMetas.length)
|
||||
.forEach(({menu}) => ContextUtil.deleteMenu(menu));
|
||||
|
||||
const $btnMass = $(`<button class="btn btn-default">Mass...</button>`)
|
||||
.click(evt => this._pHandleClick_btnListMass({evt, rdState}));
|
||||
const $iptSearch = $(`<input type="search" class="search manbrew__search form-control" placeholder="Search ${this._brewUtil.DISPLAY_NAME}...">`);
|
||||
const $cbAll = $(`<input type="checkbox">`);
|
||||
const $wrpList = $(`<div class="list-display-only max-h-unset smooth-scroll ve-overflow-y-auto h-100 min-h-0 brew-list brew-list--target manbrew__list relative ve-flex-col w-100 mb-3"></div>`);
|
||||
|
||||
rdState.list = new List({
|
||||
$iptSearch,
|
||||
$wrpList,
|
||||
isUseJquery: true,
|
||||
isFuzzy: true,
|
||||
sortByInitial: rdState.list ? rdState.list.sortBy : undefined,
|
||||
sortDirInitial: rdState.list ? rdState.list.sortDir : undefined,
|
||||
});
|
||||
|
||||
const $wrpBtnsSort = $$`<div class="filtertools manbrew__filtertools btn-group input-group input-group--bottom ve-flex no-shrink">
|
||||
<label class="ve-col-0-5 pr-0 btn btn-default btn-xs ve-flex-vh-center">${$cbAll}</label>
|
||||
<button class="ve-col-1 btn btn-default btn-xs" disabled>Type</button>
|
||||
<button class="ve-col-3 btn btn-default btn-xs" data-sort="source">Source</button>
|
||||
<button class="ve-col-3 btn btn-default btn-xs" data-sort="authors">Authors</button>
|
||||
<button class="ve-col-3 btn btn-default btn-xs" disabled>Origin</button>
|
||||
<button class="ve-col-1-5 btn btn-default btn-xs ve-grow" disabled> </button>
|
||||
</div>`;
|
||||
|
||||
$$(rdState.$stgBrewList)`
|
||||
<div class="ve-flex-col h-100">
|
||||
<div class="input-group ve-flex-vh-center">
|
||||
${$btnMass}
|
||||
${$iptSearch}
|
||||
</div>
|
||||
${$wrpBtnsSort}
|
||||
<div class="ve-flex w-100 h-100 min-h-0 relative">${$wrpList}</div>
|
||||
</div>`;
|
||||
|
||||
rdState.listSelectClickHandler = new ListSelectClickHandler({list: rdState.list});
|
||||
rdState.listSelectClickHandler.bindSelectAllCheckbox($cbAll);
|
||||
SortUtil.initBtnSortHandlers($wrpBtnsSort, rdState.list);
|
||||
|
||||
rdState.brews = (await this._brewUtil.pGetBrew()).map(brew => this._pRender_getProcBrew(brew));
|
||||
|
||||
rdState.brews.forEach((brew, ix) => {
|
||||
const meta = this._pRender_getLoadedRowMeta(rdState, brew, ix);
|
||||
rdState.rowMetas.push(meta);
|
||||
rdState.list.addItem(meta.listItem);
|
||||
});
|
||||
|
||||
rdState.list.init();
|
||||
$iptSearch.focus();
|
||||
}
|
||||
|
||||
get _LBL_LIST_UPDATE () { return "Update"; }
|
||||
get _LBL_LIST_MANAGE_CONTENTS () { return "Manage Contents"; }
|
||||
get _LBL_LIST_EXPORT () { return "Export"; }
|
||||
get _LBL_LIST_VIEW_JSON () { return "View JSON"; }
|
||||
get _LBL_LIST_DELETE () { return "Delete"; }
|
||||
get _LBL_LIST_MOVE_TO_EDITABLE () { return `Move to Editable ${this._brewUtil.DISPLAY_NAME.toTitleCase()} Document`; }
|
||||
|
||||
_initListMassMenu ({rdState}) {
|
||||
if (rdState.menuListMass) return;
|
||||
|
||||
const getSelBrews = ({fnFilter = null} = {}) => {
|
||||
const brews = rdState.list.items
|
||||
.filter(li => li.data.cbSel.checked)
|
||||
.map(li => rdState.brews[li.ix])
|
||||
.filter(brew => fnFilter ? fnFilter(brew) : true);
|
||||
|
||||
if (!brews.length) JqueryUtil.doToast({content: `Please select some suitable ${this._brewUtil.DISPLAY_NAME_PLURAL} first!`, type: "warning"});
|
||||
|
||||
return brews;
|
||||
};
|
||||
|
||||
rdState.menuListMass = ContextUtil.getMenu([
|
||||
new ContextUtil.Action(
|
||||
this._LBL_LIST_UPDATE,
|
||||
async () => this._pDoPullAll({
|
||||
rdState,
|
||||
brews: getSelBrews(),
|
||||
}),
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
this._LBL_LIST_EXPORT,
|
||||
async () => {
|
||||
for (const brew of getSelBrews()) await this._pRender_pDoDownloadBrew({brew});
|
||||
},
|
||||
),
|
||||
this._brewUtil.IS_EDITABLE
|
||||
? new ContextUtil.Action(
|
||||
this._LBL_LIST_MOVE_TO_EDITABLE,
|
||||
async () => this._pRender_pDoMoveToEditable({
|
||||
rdState,
|
||||
brews: getSelBrews({
|
||||
fnFilter: brew => this._isBrewOperationPermitted_moveToEditable(brew),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
new ContextUtil.Action(
|
||||
this._LBL_LIST_DELETE,
|
||||
async () => this._pRender_pDoDelete({
|
||||
rdState,
|
||||
brews: getSelBrews({
|
||||
fnFilter: brew => this._isBrewOperationPermitted_delete(brew),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
].filter(Boolean));
|
||||
}
|
||||
|
||||
_isBrewOperationPermitted_update (brew) { return this._brewUtil.isPullable(brew); }
|
||||
_isBrewOperationPermitted_moveToEditable (brew) { return BrewDoc.isOperationPermitted_moveToEditable({brew}); }
|
||||
_isBrewOperationPermitted_delete (brew) { return !brew.head.isLocal; }
|
||||
|
||||
async _pHandleClick_btnListMass ({evt, rdState}) {
|
||||
this._initListMassMenu({rdState});
|
||||
await ContextUtil.pOpenMenu(evt, rdState.menuListMass);
|
||||
}
|
||||
|
||||
static _getBrewName (brew) {
|
||||
const sources = brew.body._meta?.sources || [];
|
||||
|
||||
return sources
|
||||
.map(brewSource => brewSource.full || SOURCE_UNKNOWN_FULL)
|
||||
.sort(SortUtil.ascSortLower)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
_pRender_getLoadedRowMeta (rdState, brew, ix) {
|
||||
const sources = brew.body._meta?.sources || [];
|
||||
|
||||
const rowsSubMetas = sources
|
||||
.map(brewSource => {
|
||||
const hasConverters = !!brewSource.convertedBy?.length;
|
||||
const btnConvertedBy = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-xxs btn-default ${!hasConverters ? "disabled" : ""}`,
|
||||
title: hasConverters ? `Converted by: ${brewSource.convertedBy.join(", ").qq()}` : "(No conversion credit given)",
|
||||
children: [
|
||||
e_({tag: "span", clazz: "mobile__hidden", text: "View Converters"}),
|
||||
e_({tag: "span", clazz: "mobile__visible", text: "Convs.", title: "View Converters"}),
|
||||
],
|
||||
click: () => {
|
||||
if (!hasConverters) return;
|
||||
const {$modalInner} = UiUtil.getShowModal({
|
||||
title: `Converted By:${brewSource.convertedBy.length === 1 ? ` ${brewSource.convertedBy.join("")}` : ""}`,
|
||||
isMinHeight0: true,
|
||||
});
|
||||
|
||||
if (brewSource.convertedBy.length === 1) return;
|
||||
$modalInner.append(`<ul>${brewSource.convertedBy.map(it => `<li>${it.qq()}</li>`).join("")}</ul>`);
|
||||
},
|
||||
});
|
||||
|
||||
const authorsFull = [(brewSource.authors || [])].flat(2).join(", ");
|
||||
|
||||
const lnkUrl = brewSource.url
|
||||
? e_({
|
||||
tag: "a",
|
||||
clazz: "ve-col-2 ve-text-center",
|
||||
href: brewSource.url,
|
||||
attrs: {
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
},
|
||||
text: "View Source",
|
||||
})
|
||||
: e_({
|
||||
tag: "span",
|
||||
clazz: "ve-col-2 ve-text-center",
|
||||
});
|
||||
|
||||
const eleRow = e_({
|
||||
tag: "div",
|
||||
clazz: `w-100 ve-flex-v-center`,
|
||||
children: [
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: `ve-col-4 manbrew__source px-1`,
|
||||
text: brewSource.full,
|
||||
}),
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: `ve-col-4 px-1`,
|
||||
text: authorsFull,
|
||||
}),
|
||||
lnkUrl,
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `ve-flex-vh-center ve-grow`,
|
||||
children: [
|
||||
btnConvertedBy,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
eleRow,
|
||||
authorsFull,
|
||||
name: brewSource.full || SOURCE_UNKNOWN_FULL,
|
||||
abbreviation: brewSource.abbreviation || SOURCE_UNKNOWN_ABBREVIATION,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => SortUtil.ascSortLower(a.name, b.name));
|
||||
|
||||
const brewName = this.constructor._getBrewName(brew);
|
||||
|
||||
// region These are mutually exclusive
|
||||
const btnPull = this._pRender_getBtnPull({rdState, brew});
|
||||
const btnEdit = this._pRender_getBtnEdit({rdState, brew});
|
||||
const btnPullEditPlaceholder = (btnPull || btnEdit) ? null : this.constructor._pRender_getBtnPlaceholder();
|
||||
// endregion
|
||||
|
||||
const btnDownload = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default btn-xs mobile__hidden w-24p`,
|
||||
title: this._LBL_LIST_EXPORT,
|
||||
children: [
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: "glyphicon glyphicon-download manbrew-row__icn-btn",
|
||||
}),
|
||||
],
|
||||
click: () => this._pRender_pDoDownloadBrew({brew, brewName}),
|
||||
});
|
||||
|
||||
const btnViewJson = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default btn-xs mobile-lg__hidden w-24p`,
|
||||
title: `${this._LBL_LIST_VIEW_JSON}: ${this.constructor._getBrewJsonTitle({brew, brewName})}`,
|
||||
children: [
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: "ve-bolder code relative manbrew-row__icn-btn--text",
|
||||
text: "{}",
|
||||
}),
|
||||
],
|
||||
click: evt => this._pRender_doViewBrew({evt, brew, brewName}),
|
||||
});
|
||||
|
||||
const btnOpenMenu = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default btn-xs w-24p`,
|
||||
title: "Menu",
|
||||
children: [
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: "glyphicon glyphicon-option-vertical manbrew-row__icn-btn",
|
||||
}),
|
||||
],
|
||||
click: evt => this._pRender_pDoOpenBrewMenu({evt, rdState, brew, brewName, rowMeta}),
|
||||
});
|
||||
|
||||
const btnDelete = this._isBrewOperationPermitted_delete(brew) ? e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-danger btn-xs mobile__hidden w-24p`,
|
||||
title: this._LBL_LIST_DELETE,
|
||||
children: [
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: "glyphicon glyphicon-trash manbrew-row__icn-btn",
|
||||
}),
|
||||
],
|
||||
click: () => this._pRender_pDoDelete({rdState, brews: [brew]}),
|
||||
}) : this.constructor._pRender_getBtnPlaceholder();
|
||||
|
||||
// Weave in HRs
|
||||
const elesSub = rowsSubMetas.map(it => it.eleRow);
|
||||
for (let i = rowsSubMetas.length - 1; i > 0; --i) elesSub.splice(i, 0, e_({tag: "hr", clazz: `hr-1 hr--dotted`}));
|
||||
|
||||
const cbSel = e_({
|
||||
tag: "input",
|
||||
clazz: "no-events",
|
||||
type: "checkbox",
|
||||
});
|
||||
|
||||
const ptCategory = brew.head.isLocal
|
||||
? {short: `Local`, title: `Local Document`}
|
||||
: brew.head.isEditable
|
||||
? {short: `Editable`, title: `Editable Document`}
|
||||
: {short: `Standard`, title: `Standard Document`};
|
||||
|
||||
const eleLi = e_({
|
||||
tag: "div",
|
||||
clazz: `manbrew__row ve-flex-v-center lst__row lst--border lst__row-inner no-shrink py-1 no-select`,
|
||||
children: [
|
||||
e_({
|
||||
tag: "label",
|
||||
clazz: `ve-col-0-5 ve-flex-vh-center ve-self-flex-stretch`,
|
||||
children: [cbSel],
|
||||
}),
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `ve-col-1 ve-text-center italic mobile__text-clip-ellipsis`,
|
||||
title: ptCategory.title,
|
||||
text: ptCategory.short,
|
||||
}),
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `ve-col-9 ve-flex-col`,
|
||||
children: elesSub,
|
||||
}),
|
||||
e_({
|
||||
tag: "div",
|
||||
clazz: `ve-col-1-5 btn-group ve-flex-vh-center`,
|
||||
children: [
|
||||
btnPull,
|
||||
btnEdit,
|
||||
btnPullEditPlaceholder,
|
||||
btnDownload,
|
||||
btnViewJson,
|
||||
btnOpenMenu,
|
||||
btnDelete,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const listItem = new ListItem(
|
||||
ix,
|
||||
eleLi,
|
||||
brewName,
|
||||
{
|
||||
authors: rowsSubMetas.map(it => it.authorsFull).join(", "),
|
||||
abbreviation: rowsSubMetas.map(it => it.abbreviation).join(", "),
|
||||
},
|
||||
{
|
||||
cbSel,
|
||||
},
|
||||
);
|
||||
|
||||
eleLi.addEventListener("click", evt => rdState.listSelectClickHandler.handleSelectClick(listItem, evt, {isPassThroughEvents: true}));
|
||||
|
||||
const rowMeta = {
|
||||
listItem,
|
||||
menu: null,
|
||||
};
|
||||
return rowMeta;
|
||||
}
|
||||
|
||||
static _pRender_getBtnPlaceholder () {
|
||||
return e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default btn-xs mobile__hidden w-24p`,
|
||||
html: " ",
|
||||
})
|
||||
.attr("disabled", true);
|
||||
}
|
||||
|
||||
_pRender_getBtnPull ({rdState, brew}) {
|
||||
if (!this._isBrewOperationPermitted_update(brew)) return null;
|
||||
|
||||
const btnPull = e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default btn-xs mobile__hidden w-24p`,
|
||||
title: this._LBL_LIST_UPDATE,
|
||||
children: [
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: "glyphicon glyphicon-refresh manbrew-row__icn-btn",
|
||||
}),
|
||||
],
|
||||
click: () => this._pRender_pDoPullBrew({rdState, brew}),
|
||||
});
|
||||
if (!this._brewUtil.isPullable(brew)) btnPull.attr("disabled", true).attr("title", `(Update disabled\u2014no URL available)`);
|
||||
return btnPull;
|
||||
}
|
||||
|
||||
_pRender_getBtnEdit ({rdState, brew}) {
|
||||
if (!brew.head.isEditable) return null;
|
||||
|
||||
return e_({
|
||||
tag: "button",
|
||||
clazz: `btn btn-default btn-xs mobile__hidden w-24p`,
|
||||
title: this._LBL_LIST_MANAGE_CONTENTS,
|
||||
children: [
|
||||
e_({
|
||||
tag: "span",
|
||||
clazz: "glyphicon glyphicon-pencil manbrew-row__icn-btn",
|
||||
}),
|
||||
],
|
||||
click: () => this._pRender_pDoEditBrew({rdState, brew}),
|
||||
});
|
||||
}
|
||||
|
||||
async _pRender_pDoPullBrew ({rdState, brew}) {
|
||||
const isPull = await this._brewUtil.pPullBrew(brew);
|
||||
|
||||
JqueryUtil.doToast(
|
||||
isPull
|
||||
? `${this._brewUtil.DISPLAY_NAME.uppercaseFirst()} updated!`
|
||||
: `${this._brewUtil.DISPLAY_NAME.uppercaseFirst()} is already up-to-date.`,
|
||||
);
|
||||
|
||||
if (!isPull) return;
|
||||
|
||||
await this._pRender_pBrewList(rdState);
|
||||
}
|
||||
|
||||
async _pRender_pDoEditBrew ({rdState, brew}) {
|
||||
const {isDirty, brew: nxtBrew} = await ManageEditableBrewContentsUi.pDoOpen({brewUtil: this._brewUtil, brew, isModal: this._isModal});
|
||||
if (!isDirty) return;
|
||||
|
||||
await this._brewUtil.pUpdateBrew(nxtBrew);
|
||||
await this._pRender_pBrewList(rdState);
|
||||
}
|
||||
|
||||
async _pRender_pDoDownloadBrew ({brew, brewName = null}) {
|
||||
const filename = (brew.head.filename || "").split(".").slice(0, -1).join(".");
|
||||
|
||||
// For the editable brew, if there are multiple sources, present the user with a selection screen. We then filter
|
||||
// the editable brew down to whichever sources they selected.
|
||||
const isChooseSources = brew.head.isEditable && (brew.body._meta?.sources || []).length > 1;
|
||||
|
||||
if (!isChooseSources) {
|
||||
const outFilename = filename || brewName || this.constructor._getBrewName(brew);
|
||||
const json = brew.head.isEditable ? MiscUtil.copyFast(brew.body) : brew.body;
|
||||
this.constructor._mutExportableEditableData({json: json});
|
||||
return DataUtil.userDownload(outFilename, json, {isSkipAdditionalMetadata: true});
|
||||
}
|
||||
|
||||
// region Get chosen sources
|
||||
const getSourceAsText = source => `[${(source.abbreviation || "").qq()}] ${(source.full || "").qq()}`;
|
||||
|
||||
const choices = await InputUiUtil.pGetUserMultipleChoice({
|
||||
title: `Choose Sources`,
|
||||
values: brew.body._meta.sources,
|
||||
fnDisplay: getSourceAsText,
|
||||
isResolveItems: true,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
isSearchable: true,
|
||||
fnGetSearchText: getSourceAsText,
|
||||
});
|
||||
if (choices == null || choices.length === 0) return;
|
||||
// endregion
|
||||
|
||||
// region Filter output by selected sources
|
||||
const cpyBrew = MiscUtil.copyFast(brew.body);
|
||||
const sourceAllowlist = new Set(choices.map(it => it.json));
|
||||
|
||||
cpyBrew._meta.sources = cpyBrew._meta.sources.filter(it => sourceAllowlist.has(it.json));
|
||||
|
||||
Object.entries(cpyBrew)
|
||||
.forEach(([k, v]) => {
|
||||
if (!v || !(v instanceof Array)) return;
|
||||
if (k.startsWith("_")) return;
|
||||
cpyBrew[k] = v.filter(it => {
|
||||
const source = SourceUtil.getEntitySource(it);
|
||||
if (!source) return true;
|
||||
return sourceAllowlist.has(source);
|
||||
});
|
||||
});
|
||||
// endregion
|
||||
|
||||
const reducedFilename = filename || this.constructor._getBrewName({body: cpyBrew});
|
||||
|
||||
this.constructor._mutExportableEditableData({json: cpyBrew});
|
||||
|
||||
return DataUtil.userDownload(reducedFilename, cpyBrew, {isSkipAdditionalMetadata: true});
|
||||
}
|
||||
|
||||
/**
|
||||
* The editable brew may contain `uniqueId` references from the builder, which should be stripped before export.
|
||||
*/
|
||||
static _mutExportableEditableData ({json}) {
|
||||
Object.values(json)
|
||||
.forEach(arr => {
|
||||
if (arr == null || !(arr instanceof Array)) return;
|
||||
arr.forEach(ent => delete ent.uniqueId);
|
||||
});
|
||||
return json;
|
||||
}
|
||||
|
||||
static _getBrewJsonTitle ({brew, brewName}) {
|
||||
brewName = brewName || this._getBrewName(brew);
|
||||
return brew.head.filename || brewName;
|
||||
}
|
||||
|
||||
_pRender_doViewBrew ({evt, brew, brewName}) {
|
||||
const title = this.constructor._getBrewJsonTitle({brew, brewName});
|
||||
const $content = Renderer.hover.$getHoverContent_statsCode(brew.body, {isSkipClean: true, title});
|
||||
Renderer.hover.getShowWindow(
|
||||
$content,
|
||||
Renderer.hover.getWindowPositionFromEvent(evt),
|
||||
{
|
||||
title,
|
||||
isPermanent: true,
|
||||
isBookContent: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async _pRender_pDoOpenBrewMenu ({evt, rdState, brew, brewName, rowMeta}) {
|
||||
rowMeta.menu = rowMeta.menu || this._pRender_getBrewMenu({rdState, brew, brewName});
|
||||
|
||||
await ContextUtil.pOpenMenu(evt, rowMeta.menu);
|
||||
}
|
||||
|
||||
_pRender_getBrewMenu ({rdState, brew, brewName}) {
|
||||
const menuItems = [];
|
||||
|
||||
if (this._isBrewOperationPermitted_update(brew)) {
|
||||
menuItems.push(
|
||||
new ContextUtil.Action(
|
||||
this._LBL_LIST_UPDATE,
|
||||
async () => this._pRender_pDoPullBrew({rdState, brew}),
|
||||
),
|
||||
);
|
||||
} else if (brew.head.isEditable) {
|
||||
menuItems.push(
|
||||
new ContextUtil.Action(
|
||||
this._LBL_LIST_MANAGE_CONTENTS,
|
||||
async () => this._pRender_pDoEditBrew({rdState, brew}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
menuItems.push(
|
||||
new ContextUtil.Action(
|
||||
this._LBL_LIST_EXPORT,
|
||||
async () => this._pRender_pDoDownloadBrew({brew, brewName}),
|
||||
),
|
||||
new ContextUtil.Action(
|
||||
this._LBL_LIST_VIEW_JSON,
|
||||
async evt => this._pRender_doViewBrew({evt, brew, brewName}),
|
||||
),
|
||||
);
|
||||
|
||||
if (this._brewUtil.IS_EDITABLE && this._isBrewOperationPermitted_moveToEditable(brew)) {
|
||||
menuItems.push(
|
||||
new ContextUtil.Action(
|
||||
this._LBL_LIST_MOVE_TO_EDITABLE,
|
||||
async () => this._pRender_pDoMoveToEditable({rdState, brews: [brew]}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (this._isBrewOperationPermitted_delete(brew)) {
|
||||
menuItems.push(
|
||||
new ContextUtil.Action(
|
||||
this._LBL_LIST_DELETE,
|
||||
async () => this._pRender_pDoDelete({rdState, brews: [brew]}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ContextUtil.getMenu(menuItems);
|
||||
}
|
||||
|
||||
_pGetUserBoolean_isMoveBrewsToEditable ({brews}) {
|
||||
return InputUiUtil.pGetUserBoolean({
|
||||
title: `Move to Editable ${this._brewUtil.DISPLAY_NAME.toTitleCase()} Document`,
|
||||
htmlDescription: `Moving ${brews.length === 1 ? `this ${this._brewUtil.DISPLAY_NAME}` : `these
|
||||
${this._brewUtil.DISPLAY_NAME_PLURAL}`} to the editable document will prevent ${brews.length === 1 ? "it" : "them"} from being automatically updated in future.<br>Are you sure you want to move ${brews.length === 1 ? "it" : "them"}?`,
|
||||
textYes: "Yes",
|
||||
textNo: "Cancel",
|
||||
});
|
||||
}
|
||||
|
||||
async _pRender_pDoMoveToEditable ({rdState, brews}) {
|
||||
if (!brews?.length) return;
|
||||
|
||||
if (!await this._pGetUserBoolean_isMoveBrewsToEditable({brews})) return;
|
||||
|
||||
await this._brewUtil.pMoveToEditable({brews});
|
||||
|
||||
await this._pRender_pBrewList(rdState);
|
||||
|
||||
JqueryUtil.doToast(`${`${brews.length === 1 ? this._brewUtil.DISPLAY_NAME : this._brewUtil.DISPLAY_NAME_PLURAL}`.uppercaseFirst()} moved to editable document!`);
|
||||
}
|
||||
|
||||
_pGetUserBoolean_isDeleteBrews ({brews}) {
|
||||
if (!brews.some(brew => brew.head.isEditable)) return true;
|
||||
|
||||
const htmlDescription = brews.length === 1
|
||||
? `This document contains all your locally-created or edited ${this._brewUtil.DISPLAY_NAME_PLURAL}.<br>Are you sure you want to delete it?`
|
||||
: `One of the documents you are about to delete contains all your locally-created or edited ${this._brewUtil.DISPLAY_NAME_PLURAL}.<br>Are you sure you want to delete these documents?`;
|
||||
|
||||
return InputUiUtil.pGetUserBoolean({
|
||||
title: `Delete ${this._brewUtil.DISPLAY_NAME}`,
|
||||
htmlDescription,
|
||||
textYes: "Yes",
|
||||
textNo: "Cancel",
|
||||
});
|
||||
}
|
||||
|
||||
async _pRender_pDoDelete ({rdState, brews}) {
|
||||
if (!brews?.length) return;
|
||||
|
||||
if (!await this._pGetUserBoolean_isDeleteBrews({brews})) return;
|
||||
|
||||
await this._brewUtil.pDeleteBrews(brews);
|
||||
|
||||
await this._pRender_pBrewList(rdState);
|
||||
}
|
||||
|
||||
_pRender_getProcBrew (brew) {
|
||||
brew = MiscUtil.copyFast(brew);
|
||||
brew.body._meta.sources.sort((a, b) => SortUtil.ascSortLower(a.full || "", b.full || ""));
|
||||
return brew;
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ const settingsGroupMarkdown = new ConfigSettingsGroup({
|
||||
new ConfigSettingEnum({
|
||||
configId: "tagRenderMode",
|
||||
name: `Tag Handling (<code>@tag</code>)`,
|
||||
help: `THe output to produce when rendering a 5etools "@tag".`,
|
||||
help: `The output to produce when rendering a 5etools "@tag".`,
|
||||
isRowLabel: true,
|
||||
default: "convertMarkdown",
|
||||
values: [
|
||||
|
||||
@@ -1987,7 +1987,11 @@ class DataLoader {
|
||||
}
|
||||
|
||||
static async pCacheAndGetHash (page, hash, opts) {
|
||||
const source = UrlUtil.decodeHash(hash).last();
|
||||
const {source} = UrlUtil.autoDecodeHash(hash, {page});
|
||||
if (!source) {
|
||||
if (opts.isRequired) throw new Error(`Could not find entity for page "${page}" with hash "${hash}"`);
|
||||
return null;
|
||||
}
|
||||
return DataLoader.pCacheAndGet(page, source, hash, opts);
|
||||
}
|
||||
|
||||
|
||||
41
js/utils-omnisearch.js
Normal file
41
js/utils-omnisearch.js
Normal file
@@ -0,0 +1,41 @@
|
||||
export class UtilsOmnisearch {
|
||||
static getUnpackedSearchResult (r) {
|
||||
const {
|
||||
s: source,
|
||||
sA: sourceAbvRaw,
|
||||
sF: sourceFullRaw,
|
||||
sC: sourceColor,
|
||||
p: page,
|
||||
r: isSrd,
|
||||
dP: isPartnered,
|
||||
h: isHoverable,
|
||||
c: category,
|
||||
u: hash,
|
||||
} = r;
|
||||
|
||||
const ptStyle = sourceColor
|
||||
? `style="${MiscUtil.getColorStylePart(sourceColor)}"`
|
||||
: source
|
||||
? Parser.sourceJsonToStyle(source)
|
||||
: "";
|
||||
|
||||
const sourceAbv = sourceAbvRaw || (source ? Parser.sourceJsonToAbv(source) : null);
|
||||
const sourceFull = sourceFullRaw || (source ? Parser.sourceJsonToFull(source) : null);
|
||||
|
||||
return {
|
||||
source,
|
||||
sourceColor,
|
||||
page,
|
||||
isSrd,
|
||||
isPartnered,
|
||||
isHoverable,
|
||||
category,
|
||||
hash,
|
||||
|
||||
// Derived
|
||||
ptStyle,
|
||||
sourceAbv,
|
||||
sourceFull,
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user