-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Tab Filter Upgrade - Include/Exclude/Off, Filter on more things #19
Comments
|
2.4.x legacy implementation reference: The base sheet holds filter sets which can be reference during item preparation. Item preparation is generically called during
base-sheet.mjs: /**
* Track the set of item filters which are applied
* @type {Object<string, Set>}
* @protected
*/
_filters = {
inventory: new Set(),
spellbook: new Set(),
features: new Set(),
effects: new Set()
};
// ...
/**
* Initialize Item list filters by activating the set of filters which are currently applied
* @param {number} i Index of the filter in the list.
* @param {HTML} ul HTML object for the list item surrounding the filter.
* @private
*/
_initializeFilterItemList(i, ul) {
const set = this._filters[ul.dataset.filter];
const filters = ul.querySelectorAll(".filter-item");
for ( let li of filters ) {
if ( set.has(li.dataset.filter) ) li.classList.add("active");
}
}
// ...
/**
* Determine whether an Owned Item will be shown based on the current set of filters.
* @param {object[]} items Copies of item data to be filtered.
* @param {Set<string>} filters Filters applied to the item list.
* @returns {object[]} Subset of input items limited by the provided filters.
* @protected
*/
_filterItems(items, filters) {
return items.filter(item => {
// Action usage
for ( let f of ["action", "bonus", "reaction"] ) {
if ( filters.has(f) && (item.system.activation?.type !== f) ) return false;
}
// Spell-specific filters
if ( filters.has("ritual") && (item.system.components.ritual !== true) ) return false;
if ( filters.has("concentration") && (item.system.components.concentration !== true) ) return false;
if ( filters.has("prepared") ) {
if ( (item.system.level === 0) || ["innate", "always"].includes(item.system.preparation.mode) ) return true;
if ( this.actor.type === "npc" ) return true;
return item.system.preparation.prepared;
}
// Equipment-specific filters
if ( filters.has("equipped") && (item.system.equipped !== true) ) return false;
return true;
});
}
// ...
/**
* Handle toggling of filters to display a different set of owned items.
* @param {Event} event The click event which triggered the toggle.
* @returns {ActorSheet5e} This actor sheet with toggled filters.
* @private
*/
_onToggleFilter(event) {
event.preventDefault();
const li = event.currentTarget;
const set = this._filters[li.parentElement.dataset.filter];
const filter = li.dataset.filter;
if ( set.has(filter) ) set.delete(filter);
else set.add(filter);
return this.render();
}
activateListeners(html) {
// Activate Item Filters
const filterLists = html.find(".filter-list");
filterLists.each(this._initializeFilterItemList.bind(this));
filterLists.on("click", ".filter-item", this._onToggleFilter.bind(this));
// ...
} |
During character-sheet.mjs: _prepareItems(context) {
// Categorize items as inventory, spellbook, features, and classes
const inventory = {};
for ( const type of ["weapon", "equipment", "consumable", "tool", "backpack", "loot"] ) {
inventory[type] = {label: `${CONFIG.Item.typeLabels[type]}Pl`, items: [], dataset: {type}};
}
// Partition items by category
let {items, spells, feats, races, backgrounds, classes, subclasses} = context.items.reduce((obj, item) => {
const {quantity, uses, recharge} = item.system;
// Item details
const ctx = context.itemContext[item.id] ??= {};
ctx.isStack = Number.isNumeric(quantity) && (quantity !== 1);
ctx.attunement = {
[CONFIG.DND5E.attunementTypes.REQUIRED]: {
icon: "fa-sun",
cls: "not-attuned",
title: "DND5E.AttunementRequired"
},
[CONFIG.DND5E.attunementTypes.ATTUNED]: {
icon: "fa-sun",
cls: "attuned",
title: "DND5E.AttunementAttuned"
}
}[item.system.attunement];
// Prepare data needed to display expanded sections
ctx.isExpanded = this._expanded.has(item.id);
// Item usage
ctx.hasUses = item.hasLimitedUses;
ctx.isOnCooldown = recharge && !!recharge.value && (recharge.charged === false);
ctx.isDepleted = ctx.isOnCooldown && ctx.hasUses && (uses.value > 0);
ctx.hasTarget = item.hasAreaTarget || item.hasIndividualTarget;
// Item toggle state
this._prepareItemToggleState(item, ctx);
// Classify items into types
if ( item.type === "spell" ) obj.spells.push(item);
else if ( item.type === "feat" ) obj.feats.push(item);
else if ( item.type === "race" ) obj.races.push(item);
else if ( item.type === "background" ) obj.backgrounds.push(item);
else if ( item.type === "class" ) obj.classes.push(item);
else if ( item.type === "subclass" ) obj.subclasses.push(item);
else if ( Object.keys(inventory).includes(item.type) ) obj.items.push(item);
return obj;
}, { items: [], spells: [], feats: [], races: [], backgrounds: [], classes: [], subclasses: [] });
// Apply active item filters
items = this._filterItems(items, this._filters.inventory);
spells = this._filterItems(spells, this._filters.spellbook);
feats = this._filterItems(feats, this._filters.features);
// Organize items
for ( let i of items ) {
const ctx = context.itemContext[i.id] ??= {};
ctx.totalWeight = (i.system.quantity * i.system.weight).toNearest(0.1);
inventory[i.type].items.push(i);
}
// Organize Spellbook and count the number of prepared spells (excluding always, at will, etc...)
const spellbook = this._prepareSpellbook(context, spells);
const nPrepared = spells.filter(spell => {
const prep = spell.system.preparation;
return (spell.system.level > 0) && (prep.mode === "prepared") && prep.prepared;
}).length;
// Sort classes and interleave matching subclasses, put unmatched subclasses into features so they don't disappear
classes.sort((a, b) => b.system.levels - a.system.levels);
const maxLevelDelta = CONFIG.DND5E.maxLevel - this.actor.system.details.level;
classes = classes.reduce((arr, cls) => {
const ctx = context.itemContext[cls.id] ??= {};
ctx.availableLevels = Array.fromRange(CONFIG.DND5E.maxLevel + 1).slice(1).map(level => {
const delta = level - cls.system.levels;
return { level, delta, disabled: delta > maxLevelDelta };
});
arr.push(cls);
const identifier = cls.system.identifier || cls.name.slugify({strict: true});
const subclass = subclasses.findSplice(s => s.system.classIdentifier === identifier);
if ( subclass ) arr.push(subclass);
return arr;
}, []);
for ( const subclass of subclasses ) {
feats.push(subclass);
const message = game.i18n.format("DND5E.SubclassMismatchWarn", {
name: subclass.name, class: subclass.system.classIdentifier
});
context.warnings.push({ message, type: "warning" });
}
// Organize Features
const features = {
race: {
label: CONFIG.Item.typeLabels.race, items: races,
hasActions: false, dataset: {type: "race"} },
background: {
label: CONFIG.Item.typeLabels.background, items: backgrounds,
hasActions: false, dataset: {type: "background"} },
classes: {
label: `${CONFIG.Item.typeLabels.class}Pl`, items: classes,
hasActions: false, dataset: {type: "class"}, isClass: true },
active: {
label: "DND5E.FeatureActive", items: [],
hasActions: true, dataset: {type: "feat", "activation.type": "action"} },
passive: {
label: "DND5E.FeaturePassive", items: [],
hasActions: false, dataset: {type: "feat"} }
};
for ( const feat of feats ) {
if ( feat.system.activation?.type ) features.active.items.push(feat);
else features.passive.items.push(feat);
}
// Assign and return
context.inventoryFilters = true;
context.inventory = Object.values(inventory);
context.spellbook = spellbook;
context.preparedSpells = nPrepared;
context.features = Object.values(features);
} |
Much the same as character sheet logic, but tuned for NPCs. Notably, there is not an inventory context props and thus no inventory filtering. npc-sheet.mjs: /** @override */
_prepareItems(context) {
// Categorize Items as Features and Spells
const features = {
weapons: { label: game.i18n.localize("DND5E.AttackPl"), items: [], hasActions: true,
dataset: {type: "weapon", "weapon-type": "natural"} },
actions: { label: game.i18n.localize("DND5E.ActionPl"), items: [], hasActions: true,
dataset: {type: "feat", "activation.type": "action"} },
passive: { label: game.i18n.localize("DND5E.Features"), items: [], dataset: {type: "feat"} },
equipment: { label: game.i18n.localize("DND5E.Inventory"), items: [], dataset: {type: "loot"}}
};
// Start by classifying items into groups for rendering
let [spells, other] = context.items.reduce((arr, item) => {
const {quantity, uses, recharge, target} = item.system;
const ctx = context.itemContext[item.id] ??= {};
ctx.isStack = Number.isNumeric(quantity) && (quantity !== 1);
ctx.isExpanded = this._expanded.has(item.id);
ctx.hasUses = uses && (uses.max > 0);
ctx.isOnCooldown = recharge && !!recharge.value && (recharge.charged === false);
ctx.isDepleted = item.isOnCooldown && (uses.per && (uses.value > 0));
ctx.hasTarget = !!target && !(["none", ""].includes(target.type));
ctx.canToggle = false;
if ( item.type === "spell" ) arr[0].push(item);
else arr[1].push(item);
return arr;
}, [[], []]);
// Apply item filters
spells = this._filterItems(spells, this._filters.spellbook);
other = this._filterItems(other, this._filters.features);
// Organize Spellbook
const spellbook = this._prepareSpellbook(context, spells);
// Organize Features
for ( let item of other ) {
if ( item.type === "weapon" ) features.weapons.items.push(item);
else if ( item.type === "feat" ) {
if ( item.system.activation.type ) features.actions.items.push(item);
else features.passive.items.push(item);
}
else features.equipment.items.push(item);
}
// Assign and return
context.inventoryFilters = true;
context.features = Object.values(features);
context.spellbook = spellbook;
} |
Vehicles currently have no filters. vehicle-sheet.mjs: /** @override */
_prepareItems(context) {
const cargoColumns = [{
label: game.i18n.localize("DND5E.Quantity"),
css: "item-qty",
property: "quantity",
editable: "Number"
}];
const equipmentColumns = [{
label: game.i18n.localize("DND5E.Quantity"),
css: "item-qty",
property: "system.quantity",
editable: "Number"
}, {
label: game.i18n.localize("DND5E.AC"),
css: "item-ac",
property: "system.armor.value"
}, {
label: game.i18n.localize("DND5E.HP"),
css: "item-hp",
property: "system.hp.value",
editable: "Number"
}, {
label: game.i18n.localize("DND5E.Threshold"),
css: "item-threshold",
property: "threshold"
}];
const features = {
actions: {
label: game.i18n.localize("DND5E.ActionPl"),
items: [],
hasActions: true,
crewable: true,
dataset: {type: "feat", "activation.type": "crew"},
columns: [{
label: game.i18n.localize("DND5E.Cover"),
css: "item-cover",
property: "cover"
}]
},
equipment: {
label: game.i18n.localize(CONFIG.Item.typeLabels.equipment),
items: [],
crewable: true,
dataset: {type: "equipment", "armor.type": "vehicle"},
columns: equipmentColumns
},
passive: {
label: game.i18n.localize("DND5E.Features"),
items: [],
dataset: {type: "feat"}
},
reactions: {
label: game.i18n.localize("DND5E.ReactionPl"),
items: [],
dataset: {type: "feat", "activation.type": "reaction"}
},
weapons: {
label: game.i18n.localize(`${CONFIG.Item.typeLabels.weapon}Pl`),
items: [],
crewable: true,
dataset: {type: "weapon", "weapon-type": "siege"},
columns: equipmentColumns
}
};
context.items.forEach(item => {
const {uses, recharge} = item.system;
const ctx = context.itemContext[item.id] ??= {};
ctx.canToggle = false;
ctx.isExpanded = this._expanded.has(item.id);
ctx.hasUses = uses && (uses.max > 0);
ctx.isOnCooldown = recharge && !!recharge.value && (recharge.charged === false);
ctx.isDepleted = item.isOnCooldown && (uses.per && (uses.value > 0));
});
const cargo = {
crew: {
label: game.i18n.localize("DND5E.VehicleCrew"),
items: context.actor.system.cargo.crew,
css: "cargo-row crew",
editableName: true,
dataset: {type: "crew"},
columns: cargoColumns
},
passengers: {
label: game.i18n.localize("DND5E.VehiclePassengers"),
items: context.actor.system.cargo.passengers,
css: "cargo-row passengers",
editableName: true,
dataset: {type: "passengers"},
columns: cargoColumns
},
cargo: {
label: game.i18n.localize("DND5E.VehicleCargo"),
items: [],
dataset: {type: "loot"},
columns: [{
label: game.i18n.localize("DND5E.Quantity"),
css: "item-qty",
property: "system.quantity",
editable: "Number"
}, {
label: game.i18n.localize("DND5E.Price"),
css: "item-price",
property: "system.price.value",
editable: "Number"
}, {
label: game.i18n.localize("DND5E.Weight"),
css: "item-weight",
property: "system.weight",
editable: "Number"
}]
}
};
// Classify items owned by the vehicle and compute total cargo weight
let totalWeight = 0;
for ( const item of context.items ) {
const ctx = context.itemContext[item.id] ??= {};
this._prepareCrewedItem(item, ctx);
// Handle cargo explicitly
const isCargo = item.flags.dnd5e?.vehicleCargo === true;
if ( isCargo ) {
totalWeight += (item.system.weight || 0) * item.system.quantity;
cargo.cargo.items.push(item);
continue;
}
// Handle non-cargo item types
switch ( item.type ) {
case "weapon":
features.weapons.items.push(item);
break;
case "equipment":
features.equipment.items.push(item);
break;
case "feat":
const act = item.system.activation;
if ( !act.type || (act.type === "none") ) features.passive.items.push(item);
else if (act.type === "reaction") features.reactions.items.push(item);
else features.actions.items.push(item);
break;
default:
totalWeight += (item.system.weight || 0) * item.system.quantity;
cargo.cargo.items.push(item);
}
}
// Update the rendering context data
context.inventoryFilters = false;
context.features = Object.values(features);
context.cargo = Object.values(cargo);
context.encumbrance = this._computeEncumbrance(totalWeight, context);
} |
* Began drafting ItemFilterService. * Minimally prototyped on the character inventory tab with action economy filters. Began ItemFilterRuntime service. * Extracted new filter buttons to V2 temp utility bar toggle, to stand in while I formalize the filters prototype. * Made temp toggle buttons respect tabindex config. * Generalized the UtilityItemFiltersV2 component to whatever group name is passed in. * Updated ItemFilterService to be capable of stitching together actor item filter data for rendering. * Extracted default item filters to their own file. Established actor/tab filter associations by actor type and tab ID. Added function to ItemFilterRuntime to get actor fitlers for a given actor. * Removed prototype code from character sheets and adjusted it for the changes to ItemFilterService. Added filter setup to NPC sheet class. * Updated ActorSheetContext to include actor item filter data and to remove the prototype prop itemFilters. * Implemented character features, inventory, and spellbook filters. * Plugged in NPC filters. * Did a quick fix for static getters that invoke Foundry-ready-dependent Tidy content. * Added FilterMenu component and swapped it into all filtering locations. Began laying out filters as pill buttons in the filter menu component, as FilterToggleButton components. Removed prototypal code. Added Clear All functionality. * Added pill-button class. * Added loc keys for Filters menu tooltip and Clear All button text. * Added Clear All pill button. Updated filter toggle buttons to be pill buttons and added the current, standard colors. * Added groups to item filters in preparation to break filters out into sections. * Changed the term "group" to "category" for item filters. Adjusted actor tab filters to also subdivide data into categories from tabs. Updated FilterMenu to section the filters into categories. * Added init function for ItemFilterRuntime. Added initRuntime function that will init all runtime classes that needed. Updated main to init runtime when Foundry is ready and before the API is available. Fixed verbal filter predicate. Added data-driven CONFIG filter for itemRarity. Updated actor item filter data fetching to invoke the dynamic filters when a function is detected. * Added capitalization to FilterToggleButtons. * Added some error handling when evaluating filter predicates. * Added spell school filters to spellbook tabs. * Added attunement filters. * Fine-tuned the row-gap for filter buttons. Adjusted the button menu divider color. * Resolved TODO. * Resolved TODO. * Resolved TODO for moving item runtime types to their own file as a first step to segmenting out / organizing runtime types. * Resolved subscription stacking issue that occurs when subscribing to stores within the constructor. Delegated to activateListeners with an unsubscribe upon registering new subscriptions, and added unsubscribe to window close. * Unexported private types in ItemFilterService. * Optimized filtering code so that all items of a group are filtered together. Promoted section item filtering from individual items to sections of items, with future plans to move search one level further up to prefilter items. * Corrected new filter logic to use the configured filter value instead of just `true`.
Closing as completed. |
Update, February 2024
With dnd5e 3.0.0, the filter sets have been moved or replaced by something else.
It is now time to go ahead and implement an alternative.
- [x] Support session storage of filtersDeferred- [x] Poll the community for desired filters (e.g., "Can Cast" in Spellbook to represent spells that can be cast, whether from being prepared, a cantrip, innate, at-will, a pact spell, etc.)Deferred until community members can use filters and offer up ideas.Original Notes
Upgrade tab filter buttons so that they can represent one of the following 3 states:
Currently, the built-in tab filters change the count of spells throughout the sheet; this has proven to be misleading over the years. Instead,
Example of filter button states:
User input:
(when focus visible) Spacebar: cycle forward(when focus visible) Shift+Spacebar: cycle backwardColors:
rgba(205, 92, 92, 1)
, as seen in the screenshot, open to suggestions, but something that denotes warning or exclusionPersist these settings as part of sheet state #17 in sessionStorage.The text was updated successfully, but these errors were encountered: