Updating the compendium filter to make it more snappy
This commit is contained in:
497
system/scripts/compendium/l5r5e-item-compendium.js
Normal file
497
system/scripts/compendium/l5r5e-item-compendium.js
Normal file
@@ -0,0 +1,497 @@
|
||||
import { L5r5eHtmlMultiSelectElement } from "../misc/l5r5e-multiselect.js";
|
||||
|
||||
const { Compendium } = foundry.applications.sidebar.apps;
|
||||
|
||||
/**
|
||||
* Extended Compendium application for L5R5e.
|
||||
* Adds source/rank/ring/rarity filters to Item compendiums
|
||||
* @extends {Compendium}
|
||||
*/
|
||||
export class ItemCompendiumL5r5e extends Compendium {
|
||||
/** @override */
|
||||
static DEFAULT_OPTIONS = {
|
||||
actions: {
|
||||
applyPlayerView: ItemCompendiumL5r5e.#onApplyPlayerView,
|
||||
},
|
||||
window: {
|
||||
resizable: true
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Our own entry partial which mirrors Foundry's index-partial.hbs structure
|
||||
* and appends ring/rarity/rank badges using data from _prepareDirectoryContext.
|
||||
*
|
||||
* NOTE: We intentionally duplicate Foundry's <li> structure here rather than
|
||||
* trying to include their partial, because their partial renders a complete <li>
|
||||
* element which cannot be nested or extended from outside. If Foundry ever
|
||||
* changes their index-partial.hbs, this file will need updating to match.
|
||||
* @override
|
||||
*/
|
||||
static _entryPartial = "systems/l5r5e/templates/" + "compendium/l5r5e-index-partial.html";
|
||||
|
||||
/**
|
||||
* Sources present in this specific compendium, populated during _prepareContext.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
#sourcesInThisCompendium = new Set();
|
||||
|
||||
/**
|
||||
* Sources unavailable to players based on permission settings.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
#unavailableSourceForPlayersSet = new Set();
|
||||
|
||||
/**
|
||||
* Whether to hide entries with empty sources from players.
|
||||
* @type {boolean}
|
||||
*/
|
||||
#hideEmptySourcesFromPlayers = false;
|
||||
|
||||
/**
|
||||
* Which filter UI controls are worth showing.
|
||||
* Determined during _prepareContext by checking whether at least two
|
||||
* distinct values exist for each filterable property.
|
||||
* @type {{ rank: boolean, rarity: boolean, source: boolean, ring: boolean }|null}
|
||||
*/
|
||||
#filtersToShow = null;
|
||||
|
||||
/**
|
||||
* Cached active filter values, read from the DOM once at the start of
|
||||
* each filter pass in #reapplyFilters and held for _onMatchSearchEntry
|
||||
* to consume per-entry without re-querying the DOM.
|
||||
* @type {{ userFilter: string[], rankFilter: string[], ringFilter: string[], rarityFilter: string[] }}
|
||||
*/
|
||||
#activeFilters = {
|
||||
userFilter: [],
|
||||
rankFilter: [],
|
||||
ringFilter: [],
|
||||
rarityFilter: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert the filter part between header and directory by composing with
|
||||
* the parent parts rather than replacing them, so future Foundry changes
|
||||
* to Compendium.PARTS are picked up automatically.
|
||||
* @override
|
||||
*/
|
||||
_configureRenderParts(options) {
|
||||
const parts = super._configureRenderParts(options);
|
||||
|
||||
const ordered = {};
|
||||
for (const [key, value] of Object.entries(parts)) {
|
||||
ordered[key] = value;
|
||||
if (key === "header") {
|
||||
ordered.filter = {
|
||||
template: `${CONFIG.l5r5e.paths.templates}compendium/filter-bar.html`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _prepareContext(options) {
|
||||
const context = await super._prepareContext(options);
|
||||
this.#sourcesInThisCompendium = new Set();
|
||||
this.#resolvePermissions();
|
||||
this.#filtersToShow = this.#computeFilterVisibility();
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _preparePartContext(partId, context, options) {
|
||||
context = await super._preparePartContext(partId, context, options);
|
||||
|
||||
if (partId === "filter") {
|
||||
const ns = CONFIG.l5r5e.namespace;
|
||||
const allCompendiumReferencesSet = game.settings.get(ns, "all-compendium-references");
|
||||
const hideDisabledOptions = game.settings.get(ns, "compendium-hide-disabled-sources");
|
||||
|
||||
context.filtersToShow = this.#filtersToShow;
|
||||
context.ranks = [1, 2, 3, 4, 5];
|
||||
context.rarities = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
context.rings = ["fire", "water", "earth", "air", "void"];
|
||||
context.hideDisabledOptions = hideDisabledOptions;
|
||||
context.showPlayerView = game.user.isGM && this.#unavailableSourceForPlayersSet.size > 0;
|
||||
|
||||
// Source multiselect options — plain data for {{selectOptions}} in the template.
|
||||
context.sources = [...allCompendiumReferencesSet].map((reference) => ({
|
||||
value: reference,
|
||||
label: CONFIG.l5r5e.sourceReference[reference]?.label ?? reference,
|
||||
translate: true,
|
||||
group:
|
||||
CONFIG.l5r5e.sourceReference[reference]?.type.split(",")[0] ??
|
||||
"l5r5e.multiselect.sources_categories.others",
|
||||
disabled:
|
||||
!this.#sourcesInThisCompendium.has(reference) ||
|
||||
(!game.user.isGM && this.#unavailableSourceForPlayersSet.has(reference)),
|
||||
}));
|
||||
}
|
||||
|
||||
if (partId === "directory") {
|
||||
context.entryFilterData = Object.fromEntries(
|
||||
[...this.collection.index.values()].map((entry) => [
|
||||
entry._id,
|
||||
{
|
||||
rank: entry.system?.rank,
|
||||
ring: entry.system?.ring,
|
||||
rarity: entry.system?.rarity,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _onRender(context, options) {
|
||||
await super._onRender(context, options);
|
||||
|
||||
if (options.parts.includes("filter")) {
|
||||
this.#bindButtonFilter(".rank-filter");
|
||||
this.#bindButtonFilter(".rarity-filter");
|
||||
this.#bindButtonFilter(".ring-filter");
|
||||
this.#bindSourceFilter();
|
||||
}
|
||||
|
||||
// Reapply filters whenever the filter controls or the entry list changes.
|
||||
if (options.parts.some((p) => p === "filter" || p === "directory")) {
|
||||
this.#reapplyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_preSyncPartState(partId, newElement, priorElement, state) {
|
||||
super._preSyncPartState(partId, newElement, priorElement, state);
|
||||
|
||||
if (partId === "filter") {
|
||||
state.selectedRanks = [...priorElement.querySelectorAll(".rank-filter .selected")].map((element) => element.dataset.rank);
|
||||
state.selectedRarities = [...priorElement.querySelectorAll(".rarity-filter .selected")].map((element) => element.dataset.rarity);
|
||||
state.selectedRings = [...priorElement.querySelectorAll(".ring-filter .selected")].map((element) => element.dataset.ring);
|
||||
state.sourceValue = priorElement.querySelector("l5r5e-multi-select")?.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore filter selections after the filter part has been re-rendered.
|
||||
* The [data-clear] button visibility is derived from whether any values
|
||||
* were restored — no extra state needed.
|
||||
* @override
|
||||
*/
|
||||
_syncPartState(partId, newElement, priorElement, state) {
|
||||
super._syncPartState(partId, newElement, priorElement, state);
|
||||
|
||||
if (partId === "filter") {
|
||||
for (const rank of state.selectedRanks ?? []) {
|
||||
newElement.querySelector(`.rank-filter [data-rank="${rank}"]`)?.classList.add("selected");
|
||||
}
|
||||
const rankClear = newElement.querySelector(".rank-filter [data-clear]");
|
||||
if (rankClear) {
|
||||
rankClear.style.display = state.selectedRanks?.length ? "" : "none";
|
||||
}
|
||||
|
||||
for (const rarity of state.selectedRarities ?? []) {
|
||||
newElement.querySelector(`.rarity-filter [data-rarity="${rarity}"]`)?.classList.add("selected");
|
||||
}
|
||||
const rarityClear = newElement.querySelector(".rarity-filter [data-clear]");
|
||||
if (rarityClear) {
|
||||
rarityClear.style.display = state.selectedRarities?.length ? "" : "none";
|
||||
}
|
||||
|
||||
for (const ring of state.selectedRings ?? []) {
|
||||
newElement.querySelector(`.ring-filter [data-ring="${ring}"]`)?.classList.add("selected");
|
||||
}
|
||||
const ringClear = newElement.querySelector(".ring-filter [data-clear]");
|
||||
if (ringClear) {
|
||||
ringClear.style.display = state.selectedRings?.length ? "" : "none";
|
||||
}
|
||||
|
||||
if (state.sourceValue) {
|
||||
const multiSelect = newElement.querySelector("l5r5e-multi-select");
|
||||
if (multiSelect) {
|
||||
multiSelect.value = state.sourceValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_onMatchSearchEntry(query, entryIds, entry, options) {
|
||||
super._onMatchSearchEntry(query, entryIds, entry, options);
|
||||
if (entry.style.display === "none") {
|
||||
return;
|
||||
}
|
||||
this.#applyEntryFilter(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot active filter state then re-run the search filter (or walk entries directly as fallback).
|
||||
* @private
|
||||
*/
|
||||
#reapplyFilters() {
|
||||
this.#refreshActiveFilters();
|
||||
|
||||
const searchFilter = this._searchFilters?.[0];
|
||||
if (searchFilter) {
|
||||
searchFilter.filter(null, searchFilter.query);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
for (const entry of this.element.querySelectorAll(".directory-item")) {
|
||||
this.#applyEntryFilter(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current filter selections from the DOM and cache them in #activeFilters.
|
||||
* @private
|
||||
*/
|
||||
#refreshActiveFilters() {
|
||||
const filterElement = this.element.querySelector("[data-application-part=\"filter\"]");
|
||||
const multiSelect = filterElement?.querySelector("l5r5e-multi-select");
|
||||
|
||||
const collectSelected = (containerSelector, dataKey) =>
|
||||
[...(filterElement?.querySelectorAll(`${containerSelector} .selected`) ?? [])]
|
||||
.map((element) => element.dataset[dataKey])
|
||||
.filter(Boolean);
|
||||
|
||||
this.#activeFilters = {
|
||||
userFilter: multiSelect?.value ?? [],
|
||||
rankFilter: collectSelected(".rank-filter", "rank"),
|
||||
ringFilter: collectSelected(".ring-filter", "ring"),
|
||||
rarityFilter: collectSelected(".rarity-filter", "rarity"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all active filters to a single directory entry, showing or hiding it accordingly.
|
||||
* @param {HTMLElement} entry
|
||||
* @private
|
||||
*/
|
||||
#applyEntryFilter(entry) {
|
||||
const indexEntry = this.collection.index.get(entry.dataset.entryId);
|
||||
if (!indexEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const system = indexEntry.system;
|
||||
const lineSource = system?.source_reference?.source ?? null;
|
||||
const { userFilter, rankFilter, ringFilter, rarityFilter } = this.#activeFilters;
|
||||
let shouldShow = true;
|
||||
|
||||
const sourceUnavailable =
|
||||
(lineSource && this.#unavailableSourceForPlayersSet.has(lineSource)) ||
|
||||
(lineSource === "" && this.#hideEmptySourcesFromPlayers);
|
||||
|
||||
if (sourceUnavailable) {
|
||||
if (game.user.isGM) {
|
||||
entry.classList.add("not-for-players");
|
||||
entry.dataset.tooltip = game.i18n.localize("l5r5e.compendium.not_for_players");
|
||||
} else {
|
||||
shouldShow = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (rankFilter.length) {
|
||||
shouldShow &&= rankFilter.includes(String(system?.rank));
|
||||
}
|
||||
if (rarityFilter.length) {
|
||||
shouldShow &&= rarityFilter.includes(String(system?.rarity));
|
||||
}
|
||||
if (ringFilter.length) {
|
||||
shouldShow &&= ringFilter.includes(system?.ring);
|
||||
}
|
||||
if (userFilter.length) {
|
||||
shouldShow &&= userFilter.includes(lineSource);
|
||||
}
|
||||
|
||||
entry.style.display = shouldShow ? "" : "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate the compendium index to:
|
||||
* 1. Populate #sourcesInThisCompendium for source filter options
|
||||
* 2. Determine which filter controls have enough distinct values to show
|
||||
* @returns {{ rank: boolean, rarity: boolean, source: boolean, ring: boolean }}
|
||||
* @private
|
||||
*/
|
||||
#computeFilterVisibility() {
|
||||
const filtersToShow = { rank: false, rarity: false, source: true, ring: false };
|
||||
const firstSeen = { rank: null, rarity: null, ring: null };
|
||||
|
||||
const markIfDistinct = (prop, value) => {
|
||||
if (filtersToShow[prop] || value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (firstSeen[prop] === null) {
|
||||
firstSeen[prop] = value;
|
||||
} else if (firstSeen[prop] !== value) {
|
||||
filtersToShow[prop] = true;
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of this.collection.index.values()) {
|
||||
const sys = entry.system;
|
||||
if (!sys) {
|
||||
continue;
|
||||
}
|
||||
if (sys.rank !== undefined) {
|
||||
markIfDistinct("rank", sys.rank);
|
||||
}
|
||||
if (sys.ring !== undefined) {
|
||||
markIfDistinct("ring", sys.ring);
|
||||
}
|
||||
if (sys.rarity !== undefined) {
|
||||
markIfDistinct("rarity", sys.rarity);
|
||||
}
|
||||
if (sys.source_reference?.source !== undefined) {
|
||||
this.#sourcesInThisCompendium.add(sys.source_reference.source);
|
||||
}
|
||||
}
|
||||
|
||||
return filtersToShow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which sources are restricted from players and cache the result
|
||||
* in instance-level sets for use by #applyEntryFilter.
|
||||
* @private
|
||||
*/
|
||||
#resolvePermissions() {
|
||||
const ns = CONFIG.l5r5e.namespace;
|
||||
const officialSet = game.settings.get(ns, "compendium-official-content-for-players");
|
||||
const unofficialSet = game.settings.get(ns, "compendium-unofficial-content-for-players");
|
||||
const allRefsSet = game.settings.get(ns, "all-compendium-references");
|
||||
|
||||
this.#hideEmptySourcesFromPlayers = game.settings.get(ns, "compendium-hide-empty-sources-from-players");
|
||||
|
||||
this.#unavailableSourceForPlayersSet = new Set(
|
||||
[...allRefsSet].filter((ref) => {
|
||||
if (CONFIG.l5r5e.sourceReference[ref]) {
|
||||
return officialSet.size > 0 ? !officialSet.has(ref) : false;
|
||||
}
|
||||
return unofficialSet.size > 0 ? !unofficialSet.has(ref) : false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind toggle-selection click handlers to all children of a button filter container.
|
||||
* A [data-clear] element at the end of the container acts as an inline reset:
|
||||
* - It is hidden (display:none in the template) when no values are selected.
|
||||
* - It becomes visible as soon as any value is selected.
|
||||
* - Clicking it deselects all values and hides itself again.
|
||||
* @param {string} containerSelector
|
||||
* @private
|
||||
*/
|
||||
#bindButtonFilter(containerSelector) {
|
||||
const container = this.element.querySelector(containerSelector);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearButton = container.querySelector("[data-clear]");
|
||||
|
||||
const updateClearButton = () => {
|
||||
if (!clearButton) {
|
||||
return;
|
||||
}
|
||||
const anySelected = [...container.children].some(
|
||||
(element) => element.dataset.clear === undefined && element.classList.contains("selected")
|
||||
);
|
||||
clearButton.style.display = anySelected ? "" : "none";
|
||||
};
|
||||
|
||||
for (const child of container.children) {
|
||||
child.addEventListener("click", (event) => {
|
||||
const target = event.currentTarget;
|
||||
|
||||
if (target.dataset.clear !== undefined) {
|
||||
// Clicked the clear button — deselect all value elements
|
||||
for (const element of container.children) {
|
||||
element.classList.remove("selected");
|
||||
}
|
||||
} else {
|
||||
// Clicked a value element — toggle it
|
||||
target.classList.toggle("selected");
|
||||
}
|
||||
|
||||
updateClearButton();
|
||||
this.#reapplyFilters();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up the change listener on the already-rendered multiselect element.
|
||||
* The element and its options are fully declared in filter-bar.html via
|
||||
* {{selectOptions}} — no imperative construction needed here.
|
||||
* @private
|
||||
*/
|
||||
#bindSourceFilter() {
|
||||
const multiSelect = this.element.querySelector("l5r5e-multi-select[name=\"filter-sources\"]");
|
||||
if (!multiSelect) {
|
||||
return;
|
||||
}
|
||||
multiSelect.addEventListener("change", () => this.#reapplyFilters());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the GM player-view button, selecting only the sources that are
|
||||
* both visible to players and present in this specific compendium.
|
||||
* @this {ItemCompendiumL5r5e}
|
||||
* @private
|
||||
*/
|
||||
static #onApplyPlayerView() {
|
||||
const ns = CONFIG.l5r5e.namespace;
|
||||
const allRefsSet = game.settings.get(ns, "all-compendium-references");
|
||||
|
||||
const availableForPlayers = [...allRefsSet]
|
||||
.filter((ref) => !this.#unavailableSourceForPlayersSet.has(ref))
|
||||
.filter((ref) => this.#sourcesInThisCompendium.has(ref));
|
||||
|
||||
const multiSelect = this.element.querySelector("l5r5e-multi-select[name=\"filter-sources\"]");
|
||||
if (!multiSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
multiSelect.value = availableForPlayers;
|
||||
this.#reapplyFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this compendium class and extend the index fields for all Item packs.
|
||||
*/
|
||||
static applyToPacks() {
|
||||
CONFIG.Item.compendiumIndexFields = [
|
||||
...(CONFIG.Item.compendiumIndexFields ?? []),
|
||||
"system.rank",
|
||||
"system.ring",
|
||||
"system.rarity",
|
||||
"system.source_reference.source",
|
||||
];
|
||||
|
||||
for (const pack of game.packs.filter((p) => p.metadata.type === "Item")) {
|
||||
pack.applicationClass = ItemCompendiumL5r5e;
|
||||
pack.getIndex(); // rebuild index with new fields — no need to await since this happens before anyone have a chance to act
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user