"use strict"; class UtilClassesPage { static getColorStyleClasses (entry, {isForceStandardSource, prefix, isSubclass} = {}) { if (isSubclass) { if (entry.isClassFeatureVariant) { if (entry.source && !isForceStandardSource && BrewUtil2.hasSourceJson(entry.source)) return [`${prefix}feature-variant-brew-subclass`]; if (entry.source && !isForceStandardSource && (SourceUtil.isNonstandardSource(entry.source) || PrereleaseUtil.hasSourceJson(entry.source))) return [`${prefix}feature-variant-ua-subclass`]; return [`${prefix}feature-variant-subclass`]; } if (entry.isReprinted) { if (entry.source && !isForceStandardSource && BrewUtil2.hasSourceJson(entry.source)) return [`${prefix}feature-brew-subclass-reprint`]; if (entry.source && !isForceStandardSource && (SourceUtil.isNonstandardSource(entry.source) || PrereleaseUtil.hasSourceJson(entry.source))) return [`${prefix}feature-ua-subclass-reprint`]; return [`${prefix}feature-subclass-reprint`]; } if (entry.source && !isForceStandardSource && BrewUtil2.hasSourceJson(entry.source)) return [`${prefix}feature-brew-subclass`]; if (entry.source && !isForceStandardSource && (SourceUtil.isNonstandardSource(entry.source) || PrereleaseUtil.hasSourceJson(entry.source))) return [`${prefix}feature-ua-subclass`]; return [`${prefix}feature-subclass`]; } if (entry.isClassFeatureVariant) { if (entry.source && !isForceStandardSource && BrewUtil2.hasSourceJson(entry.source)) return [`${prefix}feature-variant-brew`]; if (entry.source && !isForceStandardSource && (SourceUtil.isNonstandardSource(entry.source) || PrereleaseUtil.hasSourceJson(entry.source))) return [`${prefix}feature-variant-ua`]; return [`${prefix}feature-variant`]; } if (entry.source && !isForceStandardSource && BrewUtil2.hasSourceJson(entry.source)) return [`${prefix}feature-brew`]; if (entry.source && !isForceStandardSource && (SourceUtil.isNonstandardSource(entry.source) || PrereleaseUtil.hasSourceJson(entry.source))) return [`${prefix}feature-ua`]; return []; } /* -------------------------------------------- */ static setRenderFnGetStyleClasses (cls) { // Add extra classses to our features as we render them Renderer.get() .setFnGetStyleClasses(UrlUtil.PG_CLASSES, (entry) => { if (typeof entry === "string") return null; const sc = entry.subclassShortName ? (cls.subclasses || []).find(it => it.shortName === entry.subclassShortName && it.source === entry.subclassSource) : null; const isForceStandardSource = sc ? sc._isStandardSource : (entry.source === cls.source); return UtilClassesPage.getColorStyleClasses(entry, {isSubclass: !!entry.subclassShortName, isForceStandardSource, prefix: "cls__"}); }); } static unsetRenderFnGetStyleClasses () { Renderer.get() .setFnGetStyleClasses(UrlUtil.PG_CLASSES, null); } /* -------------------------------------------- */ static getSubclassCssMod (cls, sc) { if (sc.source !== cls.source) { return BrewUtil2.hasSourceJson(sc.source) ? sc.isReprinted ? "rebrewed" : "brew" : (SourceUtil.isNonstandardSource(sc.source) || PrereleaseUtil.hasSourceJson(sc.source)) ? sc.isReprinted ? "stale" : "spicy" : sc.isReprinted ? "reprinted" : "fresh"; } return "fresh"; } /* -------------------------------------------- */ static _getRenderedClassSubclassFluff ( { ent, entFluff, depthArr = null, isRemoveRootName = false, isAddLeadingHr = false, isAddTrailingHr = false, isAddSourceNote = false, }, ) { entFluff = MiscUtil.copyFast(entFluff); const hasEntries = !!entFluff?.entries?.length; const hasImages = !!entFluff?.images?.length; let stack = ""; Renderer.get().setFirstSection(true); if (hasEntries) { const renderer = Renderer.get(); if (depthArr) renderer.setDepthTracker(depthArr, {additionalPropsInherited: ["_isStandardSource"]}); else renderer.setDepthTracker([]); entFluff.entries.filter(f => f.source === ent.source).forEach(f => f._isStandardSource = true); entFluff.entries.forEach((f, i) => { const cpy = MiscUtil.copyFast(f); // Remove the name from the first section if it is a copy of the class/subclass name if ( isRemoveRootName && i === 0 && cpy.name && ( cpy.name.toLowerCase() === ent.name.toLowerCase() || cpy.name.toLowerCase() === `the ${ent.name.toLowerCase()}` ) ) { delete cpy.name; } if ( isAddSourceNote && typeof cpy !== "string" && cpy.source && cpy.source !== ent.source && cpy.entries ) { cpy.entries.unshift(`{@note The following information is from ${Parser.sourceJsonToFull(cpy.source)}${Renderer.utils.isDisplayPage(cpy.page) ? `, page ${cpy.page}` : ""}.}`); } stack += renderer.render(cpy); }); } if (hasImages) { if (hasEntries) { stack += `
`; } this._getFluffLayoutImages(entFluff.images) .forEach(ent => stack += Renderer.get().render(ent)); } if (hasImages || hasEntries) { if (isAddLeadingHr) stack = Renderer.get().render({type: "hr"}) + stack; if (isAddTrailingHr) stack += Renderer.get().render({type: "hr"}); } return { hasEntries, hasImages, rendered: stack || null, }; } static getRenderedClassFluff ( { cls, clsFluff, depthArr = null, isRemoveRootName = false, isAddTrailingHr = false, }, ) { return this._getRenderedClassSubclassFluff({ ent: cls, entFluff: clsFluff, depthArr, isRemoveRootName, isAddTrailingHr, isAddSourceNote: true, }); } static getRenderedSubclassFluff ( { sc, scFluff, depthArr = null, }, ) { return this._getRenderedClassSubclassFluff({ ent: sc, entFluff: scFluff, depthArr, isAddLeadingHr: true, isAddTrailingHr: true, }); } static _getFluffLayoutImages (images) { if (images.length === 1) { return [ { maxWidth: "98", maxWidthUnits: "%", ...images[0], }, ]; } return [ { type: "gallery", images: [...images], }, ]; } } class ClassesPage extends MixinComponentGlobalState(MixinBaseComponent(MixinProxyBase(ListPage))) { static _ascSortSubclasses (scA, scB) { return SortUtil.ascSortLower(scA.name, scB.name); } static _fnSortSubclassFilterItems (a, b) { if (a.values.isAlwaysVisible) return 1; else if (b.values.isAlwaysVisible) return -1; else return SortUtil.listSort(a, b, {sortBy: "shortName"}); } static getBtnTitleSubclass (sc) { const titlePartReprint = sc.isReprinted ? " (this subclass has been reprinted in a more recent source)" : ""; const sourcePart = Renderer.utils.getSourceAndPageText(sc); return `${sc.name}; Source: ${sourcePart}${titlePartReprint}`; } static getBaseShortName (sc) { const re = new RegExp(`\\((UA|${sc.source})\\)$`); return sc.shortName.trim().replace(re, "").trim(); } constructor () { super({}); // Don't include classId in the main state/proxy, as we want special handling for it as the main hash part this.__classId = {_: 0}; this._classId = this._getProxy("classId", this.__classId); this._list = null; this._ixData = 0; this._dataList = []; this._lastScrollFeature = null; this._outlineData = {}; this._pageFilter = new PageFilterClasses(); // region subclass list/filter this._listSubclass = null; // endregion this._fnTableHandleFilterChange = null; this._$wrpOutline = null; this._fnOutlineHandleFilterChange = null; this._$trNoContent = null; // region alternate views this._subclassComparisonView = null; this._classBookView = null; // endregion // region Active class data filtering this._activeClassDataFiltered = null; // endregion } get activeClass () { if (this._activeClassDataFiltered) return this._activeClassDataFiltered; return this.activeClassRaw; } get activeClassRaw () { return this._dataList[this._classId._]; } get filterBox () { return this._filterBox; } async pOnLoad () { Hist.setListPage(this); this._$pgContent = $(`#pagecontent`); await Promise.all([ PrereleaseUtil.pInit(), BrewUtil2.pInit(), ]); await ExcludeUtil.pInitialise(); Omnisearch.addScrollTopFloat(); const data = await DataUtil.class.loadJSON(); const $btnReset = $("#reset"); this._list = this._initList({ $iptSearch: $("#lst__search"), $wrpList: $(`.list.classes`), $btnReset, $btnClear: $(`#lst__search-glass`), dispPageTagline: document.getElementById(`page__subtitle`), isBindFindHotkey: true, optsList: { isUseJquery: true, }, }); SortUtil.initBtnSortHandlers($("#filtertools"), this._list); this._filterBox = await this._pageFilter.pInitFilterBox({ $iptSearch: $(`#lst__search`), $wrpFormTop: $(`#filter-search-group`), $btnReset, }); this._addData(data); await this._pAddPrereleaseData(); await this._pAddBrewData(); this._pageFilter.trimState(); ManageBrewUi.bindBtnOpen($(`#manage-brew`)); this._renderListFeelingLucky({isCompact: true, $btnReset}); window.onhashchange = this._handleHashChange.bind(this); this._list.init(); $(`.initial-message`).text(`Select a class from the list to view it here`); // Silently prepare our initial state this._setClassFromHash(Hist.initialLoad); this._setStateFromHash(Hist.initialLoad); await this._pInitAndRunRender(); ListPage._checkShowAllExcluded(this._dataList, this._$pgContent); this._initLinkGrabbers(); this._initScrollToSubclassSelection(); this._bindLinkExportButton({$btn: $(`#btn-link-export`)}); this._doBindBtnSettingsSidebar(); Hist.initialLoad = false; // Finally, ensure the hash correctly matches the state this._setHashFromState(true); window.dispatchEvent(new Event("toolsLoaded")); } async _pAddPrereleaseData () { await this._pAddPrereleaseBrewData({prereleaseBrewData: await DataUtil.class.loadPrerelease()}); } async _pAddBrewData () { await this._pAddPrereleaseBrewData({prereleaseBrewData: await DataUtil.class.loadBrew()}); } async _pAddPrereleaseBrewData ({prereleaseBrewData}) { const {isAddedAnyClass, isAddedAnySubclass} = this._addData(prereleaseBrewData); if (isAddedAnySubclass && !Hist.initialLoad) await this._pDoRender(); } _addData (data) { let isAddedAnyClass = false; let isAddedAnySubclass = false; if (data.class && data.class.length) { isAddedAnyClass = true; this._addData_addClassData(data); } if (data.subclass && data.subclass.length) { isAddedAnySubclass = true; this._addData_addSubclassData(data); } const walker = MiscUtil.getWalker({ keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST, isNoModification: true, }); // region Add to filters, and handle post-subclass-load mutations this._dataList.forEach(cls => { this._pageFilter.constructor.mutateForFilters(cls); // Force data on any classes with unusual sources to behave as though they have normal sources if (SourceUtil.isNonstandardSource(cls.source) || PrereleaseUtil.hasSourceJson(cls.source) || BrewUtil2.hasSourceJson(cls.source)) { cls.subclasses.filter(sc => sc.source === cls.source).forEach(sc => sc._isStandardSource = true); } // Add "reprinted" flags to subclass features of reprinted subclasses, to use when coloring headers if (cls.subclasses?.length) { cls.subclasses .filter(sc => sc.isReprinted && sc.subclassFeatures?.length) .forEach(sc => { walker.walk( sc.subclassFeatures, { object: (obj) => { if (obj.level == null) return; obj.isReprinted = true; }, }, ); }); } const isExcluded = ExcludeUtil.isExcluded(UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES](cls), "class", cls.source); // Build a map of subclass source => subclass name => is excluded const subclassExclusions = {}; (cls.subclasses || []).forEach(sc => { if (isExcluded) return; (subclassExclusions[sc.source] = subclassExclusions[sc.source] || {})[sc.name] = subclassExclusions[sc.source][sc.name] || this.constructor.isSubclassExcluded_(cls, sc); }); this._pageFilter.addToFilters(cls, isExcluded, {subclassExclusions}); }); // endregion if (isAddedAnyClass || isAddedAnySubclass) { this._list.update(); this.filterBox.render(); this._handleFilterChange(false); } return {isAddedAnyClass, isAddedAnySubclass}; } _addData_addClassData (data) { data.class.filter(cls => cls.subclasses).forEach(cls => cls.subclasses.sort(ClassesPage._ascSortSubclasses)); this._dataList.push(...data.class); const len = this._dataList.length; for (; this._ixData < len; this._ixData++) { const it = this._dataList[this._ixData]; const isExcluded = ExcludeUtil.isExcluded(UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES](it), "class", it.source); this._list.addItem(this.getListItem(it, this._ixData, isExcluded)); } } _addData_addSubclassData (data) { let isBlankSourceFilter; if (!Hist.initialLoad) { isBlankSourceFilter = !this._pageFilter.sourceFilter.getValues()._isActive; } data.subclass.forEach(sc => { if (sc.className === VeCt.STR_GENERIC || sc.classSource === VeCt.STR_GENERIC) return; const cls = this._dataList.find(c => c.name.toLowerCase() === sc.className.toLowerCase() && c.source.toLowerCase() === (sc.classSource || Parser.SRC_PHB).toLowerCase()); if (!cls) { JqueryUtil.doToast({ content: `Could not add subclass; could not find class with name: ${sc.className} and source ${sc.classSource || Parser.SRC_PHB}`, type: "danger", }); return; } const isExcludedClass = ExcludeUtil.isExcluded(UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES](cls), "class", cls.source); (cls.subclasses = cls.subclasses || []).push(sc); // Don't bother checking subclass exclusion for individually-added subclasses, as they should be from homebrew this._pageFilter.mutateAndAddToFilters(cls, isExcludedClass); cls.subclasses.sort(ClassesPage._ascSortSubclasses); }); // If we load a homebrew source when we have no source filters active, the homebrew source will set itself high // and force itself as the only visible source. Fix it in post. if (isBlankSourceFilter) this._pageFilter.sourceFilter.doSetPillsClear(); } _initHashAndStateSync () { // Wipe all hooks, as we redo them for each class render this._resetHooks("state"); this._resetHooksAll("state"); this._resetHooks("classId"); // Don't reset hooksAll for classId, as we use this to render the class this._addHookAll("state", () => this._setHashFromState()); } _setHashFromState (isSuppressHistory) { // During the initial load, force-suppress all changes if (isSuppressHistory === undefined) isSuppressHistory = Hist.initialLoad; const nxtHash = this._getHashState(); const rawLocation = window.location.hash; const location = rawLocation[0] === "#" ? rawLocation.slice(1) : rawLocation; if (nxtHash !== location) { if (isSuppressHistory) Hist.replaceHistoryHash(nxtHash); else window.location.hash = nxtHash; } } _handleHashChange () { // Parity with the implementation in hist.js if (Hist.isHistorySuppressed) return Hist.setSuppressHistory(false); this._setClassFromHash(); this._setStateFromHash(); } _setClassFromHash (isInitialLoad) { const [link] = Hist.getHashParts(); let ixToLoad; if (link === HASH_BLANK) ixToLoad = -1; else { const listItem = Hist.getActiveListItem(link); if (listItem == null) ixToLoad = -1; else { const toLoad = listItem.ix; if (toLoad == null) ixToLoad = -1; else ixToLoad = listItem.ix; } } if (!~ixToLoad && this._list.visibleItems.length) ixToLoad = this._list.visibleItems[0].ix; if (~ixToLoad) { const target = isInitialLoad ? this.__classId : this._classId; if (target._ !== ixToLoad) { Hist.lastLoadedId = ixToLoad; const cls = this._dataList[ixToLoad]; document.title = `${cls ? cls.name : "Classes"} - 5etools`; this._updateSelected(); target._ = ixToLoad; } } else { // This should never occur (failed loads should pick the first list item), but attempt to handle it semi-gracefully this._$pgContent.empty().append(ClassesPage._render_$getTrNoContent()); JqueryUtil.doToast({content: "Could not find the class to load!", type: "error"}); } } _setStateFromHash (isInitialLoad) { let [_, ...subs] = Hist.getHashParts(); subs = this.filterBox.setFromSubHashes(subs); const target = isInitialLoad ? this.__state : this._state; // On changing class (class links have no state parts), clean "feature" state if (!subs.length) this.__state.feature = null; if (this._getHashState() === subs.join(HASH_PART_SEP)) return; const cls = this.activeClass; const validScLookup = {}; cls.subclasses.forEach(sc => validScLookup[UrlUtil.getStateKeySubclass(sc)] = sc); // Track any incoming sources we need to filter to enable in order to display the desired subclasses const requiredSources = new Set(); const seenKeys = new Set(); subs.forEach(sub => { const unpacked = UrlUtil.unpackSubHash(sub); if (!unpacked.state) return; unpacked.state.map(it => { let [k, v] = it.split("="); k = k.toLowerCase(); v = UrlUtil.mini.decompress(v); if (k.startsWith("sub")) { // subclass selection state keys if (validScLookup[k]) { if (target[k] !== v) target[k] = v; requiredSources.add(validScLookup[k].source); seenKeys.add(k); } } else { // known classes page state keys const knownKey = Object.keys(ClassesPage._DEFAULT_STATE).find(it => it.toLowerCase() === k); if (knownKey) { if (target[knownKey] !== v) target[knownKey] = v; seenKeys.add(knownKey); } } // else discard it }); }); Object.entries(ClassesPage._DEFAULT_STATE).forEach(([k, v]) => { // If we did not have a value for it, and the current state doesn't match the default, reset it if (!seenKeys.has(k) && v !== target[k]) target[k] = v; }); if (requiredSources.size) { const sourceFilterValues = this._pageFilter.sourceFilter.getValues().Source; if (sourceFilterValues._isActive) { // If the filter includes "blue" values, set our sources to be included if (sourceFilterValues._totals.yes > 0) { requiredSources.forEach(source => this._pageFilter.sourceFilter.setValue(source, 1)); } else { // if there are only "red"s active, disable them for our sources requiredSources.forEach(source => { if (sourceFilterValues[source] !== 0) this._pageFilter.sourceFilter.setValue(source, 0); }); } } } Object.keys(validScLookup).forEach(k => { if (!seenKeys.has(k) && target[k]) target[k] = false; }); // Run the sync in the other direction, a loop that *should* break once the hash/state match perfectly if (!isInitialLoad) this._setHashFromState(); } /** * @param [opts] Options object. * @param [opts.class] Class to convert to hash. * @param [opts.state] State to convert to hash. */ _getHashState (opts) { opts = opts || {}; let fromState = opts.state || MiscUtil.copyFast(this.__state); let cls = opts.class || this.activeClass; // region class let primaryHash = cls ? UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES](cls) : null; if (!primaryHash) { const firstItem = this._list.items[0]; primaryHash = firstItem ? firstItem.values.hash : HASH_BLANK; } // endregion // region state const validScKeys = cls.subclasses.map(sc => UrlUtil.getStateKeySubclass(sc)); const stateParts = Object.entries(fromState) .filter(([k, v]) => ClassesPage._DEFAULT_STATE[k] !== v) // ignore any default values .filter(([k, v]) => !(ClassesPage._DEFAULT_STATE[k] === undefined && !v)) // ignore any falsey values which don't have defaults .filter(([k]) => { // Filter out any junky subclasses/those from other classes if (!k.startsWith("sub")) return true; return validScKeys.includes(k); }) .map(([k, v]) => `${k}=${UrlUtil.mini.compress(v)}`); const stateHash = stateParts.length ? UrlUtil.packSubHash("state", stateParts) : ""; // endregion const hashParts = [ primaryHash, stateHash, ].filter(Boolean); return Hist.util.getCleanHash(hashParts.join(HASH_PART_SEP)); } _initLinkGrabbers () { const $body = $(document.body); $body.on(`mousedown`, `.cls-main__linked-titles > td > * > .rd__h .entry-title-inner`, (evt) => evt.preventDefault()); $body.on(`click`, `.cls-main__linked-titles > td > * > .rd__h .entry-title-inner`, async (evt) => { const $target = $(evt.target); if (evt.shiftKey) { await MiscUtil.pCopyTextToClipboard($target.text().replace(/\.$/, "")); JqueryUtil.showCopiedEffect($target); } else { const featureId = $target.closest(`tr`).attr("data-scroll-id"); const curState = MiscUtil.copyFast(this.__state); curState.feature = featureId; const href = `${window.location.href.split("#")[0]}#${this._getHashState({state: curState})}`; await MiscUtil.pCopyTextToClipboard(href); JqueryUtil.showCopiedEffect($target, "Copied link!"); } }); } _initScrollToSubclassSelection () { const $wrp = $(`#subclasstabs`); $(document.body).on(`click`, `[data-jump-select-a-subclass]`, evt => { $wrp[0].scrollIntoView({block: "center", inline: "center"}); }); } _doBindBtnSettingsSidebar () { const menu = ContextUtil.getMenu([ new ContextUtil.Action( "Toggle Spell Points Mode", () => { this._stateGlobal.isUseSpellPoints = !this._stateGlobal.isUseSpellPoints; }, ), ]); $(`#btn-sidebar-settings`).click(evt => ContextUtil.pOpenMenu(evt, menu)); } getListItem (cls, clsI, isExcluded) { const hash = UrlUtil.autoEncodeHash(cls); const source = Parser.sourceJsonToAbv(cls.source); const $lnk = $(` ${cls.name} ${source} `); const $ele = $$`
  • ${$lnk}
  • `; return new ListItem( clsI, $ele, cls.name, { hash, source, }, { $lnk, entity: cls, isExcluded, }, ); } _doGenerateFilteredActiveClassData () { const f = this.filterBox.getValues(); const cpyCls = MiscUtil.copyFast(this.activeClassRaw); const walker = Renderer.class.getWalkerFilterDereferencedFeatures(); const isUseSubclassSources = !this._pageFilter.isClassNaturallyDisplayed(f, cpyCls) && this._pageFilter.isAnySubclassDisplayed(f, cpyCls); Renderer.class.mutFilterDereferencedClassFeatures({ walker, cpyCls, pageFilter: this._pageFilter, filterValues: f, isUseSubclassSources, }); (cpyCls.subclasses || []).forEach(sc => { Renderer.class.mutFilterDereferencedSubclassFeatures({ walker, cpySc: sc, pageFilter: this._pageFilter, filterValues: f, }); }); this._activeClassDataFiltered = cpyCls; } _handleFilterChange (isFilterValueChange) { // If the filter values changes (i.e. we're not handling an initial load), mutate the state, and trigger a // re-render. if (isFilterValueChange) { this._doGenerateFilteredActiveClassData(); this._pDoSyncrinizedRender(); return; } const f = this.filterBox.getValues(); this._list.filter(item => this._pageFilter.toDisplay(f, item.data.entity, [], null)); if (this._fnOutlineHandleFilterChange) this._fnOutlineHandleFilterChange(); if (this._fnTableHandleFilterChange) this._fnTableHandleFilterChange(f); // Force-hide any subclasses which are filtered out this._proxyAssign( "state", "_state", "__state", this.activeClass.subclasses .filter(sc => !this._pageFilter.isSubclassVisible(f, this.activeClass, sc)) .map(sc => UrlUtil.getStateKeySubclass(sc)) .filter(stateKey => this._state[stateKey]) .mergeMap(stateKey => ({[stateKey]: false})), ); } async _pInitAndRunRender () { this._$wrpOutline = $(`#sticky-nav`); // Use hookAll to allow us to reset temp hooks on the property itself this._addHookAll("classId", async () => { this._doGenerateFilteredActiveClassData(); await this._pDoSyncrinizedRender(); }); this._doGenerateFilteredActiveClassData(); await this._pDoRender(); } async _pDoSyncrinizedRender () { await this._pLock("render"); try { await this._pDoRender(); } finally { this._unlock("render"); } } async _pDoRender () { // reset all hooks in preparation for rendering this._initHashAndStateSync(); this.filterBox .off(FilterBox.EVNT_VALCHANGE) .on(FilterBox.EVNT_VALCHANGE, () => this._handleFilterChange(true)); // region bind list updates const hkSetHref = () => { // defer this for performance setTimeout(() => { this._list.items .filter(it => it.data.$lnk) .forEach(it => { const href = `#${this._getHashState({class: it.data.entity})}`; it.data.$lnk.attr("href", href); }); }, 5); }; this._addHook("classId", "_", hkSetHref); this._addHookAll("state", hkSetHref); hkSetHref(); // endregion // region rendering this._render_renderClassTable(); this._render_renderSidebar(); await this._render_pRenderSubclassTabs(); await this._render_pRenderClassContent(); this._render_renderOutline(); this._render_renderAltViews(); // endregion // region state handling const hkScrollToFeature = () => { // `state.feature` is set by clicking links in the class feature table if (this._state.feature) { // track last scrolled, otherwise *any* further hash/state change will cause us to scroll if (this._lastScrollFeature === this._state.feature) return; this._lastScrollFeature = this._state.feature; const $scrollTo = $(`[data-scroll-id="${this._state.feature}"]`); if (!$scrollTo[0]) { // This should never occur, but just in case, clean up this._state.feature = null; this._lastScrollFeature = null; } else { setTimeout(() => $scrollTo[0].scrollIntoView(), 100); } } }; this._addHookBase("feature", hkScrollToFeature); hkScrollToFeature(); const hkDisplayFluff = () => { $(`.cls-main__cls-fluff`).toggleVe(!!this._state.isShowFluff); if (!this._state.isShowFluff) { $(`.cls-main__sc-fluff`).hideVe(); } else { $(`.cls-main__sc-fluff`) .each((i, e) => { const $e = $(e); $e.toggleVe(!!this._state[$e.attr("data-subclass-id-fluff")]); }); } }; this._addHookBase("isShowFluff", hkDisplayFluff); MiscUtil.pDefer(hkDisplayFluff); const hkletDoToggleNoneSubclassMessages = (cntDisplayedSubclasses) => $(`[data-subclass-none-message]`).toggleVe(!cntDisplayedSubclasses && !this._state.isHideFeatures); const hkDisplayFeatures = () => { const cntDisplayedSubclasses = this.activeClass.subclasses.map(sc => Number(this._state[UrlUtil.getStateKeySubclass(sc)] || false)).sum(); const $dispClassFeatures = $(`[data-feature-type="class"]`); const $dispFeaturesSubclassHeader = $(`[data-feature-type="gain-subclass"]`); if (this._state.isHideFeatures) { if (this._isAnySubclassActive()) { this._$wrpOutline.toggleVe(true); this._$trNoContent.toggleVe(false); $dispClassFeatures.toggleVe(false); $dispFeaturesSubclassHeader.toggleVe(true); } else { this._$wrpOutline.toggleVe(false); this._$trNoContent.toggleVe(true); $dispClassFeatures.toggleVe(false); $dispFeaturesSubclassHeader.toggleVe(false); } } else { this._$wrpOutline.toggleVe(true); this._$trNoContent.toggleVe(false); $dispClassFeatures.toggleVe(true); $dispFeaturesSubclassHeader.toggleVe(true); } hkletDoToggleNoneSubclassMessages(cntDisplayedSubclasses); }; this._addHookBase("isHideFeatures", hkDisplayFeatures); MiscUtil.pDefer(hkDisplayFeatures); const cls = this.activeClass; // If multiple subclasses are displayed, show name prefixes const hkIsShowNamePrefixes = () => { const cntDisplayedSubclasses = cls.subclasses.map(sc => Number(this._state[UrlUtil.getStateKeySubclass(sc)] || false)).sum(); $(`[data-subclass-name-prefix]`).toggleVe(cntDisplayedSubclasses > 1); hkletDoToggleNoneSubclassMessages(cntDisplayedSubclasses); }; const hkIsShowNamePrefixesThrottled = MiscUtil.throttle(hkIsShowNamePrefixes, 50); MiscUtil.pDefer(() => hkIsShowNamePrefixesThrottled); cls.subclasses .map(sc => { let isFirstRun = true; const stateKey = UrlUtil.getStateKeySubclass(sc); const hkDisplaySubclass = () => { isFirstRun = false; const isVisible = this._state[stateKey]; $(`[data-subclass-id="${stateKey}"]`).toggleVe(!!isVisible); $(`[data-subclass-id-fluff="${stateKey}"]`).toggleVe(!!isVisible && this._state.isShowFluff); if (!isFirstRun) hkIsShowNamePrefixes(); }; this._addHookBase(stateKey, hkDisplaySubclass); // Check/update main feature display here, as if there are no subclasses active we can hide more this._addHookBase(stateKey, hkDisplayFeatures); MiscUtil.pDefer(hkDisplaySubclass); }); // endregion this._handleFilterChange(false); } _isAnySubclassActive () { return !!this._getActiveSubclasses().length; } _getActiveSubclasses (asStateKeys) { const cls = this.activeClass; return cls.subclasses .filter(sc => this._state[UrlUtil.getStateKeySubclass(sc)]) .map(sc => asStateKeys ? UrlUtil.getStateKeySubclass(sc) : sc); } _render_renderClassTable () { const $wrpTblClass = $(`#classtable`).empty(); const cls = this.activeClass; Renderer.get().resetHeaderIndex(); const $tblGroupHeaders = []; const $tblHeaders = []; if (cls.classTableGroups) { cls.classTableGroups.forEach(tableGroup => this._render_renderClassTable_renderTableGroupHeader({$tblGroupHeaders, $tblHeaders, tableGroup})); } cls.subclasses.forEach(sc => { if (!sc.subclassTableGroups) return; const stateKey = UrlUtil.getStateKeySubclass(sc); sc.subclassTableGroups.forEach(tableGroup => this._render_renderClassTable_renderTableGroupHeader({$tblGroupHeaders, $tblHeaders, tableGroup, stateKey})); }); const metasTblRows = this._render_renderClassTable_getMetasTblRows({ cls, }); this._fnTableHandleFilterChange = (f) => { const cpyCls = MiscUtil.copyFast(this.activeClassRaw); const isUseSubclassSources = !this._pageFilter.isClassNaturallyDisplayed(f, cpyCls) && this._pageFilter.isAnySubclassDisplayed(f, cpyCls); metasTblRows.forEach(metaTblRow => { metaTblRow.metasFeatureLinks.forEach(metaFeatureLink => { if (metaFeatureLink.source) { const isHidden = ![metaFeatureLink.source, ...(metaFeatureLink.otherSources || []).map(it => it.source)] .some(src => this.filterBox.toDisplayByFilters( f, { filter: this._pageFilter.sourceFilter, value: isUseSubclassSources && src === cpyCls.source ? this._pageFilter.getActiveSource(f) : src, }, { filter: this._pageFilter.levelFilter, value: metaTblRow.level, }, )); metaFeatureLink.isHidden = isHidden; metaFeatureLink.$wrpLink.toggleVe(!isHidden); } }); metaTblRow.metasFeatureLinks.forEach(metaFeatureLink => metaFeatureLink.$dispComma.toggleVe(true)); const lastVisible = metaTblRow.metasFeatureLinks.filter(metaFeatureLink => !metaFeatureLink.isHidden).last(); if (lastVisible) lastVisible.$dispComma.hideVe(); }); }; $$` ${$tblHeaders} ${metasTblRows.map(it => it.$row)}
    ${cls.name}
    ${$tblGroupHeaders}
    Level Proficiency Bonus Features
    `.appendTo($wrpTblClass); $wrpTblClass.showVe(); } _render_renderClassTable_renderTableGroupHeader ( { $tblGroupHeaders, $tblHeaders, tableGroup, stateKey, }, ) { const colLabels = tableGroup.colLabels; // Render titles (top section) const $thGroupHeader = tableGroup.title ? $(`${tableGroup.title}`) // if there's no title, add a spacer : $(``); $tblGroupHeaders.push($thGroupHeader); // Render column headers (bottom section) const $tblHeadersGroup = colLabels .map(lbl => { const $tblHeader = $(`
    `) .fastSetHtml(Renderer.get().render(lbl)); $tblHeaders.push($tblHeader); return $tblHeader; }); // region If it's a "spell progression" group, i.e. one that can be switched for a "Spell Points" column, add // appropriate handling. let $thGroupHeaderSpellPoints = null; let $tblHeaderSpellPoints = null; let $tblHeaderSpellPointsMaxSpellLevel = null; if (tableGroup.rowsSpellProgression) { // This is always a "spacer" $thGroupHeaderSpellPoints = $(``); $tblGroupHeaders.push($thGroupHeaderSpellPoints); $tblHeaderSpellPoints = $(`
    `) .fastSetHtml(Renderer.get().render(`{@variantrule Spell Points}`)); $tblHeaders.push($tblHeaderSpellPoints); $tblHeaderSpellPointsMaxSpellLevel = $(`
    Spell Level
    `); $tblHeaders.push($tblHeaderSpellPointsMaxSpellLevel); const $elesDefault = [$thGroupHeader, ...$tblHeadersGroup]; const $elesSpellPoints = [$thGroupHeaderSpellPoints, $tblHeaderSpellPoints, $tblHeaderSpellPointsMaxSpellLevel]; const hkSpellPoints = () => { $elesDefault.forEach($it => $it.toggleClass(`cls-tbl__cell-spell-progression--spell-points-enabled`, this._stateGlobal.isUseSpellPoints)); $elesSpellPoints.forEach($it => $it.toggleClass(`cls-tbl__cell-spell-points--spell-points-enabled`, this._stateGlobal.isUseSpellPoints)); }; this._addHookGlobal("isUseSpellPoints", hkSpellPoints); hkSpellPoints(); } // endregion // If there is a state key, this is a subclass table group, and may therefore need to be hidden if (!stateKey) return; const $elesSubclass = [ $thGroupHeader, ...$tblHeadersGroup, $thGroupHeaderSpellPoints, $tblHeaderSpellPoints, $tblHeaderSpellPointsMaxSpellLevel, ].filter(Boolean); const hkShowHide = () => $elesSubclass.forEach($ele => $ele.toggleVe(!!this._state[stateKey])); this._addHookBase(stateKey, hkShowHide); MiscUtil.pDefer(hkShowHide); } _render_renderClassTable_getMetasTblRows ( { cls, }, ) { return cls.classFeatures.map((lvlFeatures, ixLvl) => { const pb = Math.ceil((ixLvl + 1) / 4) + 1; const lvlFeaturesFilt = lvlFeatures .filter(it => it.name && it.type !== "inset"); // don't add inset entry names to class table const metasFeatureLinks = lvlFeaturesFilt .map((it, ixFeature) => { const featureId = `${ixLvl}-${ixFeature}`; const $lnk = $(`${it._displayNameTable || it._displayName || it.name}`) .click(() => { this._lastScrollFeature = null; this._state.feature = null; this._state.feature = featureId; }); const hkSetHref = () => { // defer this for performance setTimeout(() => { // these will modify this._state.feature when clicked const curState = MiscUtil.copyFast(this.__state); curState.feature = featureId; const href = `#${this._getHashState({state: curState})}`; $lnk.attr("href", href); }, 5); }; this._addHookAll("state", hkSetHref); hkSetHref(); // Make a dummy for the last item const $dispComma = ixFeature === lvlFeaturesFilt.length - 1 ? $(``) : $(`,`); return { $wrpLink: $$`
    ${$lnk}${$dispComma}
    `, $dispComma, source: it.source, otherSources: it.otherSources, isHidden: false, }; }); const $ptTableGroups = []; if (cls.classTableGroups) { const $cells = cls.classTableGroups .map(tableGroup => this._render_renderClassTable_renderTableGroupRow({tableGroup, ixLvl})) .flat(); Array.prototype.push.apply($ptTableGroups, $cells); } cls.subclasses.forEach(sc => { if (!sc.subclassTableGroups) return; const stateKey = UrlUtil.getStateKeySubclass(sc); const $cells = sc.subclassTableGroups .map(tableGroup => this._render_renderClassTable_renderTableGroupRow({tableGroup, stateKey, ixLvl, sc})) .flat(); Array.prototype.push.apply($ptTableGroups, $cells); }); return { $row: $$` ${Parser.getOrdinalForm(ixLvl + 1)} +${pb} ${metasFeatureLinks.length ? metasFeatureLinks.map(it => it.$wrpLink) : `\u2014`} ${$ptTableGroups} `, metasFeatureLinks, level: ixLvl + 1, }; }); } _render_renderClassTable_renderTableGroupRow ( { ixLvl, tableGroup, stateKey, sc, }, ) { const $cells = tableGroup.rowsSpellProgression?.[ixLvl] ? this._render_renderClassTable_$getSpellProgressionCells({ixLvl, tableGroup, sc}) : this._render_renderClassTable_$getGenericRowCells({ixLvl, tableGroup}); if (!stateKey) return $cells; // If there is a state key, this is a subclass table group, and may therefore need to be hidden const hkShowHide = () => $cells.forEach($cell => $cell.toggleVe(!!this._state[stateKey])); this._addHookBase(stateKey, hkShowHide); MiscUtil.pDefer(hkShowHide); // saves ~10ms return $cells; } _render_renderClassTable_$getGenericRowCells ( { ixLvl, tableGroup, propRows = "rows", }, ) { const row = tableGroup[propRows][ixLvl] || []; return row.map(cell => { const td = e_({ tag: "td", clazz: "cls-tbl__col-generic-center", html: cell === 0 ? "\u2014" : Renderer.get().render(cell), }); return $(td); }); } _render_renderClassTable_$getSpellProgressionCells ( { ixLvl, tableGroup, sc, }, ) { const $cellsDefault = this._render_renderClassTable_$getGenericRowCells({ ixLvl, tableGroup, propRows: "rowsSpellProgression", }); const row = tableGroup.rowsSpellProgression[ixLvl] || []; const spellPoints = row .map((countSlots, ix) => { const spellLevel = ix + 1; return Parser.spLevelToSpellPoints(spellLevel) * countSlots; }) .sum(); const $cellSpellPoints = $(e_({ tag: "td", clazz: "cls-tbl__col-generic-center cls-tbl__cell-spell-points", html: spellPoints === 0 ? "\u2014" : spellPoints, })); const ixLastSpellNum = row.findIndex(num => num === 0); const maxSpellLevel = !~ixLastSpellNum ? row.length : !ixLastSpellNum ? 0 : ixLastSpellNum; const $cellSpellPointsMaxSpellLevel = $(e_({ tag: "td", clazz: "cls-tbl__col-generic-center cls-tbl__cell-spell-points", html: maxSpellLevel === 0 ? "\u2014" : Renderer.get().render(`{@filter ${maxSpellLevel}|spells|level=${maxSpellLevel}|${sc ? `subclass=${this.activeClass?.name}: ${sc.shortName}` : `class=${this.activeClass?.name}`}}`), })); const $cellsSpellPoints = [ $cellSpellPoints, $cellSpellPointsMaxSpellLevel, ]; const hkSpellPoints = () => { $cellsDefault.forEach($it => $it.toggleClass(`cls-tbl__cell-spell-progression--spell-points-enabled`, this._stateGlobal.isUseSpellPoints)); $cellsSpellPoints.forEach($it => $it.toggleClass(`cls-tbl__cell-spell-points--spell-points-enabled`, this._stateGlobal.isUseSpellPoints)); }; this._addHookGlobal("isUseSpellPoints", hkSpellPoints); hkSpellPoints(); return [ ...$cellsDefault, $cellSpellPoints, $cellSpellPointsMaxSpellLevel, ]; } _render_renderSidebar () { const $wrpSidebar = $(`#statsprof`).empty(); const cls = this.activeClass; const $ptsToToggle = []; const btnToggleSidebar = e_({ tag: "div", clazz: "cls-side__btn-toggle", text: `[\u2012]`, click: () => this._state.isHideSidebar = !this._state.isHideSidebar, }); const hkSidebarHidden = () => { btnToggleSidebar.txt(this._state.isHideSidebar ? `[+]` : `[\u2012]`); $ptsToToggle.forEach($e => $e.toggleVe(!this._state.isHideSidebar)); }; this._addHookBase("isHideSidebar", hkSidebarHidden); // (call the hook later) const $btnSendToFoundry = ExtensionUtil.ACTIVE ? $(Renderer.utils.getBtnSendToFoundryHtml({isMb: false})) : null; const dataPartSendToFoundry = `data-page="${UrlUtil.PG_CLASSES}" data-source="${cls.source.qq()}" data-hash="${UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES](cls).qq()}"`; // region Group let $ptGroup = null; if (cls.classGroup) { $ptGroup = $(``) .fastSetHtml(`
    Groups
    ${cls.classGroup.map(it => it.toTitleCase()).join(", ")}
    `); $ptsToToggle.push($ptGroup); } // endregion // region Requirements const $getRenderedRequirements = (requirements, intro = null) => { const renderPart = (obj, joiner = ", ") => Object.keys(obj).filter(k => Parser.ABIL_ABVS.includes(k)).sort(SortUtil.ascSortAtts).map(k => `${Parser.attAbvToFull(k)} ${obj[k]}`).join(joiner); const orPart = requirements.or ? requirements.or.map(obj => renderPart(obj, " or ")).join("; ") : ""; const basePart = renderPart(requirements); const abilityPart = [orPart, basePart].filter(Boolean).join("; "); const allEntries = [ abilityPart ? `{@b Ability Score Minimum:} ${abilityPart}` : null, ...requirements.entries || [], ].filter(Boolean); return $$`
    ${Renderer.get().setFirstSection(true).render({type: "section", entries: allEntries})}
    `; }; let $ptRequirements = null; if (cls.requirements) { const $ptPrereq = $getRenderedRequirements(cls.requirements); $ptRequirements = $$`
    Prerequisites
    ${$ptPrereq} `; $ptsToToggle.push($ptRequirements); } // endregion // region HP/hit dice let $ptHp = null; if (cls.hd) { $ptHp = $(``) .fastSetHtml(`
    Hit Points
    Hit Dice: ${Renderer.get().render(Renderer.class.getHitDiceEntry(cls.hd))}
    Hit Points at 1st Level: ${Renderer.class.getHitPointsAtFirstLevel(cls.hd)}
    Hit Points at Higher Levels: ${Renderer.class.getHitPointsAtHigherLevels(cls.name, cls.hd)}
    `); $ptsToToggle.push($ptHp); } // endregion // region Starting proficiencies const profs = cls.startingProficiencies || {}; // endregion // region Starting equipment let $ptEquipment = null; if (cls.startingEquipment) { const equip = cls.startingEquipment; const rendered = [ equip.additionalFromBackground ? "

    You start with the following items, plus anything provided by your background.

    " : "", equip.default && equip.default.length ? `` : "", equip.goldAlternative != null ? `

    Alternatively, you may start with ${Renderer.get().render(equip.goldAlternative)} gp to buy your own equipment.

    ` : "", ].filter(Boolean).join(""); const $dispRendered = $(`
    `); $ptEquipment = $$`
    Starting Equipment
    ${$dispRendered}
    `; $dispRendered.fastSetHtml(rendered); $ptsToToggle.push($ptEquipment); } // endregion // region multiclassing let $ptMulticlassing = null; if (cls.multiclassing) { const mc = cls.multiclassing; const htmlMCcPrereqPreText = mc.requirements || mc.requirementsSpecial ? `
    To qualify for a new class, you must meet the ${mc.requirementsSpecial ? "" : "ability score "}prerequisites for both your current class and your new one.
    ` : ""; let $ptMcPrereq = null; if (mc.requirements) { $ptMcPrereq = $getRenderedRequirements(mc.requirements, htmlMCcPrereqPreText); } let $ptMcPrereqSpecial = null; if (mc.requirementsSpecial) { $ptMcPrereqSpecial = $$`
    ${mc.requirements ? "" : htmlMCcPrereqPreText} ${mc.requirements ? "Other " : ""}Prerequisites: ${Renderer.get().render(mc.requirementsSpecial || "")}
    `; } let $ptMcProfsIntro = null; let $ptMcProfsArmor = null; let $ptMcProfsWeapons = null; let $ptMcProfsTools = null; let $ptMcProfsSkills = null; if (mc.proficienciesGained) { $ptMcProfsIntro = $(`
    When you gain a level in a class other than your first, you gain only some of that class's starting proficiencies.
    `); if (mc.proficienciesGained.armor) $ptMcProfsArmor = $(`
    Armor: ${Renderer.class.getRenderedArmorProfs(mc.proficienciesGained.armor)}
    `); if (mc.proficienciesGained.weapons) $ptMcProfsWeapons = $(`
    Weapons: ${Renderer.class.getRenderedWeaponProfs(mc.proficienciesGained.weapons)}
    `); if (mc.proficienciesGained.tools) $ptMcProfsTools = $(`
    Tools: ${Renderer.class.getRenderedToolProfs(mc.proficienciesGained.tools)}
    `); if (mc.proficienciesGained.skills) $ptMcProfsSkills = $(`
    Skills: ${Renderer.class.getRenderedSkillProfs(mc.proficienciesGained.skills)}
    `); } let $ptMcEntries = null; if (mc.entries) { $ptMcEntries = $(`
    `).fastSetHtml(Renderer.get().setFirstSection(true).render({type: "section", entries: mc.entries})); } $ptMulticlassing = $$`
    Multiclassing
    ${$ptMcPrereq} ${$ptMcPrereqSpecial} ${$ptMcEntries} ${$ptMcProfsIntro} ${$ptMcProfsArmor} ${$ptMcProfsWeapons} ${$ptMcProfsTools} ${$ptMcProfsSkills} `; $ptsToToggle.push($ptMulticlassing); } // endregion const $ptProfs = $$`
    Proficiencies
    Armor: ${profs.armor ? Renderer.class.getRenderedArmorProfs(profs.armor) : "none"}
    Weapons: ${profs.weapons ? Renderer.class.getRenderedWeaponProfs(profs.weapons) : "none"}
    Tools: ${profs.tools ? Renderer.class.getRenderedToolProfs(profs.tools) : "none"}
    Saving Throws: ${cls.proficiency ? cls.proficiency.map(p => Parser.attAbvToFull(p)).join(", ") : "none"}
    Skills: ${profs.skills ? Renderer.class.getRenderedSkillProfs(profs.skills) : "none"}
    `; $ptsToToggle.push($ptProfs); $$` ${cls.authors ? `` : ""} ${$ptGroup} ${$ptRequirements} ${$ptHp} ${$ptProfs} ${$ptEquipment} ${$ptMulticlassing}
    ${cls.name}
    ${$btnSendToFoundry}${btnToggleSidebar}
    By ${cls.authors.join(", ")}
    `.appendTo($wrpSidebar); $wrpSidebar.showVe(); MiscUtil.pDefer(hkSidebarHidden); } async _render_pRenderSubclassTabs () { const $wrp = $(`#subclasstabs`).empty(); this._render_renderSubclassPrimaryControls($wrp); await this._render_pInitSubclassControls($wrp); } _render_renderSubclassPrimaryControls ($wrp) { const cls = this.activeClass; // region features/fluff const $btnToggleFeatures = ComponentUiUtil.$getBtnBool(this, "isHideFeatures", {text: "Features", activeClass: "cls__btn-cf--active", isInverted: true}).title("Toggle Class Features"); const $btnToggleFeatureVariants = $(``) .click(() => { const f = this.filterBox.getValues(); const isClassFeatureVariantsDisplayed = f[this._pageFilter.optionsFilter.header].isClassFeatureVariant; this._pageFilter.optionsFilter.setValue("isClassFeatureVariant", !isClassFeatureVariantsDisplayed); this._pageFilter.filterBox.fireChangeEvent(); }); const hkUpdateBtnFeatureVariants = () => { const f = this.filterBox.getValues(); const isClassFeatureVariantsDisplayed = f[this._pageFilter.optionsFilter.header].isClassFeatureVariant; $btnToggleFeatureVariants.toggleClass("active", isClassFeatureVariantsDisplayed); }; this.filterBox.on(FilterBox.EVNT_VALCHANGE, () => hkUpdateBtnFeatureVariants()); hkUpdateBtnFeatureVariants(); const $btnToggleFluff = ComponentUiUtil.$getBtnBool(this, "isShowFluff", {text: "Info"}).title("Toggle Class Info"); $$`
    ${$btnToggleFeatures}${$btnToggleFeatureVariants}${$btnToggleFluff}
    `.appendTo($wrp); // endregion // region subclasses const $wrpScTabs = $(`
    `).appendTo($wrp); this._listSubclass = new List({$wrpList: $wrpScTabs, isUseJquery: true, fnSort: ClassesPage._fnSortSubclassFilterItems}); cls.subclasses.forEach((sc, i) => { const listItem = this._render_getSubclassTab(cls, sc, i); if (!listItem) return; this._listSubclass.addItem(listItem); }); const $dispCount = $(`
    `); this._listSubclass.addItem(new ListItem( -1, $dispCount, null, {isAlwaysVisible: true}, )); this._listSubclass.on("updated", () => { $dispCount.off("click"); if (this._listSubclass.visibleItems.length) { const cntNotShown = this._listSubclass.items.length - this._listSubclass.visibleItems.length; $dispCount.html(cntNotShown ? `(${cntNotShown} more not shown)` : "").click(() => this._doSelectAllSubclasses()); } else if (this._listSubclass.items.length > 1) { $dispCount.html(`(${this._listSubclass.items.length - 1} subclasses not shown)`).click(() => this._doSelectAllSubclasses()); } else $dispCount.html(""); }); this._listSubclass.init(); // endregion } _doSelectAllSubclasses ({allowlistMods = null} = {}) { const cls = this.activeClass; const allStateKeys = cls.subclasses .map(sc => { return { stateKey: UrlUtil.getStateKeySubclass(sc), isSelected: allowlistMods == null || allowlistMods.has(UtilClassesPage.getSubclassCssMod(cls, sc)), }; }); this._pageFilter.sourceFilter.doSetPillsClear(); this.filterBox.fireChangeEvent(); this._proxyAssign("state", "_state", "__state", allStateKeys.mergeMap(({stateKey, isSelected}) => ({[stateKey]: isSelected}))); } async _render_pInitSubclassControls ($wrp) { const cls = this.activeClass; const $btnSelAll = $(``) .click(evt => { const allStateKeys = cls.subclasses.map(sc => UrlUtil.getStateKeySubclass(sc)); if (evt.shiftKey) { this._doSelectAllSubclasses({allowlistMods: new Set(["fresh", "brew", "spicy"])}); } else if (EventUtil.isCtrlMetaKey(evt)) { const nxtState = {}; allStateKeys.forEach(k => nxtState[k] = false); this._listSubclass.visibleItems .filter(it => it.values.mod === "brew" || it.values.mod === "fresh") .map(it => it.values.stateKey) .forEach(stateKey => nxtState[stateKey] = true); this._proxyAssign("state", "_state", "__state", nxtState); } else { const nxtState = {}; allStateKeys.forEach(k => nxtState[k] = false); this._listSubclass.visibleItems .map(it => it.values.stateKey) .filter(Boolean) .forEach(stateKey => nxtState[stateKey] = true); this._proxyAssign("state", "_state", "__state", nxtState); } }); const filterSets = [ {name: "View Default", subHashes: [], isClearSources: false}, {name: "View Standard Plus Partnered", subHashes: [], isClearSources: false, sourceCategories: [SourceUtil.FILTER_GROUP_STANDARD, SourceUtil.FILTER_GROUP_PARTNERED]}, {name: "View Standard Plus Homebrew", subHashes: [], isClearSources: false, sourceCategories: [SourceUtil.FILTER_GROUP_STANDARD, SourceUtil.FILTER_GROUP_HOMEBREW]}, {name: "View Most Recent", subHashes: [], isClearSources: true}, {name: "View All", subHashes: ["flstmiscellaneous:reprinted=0"], isClearSources: true}, ]; const setFilterSet = ix => { const filterSet = filterSets[ix]; const boxSubhashes = this.filterBox.getBoxSubHashes() || []; const cpySubHashes = MiscUtil.copyFast(filterSet.subHashes); if (filterSet.sourceCategories || filterSet.isClearSources) { const classifiedSources = this._pageFilter.sourceFilter.getSources(); const toInclude = filterSet.sourceCategories || []; const toExclude = [ SourceUtil.FILTER_GROUP_STANDARD, SourceUtil.FILTER_GROUP_PARTNERED, SourceUtil.FILTER_GROUP_NON_STANDARD, SourceUtil.FILTER_GROUP_HOMEBREW, ].filter(it => !toInclude.includes(it)); const sourcePart = [ ...toInclude .map(prop => classifiedSources[prop] || []) .flat() .map(src => `${src.toUrlified()}=1`), ...toExclude .map(prop => classifiedSources[prop] || []) .flat() .map(src => `${src.toUrlified()}=0`), ] .join(HASH_SUB_LIST_SEP); cpySubHashes.push(`flstsource:${sourcePart}`); } else if (filterSet.sources) { const sourcePartSpecified = Object.entries(filterSet.sources).map(([src, val]) => `${src.toUrlified()}=${val}`); const classifiedSources = this._pageFilter.sourceFilter.getSources(); const sourcePartRest = classifiedSources.all .filter(src => filterSet.sources[src] == null) .map(src => `${src.toUrlified()}=0`); const sourcePart = [...sourcePartSpecified, ...sourcePartRest].join(HASH_SUB_LIST_SEP); cpySubHashes.push(`flstsource:${sourcePart}`); } this.filterBox.setFromSubHashes([ ...boxSubhashes, ...cpySubHashes, `flopsource:extend`, ].filter(Boolean), {force: true}); $selFilterPreset.val("-1"); }; const $selFilterPreset = $(``) .change(() => { const val = Number($selFilterPreset.val()); if (val == null) return; setFilterSet(val); }); filterSets.forEach((it, i) => $selFilterPreset.append(``)); $selFilterPreset.val("-1"); const $btnReset = $(``) .click(() => { this._proxyAssign("state", "_state", "__state", cls.subclasses.mergeMap(sc => ({[UrlUtil.getStateKeySubclass(sc)]: false}))); }); this.filterBox.on(FilterBox.EVNT_VALCHANGE, this._handleSubclassFilterChange.bind(this)); this._handleSubclassFilterChange(); // Remove the temporary "hidden" class used to prevent popping this._listSubclass.items.forEach(it => it.ele.showVe()); const $btnToggleSources = ComponentUiUtil.$getBtnBool(this, "isShowScSources", {$ele: $(``)}); const $btnShuffle = $(``) .click(() => { if (!this._listSubclass.visibleItems.length) return JqueryUtil.doToast({content: "No subclasses to choose from!", type: "warning"}); const doDeselAll = () => this._listSubclass.items.filter(it => it.values.stateKey).forEach(it => this._state[it.values.stateKey] = false); const visibleSubclassItems = this._listSubclass.visibleItems.filter(it => it.values.stateKey); const activeKeys = Object.keys(this._state).filter(it => it.startsWith("sub") && this._state[it]); const visibleActiveKeys = this._listSubclass.visibleItems.filter(it => it.values.stateKey).map(it => it.values.stateKey).filter(it => activeKeys.includes(it)); // Avoid re-selecting the same option if there's only one selected, unless there is only one subclass if (visibleActiveKeys.length === 1 && visibleSubclassItems.length !== 1) { doDeselAll(); const options = this._listSubclass.visibleItems.filter(it => it.values.stateKey).map(it => it.values.stateKey).filter(it => it !== visibleActiveKeys[0]); this._state[RollerUtil.rollOnArray(options)] = true; } else { doDeselAll(); const it = RollerUtil.rollOnArray(this._listSubclass.visibleItems.filter(it => it.values.stateKey)); this._state[it.values.stateKey] = true; } }); $$`
    ${$selFilterPreset}
    `.appendTo($wrp); $$`
    ${$btnSelAll}${$btnShuffle}${$btnReset}${$btnToggleSources}
    `.appendTo($wrp); } _handleSubclassFilterChange () { const f = this.filterBox.getValues(); this._listSubclass.filter(li => { if (li.values.isAlwaysVisible) return true; return this._pageFilter.isSubclassVisible(f, this.activeClass, li.data.entity); }); } _render_getSubclassTab (cls, sc, ix) { const isExcluded = this.constructor.isSubclassExcluded_(cls, sc); const stateKey = UrlUtil.getStateKeySubclass(sc); const mod = UtilClassesPage.getSubclassCssMod(cls, sc); const clsActive = `cls__btn-sc--active-${mod}`; if (this._state[stateKey] == null) this._state[stateKey] = false; const $dispName = $(`
    `); const $dispSource = $(`
    (${Parser.sourceJsonToAbv(sc.source)})
    `); const hkSourcesVisible = () => { $dispName.text(this._state.isShowScSources ? ClassesPage.getBaseShortName(sc) : sc.shortName); $dispSource.toggleVe(!!this._state.isShowScSources); }; this._addHookBase("isShowScSources", hkSourcesVisible); MiscUtil.pDefer(hkSourcesVisible); // Initially have these "hidden," to prevent them popping out when we filter them const $btn = $$`` .click(() => this._state[stateKey] = !this._state[stateKey]) .contextmenu(evt => { evt.preventDefault(); this._state[stateKey] = !this._state[stateKey]; }); const hkVisible = () => $btn.toggleClass(clsActive, !!this._state[stateKey]); this._addHookBase(stateKey, hkVisible); MiscUtil.pDefer(hkVisible); return new ListItem( ix, $btn, sc.name, { source: sc.source, shortName: sc.shortName, stateKey, mod, }, { isExcluded, entity: sc, }, ); } _trackOutlineFluffData (depthData) { this._outlineData.fluff = depthData; } _trackOutlineCfData (ixLvl, ixFeature, depthData) { ((this._outlineData.classFeatures ||= [])[ixLvl] ||= [])[ixFeature] = depthData; } _trackOutlineScData (stateKey, level, ixScFeature, depthData) { (((this._outlineData.subclassFeatures ||= {})[stateKey] ||= [])[level] ||= [])[ixScFeature] = depthData; } _trackOutlineScFluffData (stateKey, level, ixScFeature, depthData) { (((this._outlineData.subclassFluff ||= {})[stateKey] ||= [])[level] ||= [])[ixScFeature] = depthData; } _render_renderOutline () { this._$wrpOutline.empty(); // Auto-hide the outline on small screens if (Renderer.hover.isSmallScreen()) this._state.isHideOutline = true; const $dispShowHide = $(`
    `); const $wrpHeadInner = $$`
    Outline
    ${$dispShowHide}
    ` .click(() => this._state.isHideOutline = !this._state.isHideOutline); const $wrpHead = $$`
    ${$wrpHeadInner}
    `.appendTo(this._$wrpOutline); const $wrpBody = $(`