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
  • structure here rather than * trying to include their partial, because their partial renders a complete
  • * 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} */ #sourcesInThisCompendium = new Set(); /** * Sources unavailable to players based on permission settings. * @type {Set} */ #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 } } }