Updating the compendium filter to make it more snappy

This commit is contained in:
Litasa
2026-02-27 04:15:10 +00:00
parent aa203c546c
commit 2dd9ee19e9
17 changed files with 2327 additions and 639 deletions

View 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
}
}
}