[Feature] Compedium Browser (#707)

* Create files

* Item Browser v0.5

* dialog with (temporary)

* Css

* initial style in compedium browser

* config

* fixes

* Replace compendium calls

* Level Up

* style compedium item list

* removing css files

---------

Co-authored-by: Dapoolp <elcatnet@gmail.com>
Co-authored-by: WBHarry <williambjrklund@gmail.com>
This commit is contained in:
Murilo Brito 2025-08-07 20:08:52 -03:00 committed by GitHub
parent a19c77ae4a
commit a25007b994
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1650 additions and 123 deletions

View file

@ -1,5 +1,6 @@
import { abilities } from '../../config/actorConfig.mjs';
import { burden } from '../../config/generalConfig.mjs';
import { ItemBrowser } from '../ui/itemBrowser.mjs';
import { createEmbeddedItemsWithEffects, createEmbeddedItemWithEffects } from '../../helpers/utils.mjs';
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
@ -42,6 +43,8 @@ export default class DhCharacterCreation extends HandlebarsApplicationMixin(Appl
};
this._dragDrop = this._createDragDropHandlers();
this.itemBrowser = null;
}
get title() {
@ -491,8 +494,24 @@ export default class DhCharacterCreation extends HandlebarsApplicationMixin(Appl
});
}
static async viewCompendium(_, target) {
(await game.packs.get(`daggerheart.${target.dataset.compendium}`))?.render(true);
static async viewCompendium(event, target) {
const type = target.dataset.compendium ?? target.dataset.type;
const presets = {
compendium: 'daggerheart',
folder: type,
render: {
noFolder: true
}
};
if (type == 'domains')
presets.filter = {
'level.max': { key: 'level.max', value: 1 },
'system.domain': { key: 'system.domain', value: this.setup.class?.system.domains ?? null }
};
return (this.itemBrowser = await new ItemBrowser({ presets }).render({ force: true }));
}
static async viewItem(_, target) {
@ -604,6 +623,7 @@ export default class DhCharacterCreation extends HandlebarsApplicationMixin(Appl
{ overwrite: true }
);
if (this.itemBrowser) this.itemBrowser.close();
this.close();
}

View file

@ -1,5 +1,6 @@
import { abilities, subclassFeatureLabels } from '../../config/actorConfig.mjs';
import { getDeleteKeys, tagifyElement } from '../../helpers/utils.mjs';
import { ItemBrowser } from '../ui/itemBrowser.mjs';
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
@ -11,6 +12,8 @@ export default class DhlevelUp extends HandlebarsApplicationMixin(ApplicationV2)
this._dragDrop = this._createDragDropHandlers();
this.tabGroups.primary = 'advancements';
this.itemBrowser = null;
}
get title() {
@ -533,8 +536,30 @@ export default class DhlevelUp extends HandlebarsApplicationMixin(ApplicationV2)
this.render();
}
static async viewCompendium(_, button) {
(await game.packs.get(`daggerheart.${button.dataset.compendium}`))?.render(true);
static async viewCompendium(event, target) {
const type = target.dataset.compendium ?? target.dataset.type;
const presets = {
compendium: 'daggerheart',
folder: type,
render: {
noFolder: true
}
};
if (type == 'domains') {
const domains = this.actor.system.domains,
multiclassDomain = this.levelup.classUpgradeChoices?.multiclass?.domain;
if (multiclassDomain) {
if (!domains.includes(x => x === multiclassDomain)) domains.push(multiclassDomain);
}
presets.filter = {
'level.max': { key: 'level.max', value: this.levelup.currentLevel },
'system.domain': { key: 'system.domain', value: domains }
};
}
return (this.itemBrowser = await new ItemBrowser({ presets }).render({ force: true }));
}
static async selectPreview(_, button) {
@ -635,6 +660,7 @@ export default class DhlevelUp extends HandlebarsApplicationMixin(ApplicationV2)
}, {});
await this.actor.levelUp(levelupData);
if (this.itemBrowser) this.itemBrowser.close();
this.close();
}
}

View file

