/** * Base Item Sheet for Vagabond RPG * * Provides common functionality for all item sheets: * - Tab navigation (for complex items) * - Form handling with automatic updates * - Rich text editor integration * - Type-specific rendering via parts system * * Uses Foundry VTT v13 ItemSheetV2 API with HandlebarsApplicationMixin. * * @extends ItemSheetV2 * @mixes HandlebarsApplicationMixin */ const { HandlebarsApplicationMixin } = foundry.applications.api; const { ItemSheetV2 } = foundry.applications.sheets; export default class VagabondItemSheet extends HandlebarsApplicationMixin(ItemSheetV2) { /** * @param {Object} options - Application options */ constructor(options = {}) { super(options); // Active tab tracking (for items with tabs) this._activeTab = "details"; } /* -------------------------------------------- */ /* Static Properties */ /* -------------------------------------------- */ /** @override */ static DEFAULT_OPTIONS = { id: "vagabond-item-sheet-{id}", classes: ["vagabond", "sheet", "item", "themed"], tag: "form", window: { title: "VAGABOND.ItemSheet", icon: "fa-solid fa-suitcase", resizable: true, }, position: { width: 520, height: 480, }, form: { handler: VagabondItemSheet.#onFormSubmit, submitOnChange: true, closeOnSubmit: false, }, actions: { changeTab: VagabondItemSheet.#onChangeTab, addArrayEntry: VagabondItemSheet.#onAddArrayEntry, deleteArrayEntry: VagabondItemSheet.#onDeleteArrayEntry, createEffect: VagabondItemSheet.#onCreateEffect, editEffect: VagabondItemSheet.#onEditEffect, deleteEffect: VagabondItemSheet.#onDeleteEffect, toggleEffect: VagabondItemSheet.#onToggleEffect, }, }; /** @override */ static PARTS = { header: { template: "systems/vagabond/templates/item/parts/item-header.hbs", }, tabs: { template: "systems/vagabond/templates/item/parts/item-tabs.hbs", }, body: { template: "systems/vagabond/templates/item/parts/item-body.hbs", }, effects: { template: "systems/vagabond/templates/item/parts/item-effects.hbs", }, }; /* -------------------------------------------- */ /* Getters */ /* -------------------------------------------- */ /** * Convenient alias for the item document. * @returns {VagabondItem} */ get item() { return this.document; } /** @override */ get title() { return this.document.name; } /** * Get the available tabs for this item type. * All items have Details and Effects tabs. * @returns {Object[]} Array of tab definitions */ get tabs() { return [ { id: "details", label: "VAGABOND.TabDetails", icon: "fas fa-list", }, { id: "effects", label: "VAGABOND.TabEffects", icon: "fas fa-bolt", }, ]; } /** * Whether this item type uses tabs. * @returns {boolean} */ get hasTabs() { return this.tabs !== null && this.tabs.length > 0; } /* -------------------------------------------- */ /* Data Preparation */ /* -------------------------------------------- */ /** @override */ async _prepareContext(options) { const context = await super._prepareContext(options); // Basic item data context.item = this.item; context.system = this.item.system; context.source = this.item.toObject().system; // Sheet state context.hasTabs = this.hasTabs; context.activeTab = this._activeTab; if (this.hasTabs) { context.tabs = this.tabs.map((tab) => ({ ...tab, active: tab.id === this._activeTab, cssClass: tab.id === this._activeTab ? "active" : "", })); } // Roll data for formulas in templates context.rollData = this.item.getRollData(); // Editable state context.editable = this.isEditable; context.owner = this.item.isOwner; context.limited = this.item.limited; // System configuration context.config = CONFIG.VAGABOND; // Item type for conditional rendering context.itemType = this.item.type; // Active Effects on this item context.effects = this._prepareEffects(); // Enrich HTML content for ProseMirror editors // ApplicationV2 requires enriched HTML to be prepared in context const enrichOptions = { secrets: this.item.isOwner, rollData: context.rollData, relativeTo: this.item, }; const TextEditorImpl = foundry.applications.ux.TextEditor.implementation; context.enrichedDescription = await TextEditorImpl.enrichHTML( this.item.system.description ?? "", enrichOptions ); // Enrich spell-specific fields if (this.item.type === "spell") { context.enrichedEffect = await TextEditorImpl.enrichHTML( this.item.system.effect ?? "", enrichOptions ); context.enrichedCritEffect = await TextEditorImpl.enrichHTML( this.item.system.critEffect ?? "", enrichOptions ); } // Enrich class-specific fields if (this.item.type === "class") { context.enrichedStartingPack = await TextEditorImpl.enrichHTML( this.item.system.startingPack ?? "", enrichOptions ); } // Type-specific context await this._prepareTypeContext(context, options); return context; } /** * Prepare Active Effects for display. * @returns {Object[]} Array of effect data for rendering * @protected */ _prepareEffects() { return this.item.effects.map((effect) => ({ id: effect.id, name: effect.name, icon: effect.icon, disabled: effect.disabled, duration: effect.duration, source: effect.sourceName, })); } /** * Prepare type-specific context data. * Subclasses should override this. * * @param {Object} context - The context object to augment * @param {Object} options - Render options * @protected */ async _prepareTypeContext(_context, _options) { // Override in subclasses } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** @override */ _onRender(context, options) { super._onRender(context, options); // Apply theme class based on configured theme this._applyThemeClass(); // Clean up inactive tabs if using tabs if (this.hasTabs) { this._cleanupInactiveTabs(); } // Initialize rich text editors this._initializeEditors(); } /** * Apply the configured theme class to the sheet element. * Foundry v13 doesn't automatically add theme classes to ApplicationV2 sheets, * so we handle it manually. * @protected */ _applyThemeClass() { if (!this.element) return; // Remove any existing theme classes this.element.classList.remove("theme-light", "theme-dark"); // Try to get per-sheet theme (location TBD - Foundry v13 storage unclear) // TODO: Find correct API for per-sheet theme in Foundry v13 const sheetConfig = this.document.getFlag("core", "sheetTheme"); const sheetClasses = game.settings.get("core", "sheetClasses"); const typeConfig = sheetClasses?.[this.document.documentName]?.[this.document.type]; // Determine which theme to apply: document-specific > type default > global let theme = sheetConfig || typeConfig?.defaultTheme; // If no specific theme, check global preference if (!theme) { const uiConfig = game.settings.get("core", "uiConfig"); const colorScheme = uiConfig?.colorScheme?.applications; if (colorScheme === "dark") { theme = "dark"; } else if (colorScheme === "light") { theme = "light"; } } // Apply the theme class if (theme === "dark") { this.element.classList.add("theme-dark"); } else if (theme === "light") { this.element.classList.add("theme-light"); } } /** * Remove tab content sections that don't match the active tab. * ApplicationV2's parts rendering appends new parts without removing old ones. * @protected */ _cleanupInactiveTabs() { if (!this.element) return; const activeTabClass = `${this._activeTab}-tab`; const tabContents = this.element.querySelectorAll(".tab-content"); for (const tabContent of tabContents) { if (!tabContent.classList.contains(activeTabClass)) { tabContent.remove(); } } } /** * Initialize rich text editors for description fields. * @protected */ _initializeEditors() { // Foundry automatically handles ProseMirror/TinyMCE for HTMLField inputs // Additional initialization can be added here if needed } /* -------------------------------------------- */ /* Action Handlers */ /* -------------------------------------------- */ /** * Handle form submission. * @param {Event} event * @param {HTMLFormElement} form * @param {FormDataExtended} formData */ static async #onFormSubmit(event, form, formData) { const sheet = this; const updateData = foundry.utils.expandObject(formData.object); // Clean up array data that may have gaps from deletions sheet._cleanArrayData(updateData); await sheet.item.update(updateData); } /** * Clean array data to remove gaps from deleted entries. * Converts { "0": {...}, "2": {...} } to [ {...}, {...} ] * @param {Object} data - The update data object * @protected */ _cleanArrayData(data) { // Handle system-level arrays if (data.system) { this._cleanArraysRecursive(data.system); } } /** * Recursively clean arrays in an object. * @param {Object} obj - Object to clean * @protected */ _cleanArraysRecursive(obj) { for (const [key, value] of Object.entries(obj)) { if (value && typeof value === "object" && !Array.isArray(value)) { // Check if this looks like an indexed object (array-like) const keys = Object.keys(value); const isIndexed = keys.every((k) => /^\d+$/.test(k)); if (isIndexed && keys.length > 0) { // Convert to array, filtering out nulls/undefined obj[key] = Object.values(value).filter((v) => v != null); } else { // Recurse into nested objects this._cleanArraysRecursive(value); } } } } /** * Handle tab change action. * @param {PointerEvent} event * @param {HTMLElement} target */ static async #onChangeTab(event, target) { event.preventDefault(); const tab = target.dataset.tab; if (!tab) return; this._activeTab = tab; this.render(); } /** * Handle adding a new entry to an array field. * @param {PointerEvent} event * @param {HTMLElement} target */ static async #onAddArrayEntry(event, target) { event.preventDefault(); const field = target.dataset.field; if (!field) return; const currentArray = foundry.utils.getProperty(this.item, field) || []; const template = this._getArrayEntryTemplate(field); await this.item.update({ [field]: [...currentArray, template], }); } /** * Handle deleting an entry from an array field. * @param {PointerEvent} event * @param {HTMLElement} target */ static async #onDeleteArrayEntry(event, target) { event.preventDefault(); const field = target.dataset.field; const index = parseInt(target.dataset.index, 10); if (!field || isNaN(index)) return; const currentArray = foundry.utils.getProperty(this.item, field) || []; const newArray = [...currentArray]; newArray.splice(index, 1); await this.item.update({ [field]: newArray, }); } /** * Get the default template for a new array entry. * Subclasses should override for their specific array fields. * @param {string} field - The field path * @returns {Object} Default entry object * @protected */ _getArrayEntryTemplate(_field) { // Default template - subclasses override for specific fields return { name: "", description: "" }; } /* -------------------------------------------- */ /* Active Effect Actions */ /* -------------------------------------------- */ /** * Handle creating a new Active Effect on this item. * @param {PointerEvent} event * @param {HTMLElement} target */ static async #onCreateEffect(event, _target) { event.preventDefault(); // Create a new effect with default values const effectData = { name: game.i18n.localize("VAGABOND.NewEffect"), icon: "icons/svg/aura.svg", origin: this.item.uuid, disabled: false, }; const [created] = await this.item.createEmbeddedDocuments("ActiveEffect", [effectData]); // Open the effect config sheet if (created) { created.sheet.render(true); } } /** * Handle editing an existing Active Effect. * @param {PointerEvent} event * @param {HTMLElement} target */ static async #onEditEffect(event, target) { event.preventDefault(); const effectId = target.closest("[data-effect-id]")?.dataset.effectId; if (!effectId) return; const effect = this.item.effects.get(effectId); if (effect) { effect.sheet.render(true); } } /** * Handle deleting an Active Effect. * @param {PointerEvent} event * @param {HTMLElement} target */ static async #onDeleteEffect(event, target) { event.preventDefault(); const effectId = target.closest("[data-effect-id]")?.dataset.effectId; if (!effectId) return; const effect = this.item.effects.get(effectId); if (!effect) return; // Confirm deletion const confirmed = await Dialog.confirm({ title: game.i18n.localize("VAGABOND.DeleteEffect"), content: `
${game.i18n.format("VAGABOND.DeleteEffectConfirm", { name: effect.name })}
`, }); if (confirmed) { await effect.delete(); } } /** * Handle toggling an Active Effect's disabled state. * @param {PointerEvent} event * @param {HTMLElement} target */ static async #onToggleEffect(event, target) { event.preventDefault(); const effectId = target.closest("[data-effect-id]")?.dataset.effectId; if (!effectId) return; const effect = this.item.effects.get(effectId); if (effect) { await effect.update({ disabled: !effect.disabled }); } } }