Theme System:
- Add _theme-variables.scss with light (parchment) and dark color palettes
- Register theme options in system.json for Foundry v13 color scheme support
- Convert all SCSS color variables to CSS custom properties
- Update base, mixins, components, and sheet styles for theme support
- Add _applyThemeClass() to actor and item sheet classes
ProseMirror Editor Fix (v13 ApplicationV2):
- Replace {{editor}} helper with <prose-mirror> custom element
- Add TextEditor.enrichHTML() for rich text content preparation
- Update all 8 item templates (spell, weapon, armor, equipment, etc.)
- Fix toolbar hiding content by renaming wrapper to .editor-wrapper
- Style prose-mirror with sticky toolbar and proper flex layout
Roll Dialog & Chat Card Styling:
- Complete roll dialog styling with favor/hinder toggles, info panels
- Complete chat card styling with roll results, damage display, animations
- Mark tasks 5.7 and 5.8 complete in roadmap
- Add task 5.11 for deferred resizable editor feature
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
524 lines
14 KiB
JavaScript
524 lines
14 KiB
JavaScript
/**
|
|
* 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: `<p>${game.i18n.format("VAGABOND.DeleteEffectConfirm", { name: effect.name })}</p>`,
|
|
});
|
|
|
|
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 });
|
|
}
|
|
}
|
|
}
|