@ -5,6 +5,7 @@ import DhCharacterlevelUp from '../../levelup/characterLevelup.mjs';
import DhCharacterCreation from '../../characterCreation/characterCreation.mjs';
import FilterMenu from '../../ux/filter-menu.mjs';
import { getDocFromElement, getDocFromElementSync } from '../../../helpers/utils.mjs';
import { ItemBrowser } from '../../ui/itemBrowser.mjs';
/**@typedef {import('@client/applications/_types.mjs').ApplicationClickAction} ApplicationClickAction */
@ -25,7 +26,8 @@ export default class CharacterSheet extends DHBaseActorSheet {
toggleEquipItem: CharacterSheet.#toggleEquipItem,
toggleResourceDice: CharacterSheet.#toggleResourceDice,
handleResourceDice: CharacterSheet.#handleResourceDice,
useDowntime: this.useDowntime
useDowntime: this.useDowntime,
tempBrowser: CharacterSheet.#tempBrowser
},
window: {
resizable: true
@ -600,7 +602,16 @@ export default class CharacterSheet extends DHBaseActorSheet {
*/
static async #openPack(_event, button) {
const { key } = button.dataset;
game.packs.get(key)?.render(true);
const presets = {
compendium: 'daggerheart',
folder: key,
render: {
noFolder: true
}
};
return new ItemBrowser({ presets }).render({ force: true });
}
/**
@ -715,6 +726,13 @@ export default class CharacterSheet extends DHBaseActorSheet {
});
}
/**
* Temp
*/
static async #tempBrowser(_, target) {
new ItemBrowser().render({ force: true });
}
/**
* Handle the roll values of resource dice.
* @type {ApplicationClickAction}

View file

@ -1,5 +1,6 @@
const { HandlebarsApplicationMixin } = foundry.applications.api;
import { getDocFromElement, getDocFromElementSync, tagifyElement } from '../../../helpers/utils.mjs';
import { ItemBrowser } from '../../ui/itemBrowser.mjs';
/**
* @typedef {import('@client/applications/_types.mjs').ApplicationClickAction} ApplicationClickAction
@ -82,7 +83,9 @@ export default function DHApplicationMixin(Base) {
toChat: DHSheetV2.#toChat,
useItem: DHSheetV2.#useItem,
toggleEffect: DHSheetV2.#toggleEffect,
toggleExtended: DHSheetV2.#toggleExtended
toggleExtended: DHSheetV2.#toggleExtended,
addNewItem: DHSheetV2.#addNewItem,
browseItem: DHSheetV2.#browseItem
},
contextMenus: [
{
@ -333,19 +336,19 @@ export default function DHApplicationMixin(Base) {
callback: async (target, event) => {
const doc = await getDocFromElement(target),
action = doc?.system?.attack ?? doc;
return action && action.use(event, { byPassRoll: true })
return action && action.use(event, { byPassRoll: true });
}
});
options.unshift({
name: 'DAGGERHEART.APPLICATIONS.ContextMenu.useItem',
icon: 'fa-solid fa-burst',
condition: target => {
const doc = getDocFromElementSync(target);
return doc && !(doc.type === 'domainCard' && doc.system.inVault);
},
callback: async (target, event) => (await getDocFromElement(target)).use(event)
});
options.unshift({
name: 'DAGGERHEART.APPLICATIONS.ContextMenu.useItem',
icon: 'fa-solid fa-burst',
condition: target => {
const doc = getDocFromElementSync(target);
return doc && !(doc.type === 'domainCard' && doc.system.inVault);
},
callback: async (target, event) => (await getDocFromElement(target)).use(event)
});
if (toChat)
options.push({
@ -423,6 +426,68 @@ export default function DHApplicationMixin(Base) {
/* Application Clicks Actions */
/* -------------------------------------------- */
static async #addNewItem(event, target) {
const { type } = target.dataset;
const createChoice = await foundry.applications.api.DialogV2.wait({
classes: ['dh-style', 'two-big-buttons'],
buttons: [
{
action: 'create',
label: 'Create Item',
icon: 'fa-solid fa-plus'
},
{
action: 'browse',
label: 'Browse Compendium',
icon: 'fa-solid fa-book'
}
]
});
if (!createChoice) return;
if (createChoice === 'browse') return DHSheetV2.#browseItem.call(this, event, target);
else return DHSheetV2.#createDoc.call(this, event, target);
}
static async #browseItem(event, target) {
const type = target.dataset.compendium ?? target.dataset.type;
const presets = {};
switch (type) {
case 'loot':
case 'consumable':
case 'armor':
case 'weapon':
presets.compendium = 'daggerheart';
presets.folder = 'equipments';
presets.render = {
noFolder: true
};
presets.filter = {
type: { key: 'type', value: type, forced: true }
};
break;
case 'domainCard':
presets.compendium = 'daggerheart';
presets.folder = 'domains';
presets.render = {
noFolder: true
};
presets.filter = {
'level.max': { key: 'level.max', value: this.document.system.levelData.level.current },
'system.domain': { key: 'system.domain', value: this.document.system.domains }
};
break;
default:
return;
}
return new ItemBrowser({ presets }).render({ force: true });
}
/**
* Create an embedded document.
* @type {ApplicationClickAction}

View file

@ -194,11 +194,12 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
event.stopPropagation();
const item = await foundry.utils.fromUuid(message.system.origin);
const action = item.system.attack?.id === event.currentTarget.id ? item.system.attack : item.system.actions.get(event.currentTarget.id);
if(event.currentTarget.dataset.directDamage)
action.use(event, { byPassRoll: true })
else
action.use(event);
const action =
item.system.attack?.id === event.currentTarget.id
? item.system.attack
: item.system.actions.get(event.currentTarget.id);
if (event.currentTarget.dataset.directDamage) action.use(event, { byPassRoll: true });
else action.use(event);
}
async actionUseButton(event, message) {

View file

@ -0,0 +1,425 @@
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
/**
* A UI element which displays the Users defined for this world.
* Currently active users are always displayed, while inactive users can be displayed on toggle.
*
* @extends ApplicationV2
* @mixes HandlebarsApplication
*/
export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(options = {}) {
super(options);
this.items = [];
this.fieldFilter = [];
this.selectedMenu = { path: [], data: null };
this.config = CONFIG.DH.ITEMBROWSER.compendiumConfig;
this.presets = options.presets;
if(this.presets?.compendium && this.presets?.folder)
ItemBrowser.selectFolder.call(this, null, null, this.presets.compendium, this.presets.folder);
}
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: 'itemBrowser',
classes: ['daggerheart', 'dh-style', 'dialog', 'compendium-browser'],
tag: 'div',
// title: 'Item Browser',
window: {
frame: true,
title: 'Compendium Browser',
icon: 'fa-solid fa-book-atlas',
positioned: true,
resizable: true
},
actions: {
selectFolder: this.selectFolder,
expandContent: this.expandContent,
resetFilters: this.resetFilters,
sortList: this.sortList
},
position: {
top: 330,
left: 120,
width: 800,
height: 600
}
};
/** @override */
static PARTS = {
sidebar: {
template: 'systems/daggerheart/templates/ui/itemBrowser/sidebar.hbs'
},
list: {
template: 'systems/daggerheart/templates/ui/itemBrowser/itemBrowser.hbs'
}
};
/* -------------------------------------------- */
/* Filter Tracking */
/* -------------------------------------------- */
/**
* The currently active search filter.
* @type {foundry.applications.ux.SearchFilter}
*/
#search = {};
#input = {};
/**
* Tracks which item IDs are currently displayed, organized by filter type and section.
* @type {{
* inventory: {
* search: Set<string>,
* input: Set<string>
* }
* }}
*/
#filteredItems = {
browser: {
search: new Set(),
input: new Set()
}
};
/** @inheritDoc */
async _preFirstRender(context, options) {
if(context.presets?.render?.noFolder || context.presets?.render?.lite)
options.position.width = 600;
await super._preFirstRender(context, options);
}
/** @inheritDoc */
async _preRender(context, options) {
if(context.presets?.render?.noFolder || context.presets?.render?.lite)
options.parts.splice(options.parts.indexOf('sidebar'), 1);
await super._preRender(context, options);
}
/** @inheritDoc */
async _onRender(context, options) {
await super._onRender(context, options);
this._createSearchFilter();
this._createFilterInputs();
this._createDragProcess();
if(context.presets?.render?.lite)
this.element.classList.add('lite');
if(context.presets?.render?.noFolder)
this.element.classList.add('no-folder');
if(context.presets?.render?.noFilter)
this.element.classList.add('no-filter');
if(this.presets?.filter) {
Object.entries(this.presets.filter).forEach(([k,v]) => this.fieldFilter.find(c => c.name === k).value = v.value);
await this._onInputFilterBrowser();
}
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @override */
async _prepareContext(options) {
const context = await super._prepareContext(options);
context.compendiums = this.getCompendiumFolders(foundry.utils.deepClone(this.config));
// context.pathTitle = this.pathTile;
context.menu = this.selectedMenu;
context.formatLabel = this.formatLabel;
context.formatChoices = this.formatChoices;
context.fieldFilter = this.fieldFilter = this._createFieldFilter();
context.items = this.items;
context.presets = this.presets;
return context;
}
getCompendiumFolders(config, parent = null, depth = 0) {
let folders = [];
Object.values(config).forEach(c => {
const folder = {
id: c.id,
label: c.label,
selected: (!parent || parent.selected) && this.selectedMenu.path[depth] === c.id
};
folder.folders = c.folders
? ItemBrowser.sortBy(this.getCompendiumFolders(c.folders, folder, depth + 2), 'label')
: [];
folders.push(folder);
});
return folders;
}
static async selectFolder(_, target, compend, folder) {
const config = foundry.utils.deepClone(this.config),
compendium = compend ?? target.closest('[data-compendium-id]').dataset.compendiumId,
folderId = folder ?? target.dataset.folderId,
folderPath = `${compendium}.folders.${folderId}`,
folderData = foundry.utils.getProperty(config, folderPath);
this.selectedMenu = {
path: folderPath.split('.'),
data: {
...folderData,
columns: ItemBrowser.getFolderConfig(folderData)
}
};
let items = [];
for (const key of folderData.keys) {
const comp = game.packs.get(`${compendium}.${key}`);
if (!comp) return;
items = items.concat(await comp.getDocuments({ type__in: folderData.type }));
}
this.items = ItemBrowser.sortBy(items, 'name');
this.render({ force: true });
}
static expandContent(_, target) {
const parent = target.parentElement;
parent.classList.toggle('expanded');
}
static sortBy(data, property) {
return data.sort((a, b) => (a[property] > b[property] ? 1 : -1));
}
formatLabel(item, field) {
const property = foundry.utils.getProperty(item, field.key);
if (typeof field.format !== 'function') return property ?? '-';
return field.format(property);
}
formatChoices(data) {
if (!data.field.choices) return null;
const config = {
choices: data.field.choices
};
foundry.data.fields.StringField._prepareChoiceConfig(config);
return config.options.filter(
c => data.filtered.includes(c.value) || data.filtered.includes(c.label.toLowerCase())
);
}
_createFieldFilter() {
const filters = ItemBrowser.getFolderConfig(this.selectedMenu.data, 'filters');
filters.forEach(f => {
if (typeof f.field === 'string') f.field = foundry.utils.getProperty(game, f.field);
else if (typeof f.choices === 'function') {
f.choices = f.choices();
}
f.name ??= f.key;
f.value = this.presets?.filter?.[f.name]?.value ?? null;
});
return filters;
}
/* -------------------------------------------- */
/* Search Inputs */
/* -------------------------------------------- */
/**
* Create and initialize search filter instances for the inventory and loadout sections.
*
* Sets up two {@link foundry.applications.ux.SearchFilter} instances:
* - One for the inventory, which filters items in the inventory grid.
* - One for the loadout, which filters items in the loadout/card grid.
* @private
*/
_createSearchFilter() {
//Filters could be a application option if needed
const filters = [
{
key: 'browser',
input: 'input[type="search"].search-input',
content: '[data-application-part="list"] .item-list',
callback: this._onSearchFilterBrowser.bind(this)
}
];
for (const { key, input, content, callback } of filters) {
const filter = new foundry.applications.ux.SearchFilter({
inputSelector: input,
contentSelector: content,
callback
});
filter.bind(this.element);
this.#search[key] = filter;
}
}
/* -------------------------------------------- */
/* Filter Inputs */
/* -------------------------------------------- */
_createFilterInputs() {
const inputs = [
{
key: 'browser',
container: '[data-application-part="list"] .filter-content .wrapper',
content: '[data-application-part="list"] .item-list',
callback: this._onInputFilterBrowser.bind(this)
}
];
inputs.forEach(m => {
const container = this.element.querySelector(m.container);
if (!container) return (this.#input[m.key] = {});
const inputs = container.querySelectorAll('input, select');
inputs.forEach(input => {
input.addEventListener('change', this._onInputFilterBrowser.bind(this));
});
this.#filteredItems[m.key].input = new Set(this.items.map(i => i.id));
this.#input[m.key] = inputs;
});
}
/**
* Handle invetory items search and filtering.
* @param {KeyboardEvent} event The keyboard input event.
* @param {string} query The input search string.
* @param {RegExp} rgx The regular expression query that should be matched against.
* @param {HTMLElement} html The container to filter items from.
* @protected
*/
async _onSearchFilterBrowser(event, query, rgx, html) {
this.#filteredItems.browser.search.clear();
for (const li of html.querySelectorAll('.item-container')) {
const itemUUID = li.dataset.itemUuid,
item = this.items.find(i => i.uuid === itemUUID);
const matchesSearch = !query || foundry.applications.ux.SearchFilter.testQuery(rgx, item.name);
if (matchesSearch) this.#filteredItems.browser.search.add(item.id);
const { input } = this.#filteredItems.browser;
li.hidden = !(input.has(item.id) && matchesSearch);
}
}
/**
* Callback when filters change
* @param {PointerEvent} event
* @param {HTMLElement} html
*/
async _onInputFilterBrowser(event) {
this.#filteredItems.browser.input.clear();
if(event) this.fieldFilter.find(f => f.name === event.target.name).value = event.target.value;
for (const li of this.element.querySelectorAll('.item-container')) {
const itemUUID = li.dataset.itemUuid,
item = this.items.find(i => i.uuid === itemUUID);
if(!item) continue;
const matchesMenu =
this.fieldFilter.length === 0 ||
this.fieldFilter.every(f => (
!f.value && f.value !== false) ||
ItemBrowser.evaluateFilter(item, this.createFilterData(f))
);
if (matchesMenu) this.#filteredItems.browser.input.add(item.id);
const { search } = this.#filteredItems.browser;
li.hidden = !(search.has(item.id) && matchesMenu);
}
}
/**
* Foundry evaluateFilter doesn't allow you to match if filter values are included into item data
* @param {*} obj
* @param {*} filter
*/
static evaluateFilter(obj, filter) {
let docValue = foundry.utils.getProperty(obj, filter.field);
let filterValue = filter.value;
switch (filter.operator) {
case "contains2":
filterValue = Array.isArray(filterValue) ? filterValue : [filterValue];
docValue = Array.isArray(docValue) ? docValue : [docValue];
return docValue.some(dv => filterValue.includes(dv));
case "contains3":
return docValue.some(f => f.value === filterValue);
default:
return foundry.applications.ux.SearchFilter.evaluateFilter(obj, filter);
}
}
createFilterData(filter) {
return {
field: filter.key,
value: isNaN(filter.value)
? ['true', 'false'].includes(filter.value)
? filter.value === 'true'
: filter.value
: Number(filter.value),
operator: filter.operator,
negate: filter.negate
};
}
static resetFilters() {
this.render({ force: true });
}
static getFolderConfig(folder, property = "columns") {
if(!folder) return [];
return folder[property] ?? CONFIG.DH.ITEMBROWSER.typeConfig[folder.listType]?.[property] ?? [];
}
static sortList(_, target) {
const key = target.dataset.sortKey,
type = !target.dataset.sortType || target.dataset.sortType === "DESC" ? "ASC" : "DESC",
itemListContainer = target.closest(".compendium-results").querySelector(".item-list"),
itemList = itemListContainer.querySelectorAll(".item-container");
target.closest(".item-list-header").querySelectorAll('[data-sort-key]').forEach(b => b.dataset.sortType = "");
target.dataset.sortType = type;
const newOrder = [...itemList].reverse().sort((a, b) => {
const aProp = a.querySelector(`[data-item-key="${key}"]`),
bProp = b.querySelector(`[data-item-key="${key}"]`)
if(type === "DESC") {
return aProp.innerText < bProp.innerText ? 1 : -1;
} else {
return aProp.innerText > bProp.innerText ? 1 : -1;
}
});
itemListContainer.replaceChildren(...newOrder);
}
_createDragProcess() {
new foundry.applications.ux.DragDrop.implementation({
dragSelector: '.item-container',
permissions: {
dragstart: this._canDragStart.bind(this)
},
callbacks: {
dragstart: this._onDragStart.bind(this)
}
}).bind(this.element);
}
async _onDragStart(event) {
const { itemUuid } = event.target.closest('[data-item-uuid]').dataset,
item = await foundry.utils.fromUuid(itemUuid),
dragData = item.toDragData();
event.dataTransfer.setData('text/plain', JSON.stringify(dragData));
}
_canDragStart() {
return true;
}
}