"use strict"; class FilterUtil {} FilterUtil.SUB_HASH_PREFIX_LENGTH = 4; class PageFilter { 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_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 } globalThis.PageFilter = PageFilter; class ModalFilter { static _$getFilterColumnHeaders (btnMeta) { return btnMeta.map((it, i) => $(``)); } /** * @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 $(`
`); } _$getColumnHeaderPreviewAll (opts) { return $(``); } /** * @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 = $(`
Loading...
`).appendTo($wrp); const $iptSearch = (opts.$iptSearch || $(``)).disableSpellcheck(); const $btnReset = opts.$btnReset || $(``); const $dispNumVisible = $(`
`); const $wrpIptSearch = $$`
${$iptSearch}
${$dispNumVisible}
`; const $wrpFormTop = $$`
${$wrpIptSearch}${$btnReset}
`; const $wrpFormBottom = opts.$wrpMiniPills || $(`
`); const $wrpFormHeaders = $(`
`); const $cbSelAll = opts.isBuildUi || this._isRadio ? null : $(``); const $btnSendAllToRight = opts.isBuildUi ? $(``) : null; if (!opts.isBuildUi) { if (this._isRadio) $wrpFormHeaders.append(``); else $$``.appendTo($wrpFormHeaders); } const $btnTogglePreviewAll = this._$getColumnHeaderPreviewAll(opts) .appendTo($wrpFormHeaders); this._$getColumnHeaders().forEach($ele => $wrpFormHeaders.append($ele)); if (opts.isBuildUi) $btnSendAllToRight.appendTo($wrpFormHeaders); const $wrpForm = $$`
${$wrpFormTop}${$wrpFormBottom}${$wrpFormHeaders}
`; const $wrpList = this._$getWrpList(); const $btnConfirm = opts.isBuildUi ? null : $(``); 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(FilterBox.EVNT_VALCHANGE, handleFilterChange); this._pageFilter.filterBox.render(); handleFilterChange(); $ovlLoading.remove(); const $wrpInner = $$`
${$wrpForm} ${$wrpList} ${opts.isBuildUi ? null : $$`
${$btnConfirm}
`}
`.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 || $(`
`); 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!`); } } globalThis.ModalFilter = ModalFilter; class FilterBox extends ProxyBase { static TITLE_BTN_RESET = "Reset filters. SHIFT to reset everything."; 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(FilterBox.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(FilterUtil.SUB_HASH_PREFIX_LENGTH)}${this._namespace ? `.${this._namespace}` : ""}`; } async pGetStoredActiveSources () { const stored = await StorageUtil.pGetForPage(this._getNamespacedStorageKey()); if (stored) { const sourceFilterData = stored.filters[FilterBox.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 = $(`
`).insertAfter(this._$wrpFormTop); } else { this._$wrpMiniPills.addClass("fltr__mini-view"); } } if (this._$btnReset) { this._$btnReset .title(FilterBox.TITLE_BTN_RESET) .click((evt) => this.reset(evt.shiftKey)); } if (this._$wrpFormTop || this._$btnToggleSummaryHidden) { if (!this._$btnToggleSummaryHidden) { this._$btnToggleSummaryHidden = $(``) .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 = $(``) .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 === FilterBox.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: ``}, ); this._compSearch._addHookBase("search", () => { const searchTerm = this._compSearch._state.search.toLowerCase(); this._filters.forEach(f => f.handleSearch(searchTerm)); }); const $btnShowAllFilters = $(``) .click(() => this.showAllFilters()); const $btnHideAllFilters = $(``) .click(() => this.hideAllFilters()); const $btnReset = $(``) .click(evt => this.reset(evt.shiftKey)); const $btnSettings = $(``) .click(() => this._pOpenSettingsModal()); const $btnSaveAlt = $(``) .click(() => this._modalMeta.doClose(true)); const $wrpBtnCombineFilters = $(`
`); const $btnCombineFilterSettings = $(``) .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 = $(``) .click(() => this._modalMeta.doClose(true)); const $btnCancel = $(``) .click(() => this._modalMeta.doClose(false)); $$(this._modalMeta.$modal)`

Filters

${this._metaIptSearch.$wrp.addClass("mobile__mb-2")}
Combine as
${$wrpBtnCombineFilters}
${$btnShowAllFilters} ${$btnHideAllFilters}
${$btnReset} ${$btnSettings} ${$btnSaveAlt}


${$children}

${$btnSave}${$btnCancel}
`; } 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(`Always Save on Close`); $(``) .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 = $(``) .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, FilterUtil.SUB_HASH_PREFIX_LENGTH); const urlHeader = hashKey.substring(prefixLen); if (FilterUtil.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 (FilterUtil.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: , value: }` 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.EVNT_VALCHANGE = "valchange"; FilterBox.SOURCE_HEADER = "Source"; 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, }; 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; } } 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 `${this.header}`; } 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: ``, 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, }; class Filter extends FilterBase { static _getAsFilterItems (items) { return items ? items.map(it => it instanceof FilterItem ? it : new FilterItem({item: it})) : null; } static _validateItemNests (items, nests) { if (!nests) return; items = items.filter(it => it.nest); const noNest = items.find(it => !nests[it.nest]); if (noNest) throw new Error(`Filter does not have matching nest: "${noNest.item}" (call addNest first)`); const invalid = items.find(it => !it.nest || !nests[it.nest]); if (invalid) throw new Error(`Invalid nest: "${invalid.item}"`); } /** A single-item version of the above, for performance. */ static _validateItemNest (item, nests) { if (!nests || !item.nest) return; if (!nests[item.nest]) throw new Error(`Filter does not have matching nest: "${item.item}" (call addNest first)`); if (!item.nest || !nests[item.nest]) throw new Error(`Invalid nest: "${item.item}"`); } /** * @param opts Options object. * @param opts.header Filter header (name) * @param [opts.headerHelp] Filter header help text (tooltip) * @param opts.items Array of filter items, either `FilterItem` or strings. e.g. `["DMG", "VGM"]` * @param [opts.nests] Key-value object of `"Nest Name": {...nestMeta}`. Nests are used to group/nest filters. * @param [opts.displayFn] Function which translates an item to a displayable form, e.g. `"MM` -> "Monster Manual"` * @param [opts.displayFnMini] Function which translates an item to a shortened displayable form, e.g. `"UABravoCharlie` -> "UABC"` * @param [opts.displayFnTitle] Function which translates an item to a form for displaying in a "title" tooltip * @param [opts.selFn] Function which returns true if an item should be displayed by default; false otherwise. * @param [opts.deselFn] Function which returns true if an item should be hidden by default; false otherwise. * @param [opts.itemSortFn] Function which should be used to sort the `items` array if new entries are added. * Defaults to ascending alphabetical sort. * @param [opts.itemSortFnMini] Function which should be used to sort the `items` array when rendering mini-pills. * @param [opts.groupFn] Function which takes an item and assigns it to a group. * @param [opts.groupNameFn] Function which takes a group and returns a group name; * @param [opts.minimalUi] True if the filter should render with a reduced UI, false otherwise. * @param [opts.umbrellaItems] Items which should, when set active, show everything in the filter. E.g. "All". * @param [opts.umbrellaExcludes] Items which should ignore the state of any `umbrellaItems` * @param [opts.isSortByDisplayItems] If items should be sorted by their display value, rather than their internal value. * @param [opts.isMiscFilter] If this is the Misc. filter (containing "SRD" and "Basic Rules" tags). */ constructor (opts) { super(opts); this._items = Filter._getAsFilterItems(opts.items || []); this.__itemsSet = new Set(this._items.map(it => it.item)); // Cache the items as a set for fast exists checking this._nests = opts.nests; this._displayFn = opts.displayFn; this._displayFnMini = opts.displayFnMini; this._displayFnTitle = opts.displayFnTitle; this._selFn = opts.selFn; this._selFnCache = null; this._deselFn = opts.deselFn; this._itemSortFn = opts.itemSortFn === undefined ? SortUtil.ascSort : opts.itemSortFn; this._itemSortFnMini = opts.itemSortFnMini; this._groupFn = opts.groupFn; this._groupNameFn = opts.groupNameFn; this._minimalUi = opts.minimalUi; this._umbrellaItems = Filter._getAsFilterItems(opts.umbrellaItems); this._umbrellaExcludes = Filter._getAsFilterItems(opts.umbrellaExcludes); this._isSortByDisplayItems = !!opts.isSortByDisplayItems; this._isReprintedFilter = !!opts.isMiscFilter && this._items.some(it => it.item === "Reprinted"); this._isSrdFilter = !!opts.isMiscFilter && this._items.some(it => it.item === "SRD"); this._isBasicRulesFilter = !!opts.isMiscFilter && this._items.some(it => it.item === "Basic Rules"); Filter._validateItemNests(this._items, this._nests); this._filterBox = null; this._items.forEach(it => this._defaultItemState(it, {isForce: true})); this.__$wrpFilter = null; this.__wrpPills = null; this.__wrpMiniPills = null; this.__$wrpNestHeadInner = null; this._updateNestSummary = null; this.__nestsHidden = {}; this._nestsHidden = this._getProxy("nestsHidden", this.__nestsHidden); this._isNestsDirty = false; this._isItemsDirty = false; this._pillGroupsMeta = {}; } get isReprintedFilter () { return this._isReprintedFilter; } get isSrdFilter () { return this._isSrdFilter; } get isBasicRulesFilter () { return this._isBasicRulesFilter; } getSaveableState () { return { [this.header]: { ...this.getBaseSaveableState(), state: {...this.__state}, nestsHidden: {...this.__nestsHidden}, }, }; } 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); Object.assign(this._nestsHidden, toLoad.nestsHidden); } _getStateNotDefault ({nxtState = null} = {}) { const state = nxtState?.[this.header]?.state || this.__state; return Object.entries(state) .filter(([k, v]) => { if (k.startsWith("_")) return false; const defState = this._getDefaultState(k); return defState !== v; }); } getSubHashes () { const out = []; const baseMeta = this.getMetaSubHashes(); if (baseMeta) out.push(...baseMeta); const areNotDefaultState = this._getStateNotDefault(); 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)); } const areNotDefaultNestsHidden = Object.entries(this._nestsHidden).filter(([k, v]) => this._nests[k] && !(this._nests[k].isHidden === v)); if (areNotDefaultNestsHidden.length) { // serialize nestsHidden as `key=value` pairs const nestsHidden = areNotDefaultNestsHidden.map(([k]) => `${k.toUrlified()}=1`); out.push(UrlUtil.packSubHash(this.getSubHashPrefix("nestsHidden", this.header), nestsHidden)); } if (!out.length) return null; // Always extend default state out.push(UrlUtil.packSubHash(this.getSubHashPrefix("options", this.header), ["extend"])); return out; } 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(([k]) => !k.startsWith("_")) .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 if (!areNotDefaultState.length) return null; const ptState = Object.entries(state) .filter(([k]) => !k.startsWith("_")) .filter(([, v]) => v) .map(([k, v]) => { const item = this._items.find(item => `${item.item}` === k); if (!item) return null; // Should never occur return `${v === 2 ? "not " : ""}${this._displayFn ? this._displayFn(item.item, item) : item.item}`; }) .filter(Boolean) .join(", "); if (!ptState) return null; return `${this.header}: ${ptState}`; } /** * Get transient options used when setting state from URL. * @private */ _getOptionsFromSubHashState (state) { // `flopsource:thing1~thing2` => `{options: ["thing1", "thing2"]}` const opts = {}; Object.entries(state).forEach(([k, vals]) => { const prop = FilterBase.getProp(k); switch (prop) { case "options": { vals.forEach(val => { switch (val) { case "extend": { opts.isExtendDefaultState = true; } } }); } } }); return new FilterTransientOptions(opts); } setStateFromNextState (nxtState) { super.setStateFromNextState(nxtState); this._proxyAssignSimple("nestsHidden", nxtState[this.header].nestsHidden, true); } getNextStateFromSubhashState (state) { const nxtState = this._getNextState_base(); if (state == null) { this._mutNextState_reset(nxtState); return nxtState; } this._mutNextState_meta_fromSubHashState(nxtState, state); const transientOptions = this._getOptionsFromSubHashState(state); let hasState = false; let hasNestsHidden = false; Object.entries(state).forEach(([k, vals]) => { const prop = FilterBase.getProp(k); switch (prop) { case "state": { hasState = true; if (transientOptions.isExtendDefaultState) { Object.keys(nxtState[this.header].state).forEach(k => nxtState[this.header].state[k] = this._getDefaultState(k)); } else { // This allows e.g. @filter tags to cleanly specify their sources 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); }); break; } case "nestsHidden": { hasNestsHidden = true; Object.keys(nxtState[this.header].nestsHidden).forEach(k => { const nestKey = Object.keys(this._nests).find(it => k.toLowerCase() === it.toLowerCase()); nxtState[this.header].nestsHidden[k] = this._nests[nestKey] && this._nests[nestKey].isHidden; }); vals.forEach(v => { const [nestNameLower, state] = v.split("="); const nestName = Object.keys(nxtState[this.header].nestsHidden).find(k => k.toLowerCase() === nestNameLower); if (nestName) nxtState[this.header].nestsHidden[nestName] = !!Number(state); }); break; } } }); if (!hasState) this._mutNextState_reset(nxtState); if (!hasNestsHidden && this._nests) this._mutNextState_resetNestsHidden({tgt: nxtState[this.header].nestsHidden}); return nxtState; } setFromValues (values) { if (values[this.header]) { Object.keys(this._state).forEach(k => this._state[k] = 0); Object.assign(this._state, values[this.header]); } } setValue (k, v) { this._state[k] = v; } _mutNextState_resetNestsHidden ({tgt}) { if (!this._nests) return; Object.entries(this._nests).forEach(([nestName, nestMeta]) => tgt[nestName] = !!nestMeta.isHidden); } _defaultItemState (item, {isForce = false} = {}) { // Avoid setting state for new items if the user already has active filter state. This prevents the case where e.g.: // - The user has cleared their source filter; // - A new source is added to the site; // - The new source becomes the *only* selected item in their filter. if (!isForce && this._hasUserSavedState && !Object.values(this.__state).some(Boolean)) return this._state[item.item] = 0; // if both a selFn and a deselFn are specified, we default to deselecting this._state[item.item] = this._getDefaultState(item.item); } _getDefaultState (k) { return this._deselFn && this._deselFn(k) ? 2 : this._selFn && this._selFn(k) ? 1 : 0; } _getDisplayText (item) { return this._displayFn ? this._displayFn(item.item, item) : item.item; } _getDisplayTextMini (item) { return this._displayFnMini ? this._displayFnMini(item.item, item) : this._getDisplayText(item); } _getPill (item) { const displayText = this._getDisplayText(item); const btnPill = e_({ tag: "div", clazz: "fltr__pill", html: displayText, click: evt => this._getPill_handleClick({evt, item}), contextmenu: evt => this._getPill_handleContextmenu({evt, item}), }); this._getPill_bindHookState({btnPill, item}); item.searchText = displayText.toLowerCase(); return btnPill; } _getPill_handleClick ({evt, item}) { if (evt.shiftKey) { this._doSetPillsClear(); } if (++this._state[item.item] > 2) this._state[item.item] = 0; } _getPill_handleContextmenu ({evt, item}) { evt.preventDefault(); if (evt.shiftKey) { this._doSetPillsClear(); } if (--this._state[item.item] < 0) this._state[item.item] = 2; } _getPill_bindHookState ({btnPill, item}) { this._addHook("state", item.item, () => { const val = FilterBox._PILL_STATES[this._state[item.item]]; btnPill.attr("state", val); })(); } setTempFnSel (tempFnSel) { this._selFnCache = this._selFnCache || this._selFn; if (tempFnSel) this._selFn = tempFnSel; else this._selFn = this._selFnCache; } updateMiniPillClasses () { this._items.filter(it => it.btnMini).forEach(it => { const isDefaultDesel = this._deselFn && this._deselFn(it.item); const isDefaultSel = this._selFn && this._selFn(it.item); it.btnMini .toggleClass("fltr__mini-pill--default-desel", isDefaultDesel) .toggleClass("fltr__mini-pill--default-sel", isDefaultSel); }); } _getBtnMini (item) { const toDisplay = this._getDisplayTextMini(item); const btnMini = e_({ tag: "div", clazz: `fltr__mini-pill ${this._filterBox.isMinisHidden(this.header) ? "ve-hidden" : ""} ${this._deselFn && this._deselFn(item.item) ? "fltr__mini-pill--default-desel" : ""} ${this._selFn && this._selFn(item.item) ? "fltr__mini-pill--default-sel" : ""}`, html: toDisplay, title: `${this._displayFnTitle ? `${this._displayFnTitle(item.item, item)} (` : ""}Filter: ${this.header}${this._displayFnTitle ? ")" : ""}`, click: () => { this._state[item.item] = 0; this._filterBox.fireChangeEvent(); }, }).attr("state", FilterBox._PILL_STATES[this._state[item.item]]); const hook = () => { const val = FilterBox._PILL_STATES[this._state[item.item]]; btnMini.attr("state", val); // Bind change handlers in the mini-pill render step, as the mini-pills should always be available. if (item.pFnChange) item.pFnChange(item.item, val); }; this._addHook("state", item.item, hook); const hideHook = () => btnMini.toggleClass("ve-hidden", this._filterBox.isMinisHidden(this.header)); this._filterBox.registerMinisHiddenHook(this.header, hideHook); return btnMini; } _doSetPillsAll () { this._proxyAssignSimple( "state", Object.keys(this._state) .mergeMap(k => ({[k]: 1})), true, ); } _doSetPillsClear () { this._proxyAssignSimple( "state", Object.keys(this._state) .mergeMap(k => ({[k]: 0})), true, ); } _doSetPillsNone () { this._proxyAssignSimple( "state", Object.keys(this._state) .mergeMap(k => ({[k]: 2})), true, ); } _doSetPinsDefault () { this.reset(); } _getHeaderControls (opts) { const btnAll = e_({ tag: "button", clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} fltr__h-btn--all w-100`, click: () => this._doSetPillsAll(), html: "All", }); 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 btnNone = e_({ tag: "button", clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} fltr__h-btn--none w-100`, click: () => this._doSetPillsNone(), html: "None", }); const btnDefault = e_({ tag: "button", clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} w-100`, click: () => this._doSetPinsDefault(), html: "Default", }); 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: [ btnAll, btnClear, btnNone, btnDefault, ], }), ], }); this._getHeaderControls_addExtraStateBtns(opts, wrpStateBtnsOuter); const wrpSummary = e_({tag: "div", clazz: "ve-flex-vh-center ve-hidden"}); const btnCombineBlue = e_({ tag: "button", clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} fltr__h-btn-logic--blue fltr__h-btn-logic w-100`, click: () => this._meta.combineBlue = Filter._getNextCombineMode(this._meta.combineBlue), title: `Blue match mode for this filter. "AND" requires all blues to match, "OR" requires at least one blue to match, "XOR" requires exactly one blue to match.`, }); const hookCombineBlue = () => e_({ele: btnCombineBlue, text: `${this._meta.combineBlue}`.toUpperCase()}); this._addHook("meta", "combineBlue", hookCombineBlue); hookCombineBlue(); const btnCombineRed = e_({ tag: "button", clazz: `btn btn-default ${opts.isMulti ? "btn-xxs" : "btn-xs"} fltr__h-btn-logic--red fltr__h-btn-logic w-100`, click: () => this._meta.combineRed = Filter._getNextCombineMode(this._meta.combineRed), title: `Red match mode for this filter. "AND" requires all reds to match, "OR" requires at least one red to match, "XOR" requires exactly one red to match.`, }); const hookCombineRed = () => e_({ele: btnCombineRed, text: `${this._meta.combineRed}`.toUpperCase()}); this._addHook("meta", "combineRed", hookCombineRed); hookCombineRed(); 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); // render summary const cur = this.getValues()[this.header]; const htmlSummary = [ cur._totals.yes ? `${cur._totals.yes}` : null, cur._totals.yes && cur._totals.no ? `` : null, cur._totals.no ? `${cur._totals.no}` : null, ].filter(Boolean).join(""); e_({ele: wrpSummary, html: htmlSummary}).toggleVe(this._meta.isHidden); }; this._addHook("meta", "isHidden", hookShowHide); hookShowHide(); return e_({ tag: "div", clazz: `ve-flex-v-center fltr__h-wrp-btns-outer`, children: [ wrpSummary, wrpStateBtnsOuter, e_({tag: "span", clazz: `btn-group ml-2 ve-flex-v-center`, children: [btnCombineBlue, btnCombineRed]}), btnShowHide, ], }); } _getHeaderControls_addExtraStateBtns () { // To be optionally implemented by child classes } /** * @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 = opts.$wrpMini ? e_({ele: opts.$wrpMini[0]}) : null; const wrpControls = this._getHeaderControls(opts); if (this._nests) { const wrpNestHead = e_({tag: "div", clazz: "fltr__wrp-pills--sub"}).appendTo(this.__wrpPills); this.__$wrpNestHeadInner = e_({tag: "div", clazz: "ve-flex ve-flex-wrap fltr__container-pills"}).appendTo(wrpNestHead); const wrpNestHeadSummary = e_({tag: "div", clazz: "fltr__summary_nest"}).appendTo(wrpNestHead); this._updateNestSummary = () => { const stats = {high: 0, low: 0}; this._items.filter(it => this._state[it.item] && this._nestsHidden[it.nest]).forEach(it => { const key = this._state[it.item] === 1 ? "high" : "low"; stats[key]++; }); wrpNestHeadSummary.empty(); if (stats.high) { e_({ tag: "span", clazz: "fltr__summary_item fltr__summary_item--include", text: stats.high, title: `${stats.high} hidden "required" tag${stats.high === 1 ? "" : "s"}`, }).appendTo(wrpNestHeadSummary); } if (stats.high && stats.low) e_({tag: "span", clazz: "fltr__summary_item_spacer"}).appendTo(wrpNestHeadSummary); if (stats.low) { e_({ tag: "span", clazz: "fltr__summary_item fltr__summary_item--exclude", text: stats.low, title: `${stats.low} hidden "excluded" tag${stats.low === 1 ? "" : "s"}`, }).appendTo(wrpNestHeadSummary); } }; this._doRenderNests(); } this._doRenderPills(); const btnMobToggleControls = this._getBtnMobToggleControls(wrpControls); this.__$wrpFilter = $$`
${opts.isFirst ? "" : `
`}
${opts.isMulti ? `\u2012` : ""} ${this._getRenderedHeader()} ${btnMobToggleControls}
${wrpControls}
${this.__wrpPills}
`; this._doToggleDisplay(); return this.__$wrpFilter; } /** * @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) { if (!opts.$wrpMini) return; this._filterBox = opts.filterBox; this.__wrpMiniPills = e_({ele: opts.$wrpMini[0]}); this._renderMinis_initWrpPills(); this._doRenderMiniPills(); } _renderMinis_initWrpPills () { this.__wrpPills = e_({tag: "div", clazz: `fltr__wrp-pills ${this._groupFn ? "fltr__wrp-subs" : "fltr__container-pills"}`}); const hook = () => this.__wrpPills.toggleVe(!this._meta.isHidden); this._addHook("meta", "isHidden", hook); hook(); } getValues ({nxtState = null} = {}) { const state = MiscUtil.copy(nxtState?.[this.header]?.state || this.__state); const meta = nxtState?.[this.header]?.meta || this.__meta; // remove state for any currently-absent filters Object.keys(state).filter(k => !this._items.some(it => `${it.item}` === k)).forEach(k => delete state[k]); const out = {...state}; // add helper data out._isActive = Object.values(state).some(Boolean); out._totals = {yes: 0, no: 0, ignored: 0}; Object.values(state).forEach(v => { const totalKey = v === 0 ? "ignored" : v === 1 ? "yes" : "no"; out._totals[totalKey]++; }); out._combineBlue = meta.combineBlue; out._combineRed = meta.combineRed; return {[this.header]: out}; } _getNextState_base () { return { [this.header]: { ...super._getNextState_base()[this.header], nestsHidden: MiscUtil.copyFast(this.__nestsHidden), }, }; } _mutNextState_reset (nxtState, {isResetAll = false} = {}) { if (isResetAll) { this._mutNextState_resetBase(nxtState); this._mutNextState_resetNestsHidden({tgt: nxtState[this.header].nestsHidden}); } else { // Always reset "AND/OR" states Object.assign(nxtState[this.header].meta, {combineBlue: Filter._DEFAULT_META.combineBlue, combineRed: Filter._DEFAULT_META.combineRed}); } Object.keys(nxtState[this.header].state).forEach(k => delete nxtState[this.header].state[k]); this._items.forEach(item => nxtState[this.header].state[item.item] = this._getDefaultState(item.item)); } _doRenderPills () { if (this._itemSortFn) this._items.sort(this._isSortByDisplayItems && this._displayFn ? (a, b) => this._itemSortFn(this._displayFn(a.item, a), this._displayFn(b.item, b)) : this._itemSortFn); this._items.forEach(it => { if (!it.rendered) { it.rendered = this._getPill(it); if (it.nest) { const hook = () => it.rendered.toggleVe(!this._nestsHidden[it.nest]); this._addHook("nestsHidden", it.nest, hook); hook(); } } if (this._groupFn) { const group = this._groupFn(it); this._doRenderPills_doRenderWrpGroup(group); this._pillGroupsMeta[group].wrpPills.append(it.rendered); } else it.rendered.appendTo(this.__wrpPills); }); } _doRenderPills_doRenderWrpGroup (group) { const existingMeta = this._pillGroupsMeta[group]; if (existingMeta && !existingMeta.isAttached) { existingMeta.wrpDivider.appendTo(this.__wrpPills); existingMeta.wrpPills.appendTo(this.__wrpPills); existingMeta.isAttached = true; } if (existingMeta) return; this._pillGroupsMeta[group] = { wrpDivider: this._doRenderPills_doRenderWrpGroup_getDivider(group).appendTo(this.__wrpPills), wrpPills: this._doRenderPills_doRenderWrpGroup_getWrpPillsSub(group).appendTo(this.__wrpPills), isAttached: true, }; Object.entries(this._pillGroupsMeta) .sort((a, b) => SortUtil.ascSortLower(a[0], b[0])) .forEach(([groupKey, groupMeta], i) => { groupMeta.wrpDivider.appendTo(this.__wrpPills); groupMeta.wrpDivider.toggleVe(!this._isGroupDividerHidden(groupKey, i)); groupMeta.wrpPills.appendTo(this.__wrpPills); }); if (this._nests) { this._pillGroupsMeta[group].toggleDividerFromNestVisibility = () => { this._pillGroupsMeta[group].wrpDivider.toggleVe(!this._isGroupDividerHidden(group)); }; // bind group dividers to show/hide depending on nest visibility state Object.keys(this._nests).forEach(nestName => { const hook = () => this._pillGroupsMeta[group].toggleDividerFromNestVisibility(); this._addHook("nestsHidden", nestName, hook); hook(); this._pillGroupsMeta[group].toggleDividerFromNestVisibility(); }); } } _isGroupDividerHidden (group, ixSortedGroups) { if (!this._nests) { // When not nested, always hide the first divider if (ixSortedGroups === undefined) return `${group}` === `${Object.keys(this._pillGroupsMeta).sort((a, b) => SortUtil.ascSortLower(a, b))[0]}`; return ixSortedGroups === 0; } const groupItems = this._items.filter(it => this._groupFn(it) === group); const hiddenGroupItems = groupItems.filter(it => this._nestsHidden[it.nest]); return groupItems.length === hiddenGroupItems.length; } _doRenderPills_doRenderWrpGroup_getDivider (group) { const eleHr = this._doRenderPills_doRenderWrpGroup_getDividerHr(group); const elesHeader = this._doRenderPills_doRenderWrpGroup_getDividerHeaders(group); return e_({ tag: "div", clazz: "ve-flex-col w-100", children: [ eleHr, ...elesHeader, ] .filter(Boolean), }); } _doRenderPills_doRenderWrpGroup_getDividerHr (group) { return e_({tag: "hr", clazz: `fltr__dropdown-divider--sub hr-2 mx-3`}); } _doRenderPills_doRenderWrpGroup_getDividerHeaders (group) { const groupName = this._groupNameFn?.(group); if (!groupName) return []; return [ e_({ tag: "div", clazz: `fltr__divider-header ve-muted italic ve-small`, text: groupName, }), ]; } _doRenderPills_doRenderWrpGroup_getWrpPillsSub () { return e_({tag: "div", clazz: `fltr__wrp-pills--sub fltr__container-pills`}); } _doRenderMiniPills () { // create a list view so we can freely sort const view = this._items.slice(0); if (this._itemSortFnMini || this._itemSortFn) { const fnSort = this._itemSortFnMini || this._itemSortFn; view.sort(this._isSortByDisplayItems && this._displayFn ? (a, b) => fnSort(this._displayFn(a.item, a), this._displayFn(b.item, b)) : fnSort); } if (this.__wrpMiniPills) { view.forEach(it => { // re-append existing elements to sort them (it.btnMini = it.btnMini || this._getBtnMini(it)).appendTo(this.__wrpMiniPills); }); } } _doToggleDisplay () { // if there are no items, hide everything if (this.__$wrpFilter) this.__$wrpFilter.toggleClass("fltr__no-items", !this._items.length); } _doRenderNests () { Object.entries(this._nests) .sort((a, b) => SortUtil.ascSort(a[0], b[0])) // array 0 (key) is the nest name .forEach(([nestName, nestMeta]) => { if (nestMeta._$btnNest == null) { // this can be restored from a saved state, otherwise, initialise it if (this._nestsHidden[nestName] == null) this._nestsHidden[nestName] = !!nestMeta.isHidden; const $btnText = $(`${nestName} [${this._nestsHidden[nestName] ? "+" : "\u2212"}]`); nestMeta._$btnNest = $$`
${$btnText}
` .click(() => this._nestsHidden[nestName] = !this._nestsHidden[nestName]); const hook = () => { $btnText.text(`${nestName} [${this._nestsHidden[nestName] ? "+" : "\u2212"}]`); const stats = {high: 0, low: 0, total: 0}; this._items .filter(it => it.nest === nestName) .find(it => { const key = this._state[it.item] === 1 ? "high" : this._state[it.item] ? "low" : "ignored"; stats[key]++; stats.total++; }); const allHigh = stats.total === stats.high; const allLow = stats.total === stats.low; nestMeta._$btnNest.toggleClass("fltr__btn_nest--include-all", this._nestsHidden[nestName] && allHigh) .toggleClass("fltr__btn_nest--exclude-all", this._nestsHidden[nestName] && allLow) .toggleClass("fltr__btn_nest--include", this._nestsHidden[nestName] && !!(!allHigh && !allLow && stats.high && !stats.low)) .toggleClass("fltr__btn_nest--exclude", this._nestsHidden[nestName] && !!(!allHigh && !allLow && !stats.high && stats.low)) .toggleClass("fltr__btn_nest--both", this._nestsHidden[nestName] && !!(!allHigh && !allLow && stats.high && stats.low)); if (this._updateNestSummary) this._updateNestSummary(); }; this._items .filter(it => it.nest === nestName) .find(it => { this._addHook("state", it.item, hook); }); this._addHook("nestsHidden", nestName, hook); hook(); } nestMeta._$btnNest.appendTo(this.__$wrpNestHeadInner); }); if (this._updateNestSummary) this._updateNestSummary(); } update () { if (this._isNestsDirty) { this._isNestsDirty = false; this._doRenderNests(); } 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(); this._doToggleDisplay(); } _getFilterItem (item) { return item instanceof FilterItem ? item : new FilterItem({item}); } 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.__itemsSet.has(item.item || item)) { item = this._getFilterItem(item); Filter._validateItemNest(item, this._nests); this._isItemsDirty = true; this._items.push(item); this.__itemsSet.add(item.item); if (this._state[item.item] == null) this._defaultItemState(item); } } addNest (nestName, nestMeta) { // may need to allow this in future // can easily be circumvented by initialising with empty nests in filter construction if (!this._nests) throw new Error(`Filter was not nested!`); if (!this._nests[nestName]) { this._isNestsDirty = true; this._nests[nestName] = nestMeta; // bind group dividers to show/hide based on the new nest if (this._groupFn) { Object.keys(this._pillGroupsMeta).forEach(group => { const hook = () => this._pillGroupsMeta[group].toggleDividerFromNestVisibility(); this._addHook("nestsHidden", nestName, hook); hook(); this._pillGroupsMeta[group].toggleDividerFromNestVisibility(); }); } } } _toDisplay_getMappedEntryVal (entryVal) { if (!(entryVal instanceof Array)) entryVal = [entryVal]; entryVal = entryVal.map(it => it instanceof FilterItem ? it : new FilterItem({item: it})); return entryVal; } _toDisplay_getFilterState (boxState) { return boxState[this.header]; } toDisplay (boxState, entryVal) { const filterState = this._toDisplay_getFilterState(boxState); if (!filterState) return true; const totals = filterState._totals; entryVal = this._toDisplay_getMappedEntryVal(entryVal); const isUmbrella = () => { if (this._umbrellaItems) { if (!entryVal) return false; if (this._umbrellaExcludes && this._umbrellaExcludes.some(it => filterState[it.item])) return false; return this._umbrellaItems.some(u => entryVal.includes(u.item)) && (this._umbrellaItems.some(u => filterState[u.item] === 0) || this._umbrellaItems.some(u => filterState[u.item] === 1)); } }; let hide = false; let display = false; switch (filterState._combineBlue) { case "or": { // default to displaying if (totals.yes === 0) display = true; // if any are 1 (blue) include if they match display = display || entryVal.some(fi => filterState[fi.item] === 1 || isUmbrella()); break; } case "xor": { // default to displaying if (totals.yes === 0) display = true; // if any are 1 (blue) include if precisely one matches display = display || entryVal.filter(fi => filterState[fi.item] === 1 || isUmbrella()).length === 1; break; } case "and": { const totalYes = entryVal.filter(fi => filterState[fi.item] === 1).length; display = !totals.yes || totals.yes === totalYes; break; } default: throw new Error(`Unhandled combine mode "${filterState._combineBlue}"`); } switch (filterState._combineRed) { case "or": { // if any are 2 (red) exclude if they match hide = hide || entryVal.filter(fi => !fi.isIgnoreRed).some(fi => filterState[fi.item] === 2); break; } case "xor": { // if exactly one is 2 (red) exclude if it matches hide = hide || entryVal.filter(fi => !fi.isIgnoreRed).filter(fi => filterState[fi.item] === 2).length === 1; break; } case "and": { const totalNo = entryVal.filter(fi => !fi.isIgnoreRed).filter(fi => filterState[fi.item] === 2).length; hide = totals.no && totals.no === totalNo; break; } default: throw new Error(`Unhandled combine mode "${filterState._combineRed}"`); } return display && !hide; } _doInvertPins () { const cur = MiscUtil.copy(this._state); Object.keys(this._state).forEach(k => this._state[k] = cur[k] === 1 ? 0 : 1); } getDefaultMeta () { // Key order is important, as @filter tags depend on it return { ...super.getDefaultMeta(), ...Filter._DEFAULT_META, }; } handleSearch (searchTerm) { const isHeaderMatch = this.header.toLowerCase().includes(searchTerm); if (isHeaderMatch) { this._items.forEach(it => { if (!it.rendered) return; it.rendered.toggleClass("fltr__hidden--search", false); }); if (this.__$wrpFilter) this.__$wrpFilter.toggleClass("fltr__hidden--search", false); return true; } let visibleCount = 0; this._items.forEach(it => { if (!it.rendered) return; const isVisible = it.searchText.includes(searchTerm); it.rendered.toggleClass("fltr__hidden--search", !isVisible); if (isVisible) visibleCount++; }); if (this.__$wrpFilter) this.__$wrpFilter.toggleClass("fltr__hidden--search", visibleCount === 0); return visibleCount !== 0; } static _getNextCombineMode (combineMode) { let ix = Filter._COMBINE_MODES.indexOf(combineMode); if (ix === -1) ix = (Filter._COMBINE_MODES.length - 1); if (++ix === Filter._COMBINE_MODES.length) ix = 0; return Filter._COMBINE_MODES[ix]; } _doTeardown () { this._items.forEach(it => { if (it.rendered) it.rendered.detach(); if (it.btnMini) it.btnMini.detach(); }); Object.values(this._nests || {}) .filter(nestMeta => nestMeta._$btnNest) .forEach(nestMeta => nestMeta._$btnNest.detach()); Object.values(this._pillGroupsMeta || {}) .forEach(it => { it.wrpDivider.detach(); it.wrpPills.detach(); it.isAttached = false; }); } } Filter._DEFAULT_META = { combineBlue: "or", combineRed: "or", }; Filter._COMBINE_MODES = ["or", "and", "xor"]; class FilterTransientOptions { /** * @param opts Options object. * @param [opts.isExtendDefaultState] */ constructor (opts) { this.isExtendDefaultState = opts.isExtendDefaultState; } } 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: ``, }, ); 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 = $$``.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) => `${m[0]}`); }); // 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 = ""; }); } } globalThis.SearchableFilter = SearchableFilter; 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; } } 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; const isBrewSource = BrewUtil2.hasSourceJson(item); const isNonStandardSource = !isBrewSource && (SourceUtil.isNonstandardSource(item) || PrereleaseUtil.hasSourceJson(item)); return ` ${Parser.sourceJsonToAbv(item)}`; } constructor (opts) { opts = opts || {}; opts.header = opts.header === undefined ? FilterBox.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 ? PageFilter.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 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: ``, 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); } _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_NON_STANDARD: return this._doRenderPills_doRenderWrpGroup_getDividerHeaders_groupNonStandard(group); case SourceUtil.FILTER_GROUP_HOMEBREW: return this._doRenderPills_doRenderWrpGroup_getDividerHeaders_groupBrew(group); default: return super._doRenderPills_doRenderWrpGroup_getDividerHeaders(group); } } _doRenderPills_doRenderWrpGroup_getDividerHeaders_groupNonStandard (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.isNonstandardSource(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.isNonstandardSource(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; 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 = $$`
${opts.isFirst ? "" : `
`}
${opts.isMulti ? `\u2012` : ""}${this._getRenderedHeader()}${btnMobToggleControls}
${wrpControls}
${this.__wrpPills}
`; 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 ? `${cur._totals.yes}` : 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}`; } } globalThis.AbilityScoreFilter = AbilityScoreFilter; 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}); } }; 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: $(``), stateName: "meta", stateProp: "_meta", }, ); const $btnReset = $(``).click(() => this.reset()); const $wrpBtns = $$`
${$btnForceMobile}${$btnReset}
`; const $wrpSummary = $(`
`).hideVe(); const $btnShowHide = $(``) .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 $$`
${$wrpBtns} ${$wrpSummary} ${$btnShowHide}
`; } _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 = $$`
`; const $wrpDropdowns = $$`
`; 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; }, }); $$`
${selMin}${selMax}
`.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 = $$`
${this._getRenderedHeader()}
${$wrpSlider} ${$wrpDropdowns}
`; } else { const btnMobToggleControls = this._getBtnMobToggleControls($wrpControls); return this.__$wrpFilter = $$`
${opts.isFirst ? "" : `
`}
${this._getRenderedHeader()}${btnMobToggleControls}
${$wrpControls}
${$wrpSlider} ${$wrpDropdowns}
`; } } $renderMinis (opts) { if (!opts.$wrpMini) return; this._filterBox = opts.filterBox; this.__$wrpMini = opts.$wrpMini; // region Mini pills this._$btnMiniGt = this._$btnMiniGt || $(`
`) .click(() => { this._state.curMin = this._state.min; this._filterBox.fireChangeEvent(); }); this._$btnMiniGt.appendTo(this.__$wrpMini); this._$btnMiniLt = this._$btnMiniLt || $(`
`) .click(() => { this._state.curMax = this._state.max; this._filterBox.fireChangeEvent(); }); this._$btnMiniLt.appendTo(this.__$wrpMini); this._$btnMiniEq = this._$btnMiniEq || $(`
`) .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 += ``; } 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, }; 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 = $$`
${$btns}
`; if (opts.isMulti) { return this.__$wrpFilter = $$`
${this._getRenderedHeader()}
${$wrpButtons}
`; } else { return this.__$wrpFilter = $$`
${opts.isFirst ? "" : `
`}
${this._getRenderedHeader()}
${$wrpControls}
${$wrpButtons}
`; } } $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 = $(`
${displayText}
`) .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 = $(`
${displayText}
`) .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 = $(``).click(() => this.reset()); const $wrpBtns = $$`
${$btnReset}
`; const $wrpSummary = $(`
`).hideVe(); const $btnShowHide = $(``) .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 $$`
${$wrpBtns} ${$wrpSummary} ${$btnShowHide}
`; } 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 = {}; 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: $(``), 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 = $$`
${$children}
`; const wrpControls = this._getHeaderControls(opts); return this.__$wrpFilter = $$`
${opts.isFirst ? "" : `
`}
${this._getRenderedHeader()}
${btnAndOr}
${wrpControls}
${this._$wrpChildren}
`; } $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); } } MultiFilter._DETAULT_STATE = { mode: "and", }; // validate subhash prefixes (() => { const boxPrefixes = Object.values(FilterBox._SUB_HASH_PREFIXES).filter(it => it.length !== FilterUtil.SUB_HASH_PREFIX_LENGTH); const filterPrefixes = Object.values(FilterBase._SUB_HASH_PREFIXES).filter(it => it.length !== FilterUtil.SUB_HASH_PREFIX_LENGTH); const allPrefixes = boxPrefixes.concat(filterPrefixes); if (allPrefixes.length) throw new Error(`Invalid prefixes! ${allPrefixes.map(it => `"${it}"`).join(", ")} ${allPrefixes.length === 1 ? `is` : `was`} not of length ${FilterUtil.SUB_HASH_PREFIX_LENGTH}`); })(); FilterUtil.SUB_HASH_PREFIXES = new Set([...Object.values(FilterBox._SUB_HASH_PREFIXES), ...Object.values(FilterBase._SUB_HASH_PREFIXES)]); globalThis.FilterUtil = FilterUtil; globalThis.PageFilter = PageFilter; globalThis.FilterBox = FilterBox; globalThis.FilterItem = FilterItem; globalThis.FilterBase = FilterBase; globalThis.Filter = Filter; globalThis.SourceFilter = SourceFilter; globalThis.RangeFilter = RangeFilter; globalThis.OptionsFilter = OptionsFilter; globalThis.MultiFilter = MultiFilter;