This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
/** @module applications */
export * as types from "./_types.mjs";
export * as api from "./api/_module.mjs";
export * as dice from "./dice/_module.mjs";
export * as elements from "./elements/_module.mjs";
export * as fields from "./forms/fields.mjs";
export * as apps from "./apps/_module.mjs";
export * as sheets from "./sheets/_module.mjs";
export * as ui from "./ui/_module.mjs";
/**
* A registry of currently rendered ApplicationV2 instances.
* @type {Map<number, ApplicationV2>}
*/
export const instances = new Map();
/**
* Parse an HTML string, returning a processed HTMLElement or HTMLCollection.
* A single HTMLElement is returned if the provided string contains only a single top-level element.
* An HTMLCollection is returned if the provided string contains multiple top-level elements.
* @param {string} htmlString
* @returns {HTMLCollection|HTMLElement}
*/
export function parseHTML(htmlString) {
const div = document.createElement("div");
div.innerHTML = htmlString;
const children = div.children;
return children.length > 1 ? children : children[0];
}

View File

@@ -0,0 +1,146 @@
/**
* @typedef {Object} ApplicationConfiguration
* @property {string} id An HTML element identifier used for this Application instance
* @property {string} uniqueId An string discriminator substituted for {id} in the default
* HTML element identifier for the class
* @property {string[]} classes An array of CSS classes to apply to the Application
* @property {string} tag The HTMLElement tag type used for the outer Application frame
* @property {ApplicationWindowConfiguration} window Configuration of the window behaviors for this Application
* @property {Record<string, ApplicationClickAction|{handler: ApplicationClickAction, buttons: number[]}>} actions
* Click actions supported by the Application and their event handler
* functions. A handler function can be defined directly which only
* responds to left-click events. Otherwise, an object can be declared
* containing both a handler function and an array of buttons which are
* matched against the PointerEvent#button property.
* @property {ApplicationFormConfiguration} [form] Configuration used if the application top-level element is a form or
* dialog
* @property {Partial<ApplicationPosition>} position Default positioning data for the application
*/
/**
* @typedef {Object} ApplicationPosition
* @property {number} top Window offset pixels from top
* @property {number} left Window offset pixels from left
* @property {number|"auto"} width Un-scaled pixels in width or "auto"
* @property {number|"auto"} height Un-scaled pixels in height or "auto"
* @property {number} scale A numeric scaling factor applied to application dimensions
* @property {number} zIndex A z-index of the application relative to siblings
*/
/**
* @typedef {Object} ApplicationWindowConfiguration
* @property {boolean} [frame=true] Is this Application rendered inside a window frame?
* @property {boolean} [positioned=true] Can this Application be positioned via JavaScript or only by CSS
* @property {string} [title] The window title. Displayed only if the application is framed
* @property {string|false} [icon] An optional Font Awesome icon class displayed left of the window title
* @property {ApplicationHeaderControlsEntry[]} [controls] An array of window control entries
* @property {boolean} [minimizable=true] Can the window app be minimized by double-clicking on the title
* @property {boolean} [resizable=false] Is this window resizable?
* @property {string} [contentTag="section"] A specific tag name to use for the .window-content element
* @property {string[]} [contentClasses] Additional CSS classes to apply to the .window-content element
*/
/**
* @typedef {Object} ApplicationFormConfiguration
* @property {ApplicationFormSubmission} handler
* @property {boolean} submitOnChange
* @property {boolean} closeOnSubmit
*/
/**
* @typedef {Object} ApplicationHeaderControlsEntry
* @property {string} icon A font-awesome icon class which denotes the control button
* @property {string} label The text label for the control button. This label will be automatically
* localized when the button is rendered
* @property {string} action The action name triggered by clicking the control button
* @property {boolean} [visible] Is the control button visible for the current client?
* @property {string|number} [ownership] A key or value in CONST.DOCUMENT_OWNERSHIP_LEVELS that restricts
* visibility of this option for the current user. This option only
* applies to DocumentSheetV2 instances.
*/
/**
* @typedef {Object} ApplicationConstructorParams
* @property {ApplicationPosition} position
*/
/**
* @typedef {Object} ApplicationRenderOptions
* @property {boolean} [force=false] Force application rendering. If true, an application which does not
* yet exist in the DOM is added. If false, only applications which
* already exist are rendered.
* @property {ApplicationPosition} [position] A specific position at which to render the Application
* @property {ApplicationWindowRenderOptions} [window] Updates to the Application window frame
* @property {string[]} [parts] Some Application classes, for example the HandlebarsApplication,
* support re-rendering a subset of application parts instead of the full
* Application HTML.
* @property {boolean} [isFirstRender] Is this render the first one for the application? This property is
* populated automatically.
*/
/**
* @typedef {Object} ApplicationWindowRenderOptions
* @property {string} title Update the window title with a new value?
* @property {string|false} icon Update the window icon with a new value?
* @property {boolean} controls Re-render the window controls menu?
*/
/**
* @typedef {Object} ApplicationRenderContext Context data provided to the renderer
*/
/**
* @typedef {Object} ApplicationClosingOptions
* @property {boolean} animate Whether to animate the close, or perform it instantaneously
* @property {boolean} closeKey Whether the application was closed via keypress.
*/
/**
* @callback ApplicationClickAction An on-click action supported by the Application. Run in the context of
* a {@link HandlebarsApplication}.
* @param {PointerEvent} event The originating click event
* @param {HTMLElement} target The capturing HTML element which defines the [data-action]
* @returns {Promise<void>}
*/
/**
* @callback ApplicationFormSubmission A form submission handler method. Run in the context of a
* {@link HandlebarsApplication}.
* @param {SubmitEvent|Event} event The originating form submission or input change event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @returns {Promise<void>}
*/
/**
* @typedef {Object} ApplicationTab
* @property {string} id
* @property {string} group
* @property {string} icon
* @property {string} label
* @property {boolean} active
* @property {string} cssClass
*/
/**
* @typedef {Object} FormNode
* @property {boolean} fieldset
* @property {string} [legend]
* @property {FormNode[]} [fields]
* @property {DataField} [field]
* @property {any} [value]
*/
/**
* @typedef {Object} FormFooterButton
* @property {string} type
* @property {string} [name]
* @property {string} [icon]
* @property {string} [label]
* @property {string} [action]
* @property {string} [cssClass]
* @property {boolean} [disabled=false]
*/

View File

@@ -0,0 +1,4 @@
export {default as ApplicationV2} from "./application.mjs";
export {default as DialogV2} from "./dialog.mjs";
export {default as DocumentSheetV2} from "./document-sheet.mjs";
export {default as HandlebarsApplicationMixin} from "./handlebars-application.mjs";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,342 @@
import ApplicationV2 from "./application.mjs";
import {mergeObject} from "../../../common/utils/helpers.mjs";
/**
* @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration
*/
/**
* @typedef {Object} DialogV2Button
* @property {string} action The button action identifier.
* @property {string} label The button label. Will be localized.
* @property {string} [icon] FontAwesome icon classes.
* @property {string} [class] CSS classes to apply to the button.
* @property {boolean} [default] Whether this button represents the default action to take if the user
* submits the form without pressing a button, i.e. with an Enter
* keypress.
* @property {DialogV2ButtonCallback} [callback] A function to invoke when the button is clicked. The value returned
* from this function will be used as the dialog's submitted value.
* Otherwise, the button's identifier is used.
*/
/**
* @callback DialogV2ButtonCallback
* @param {PointerEvent|SubmitEvent} event The button click event, or a form submission event if the dialog was
* submitted via keyboard.
* @param {HTMLButtonElement} button If the form was submitted via keyboard, this will be the default
* button, otherwise the button that was clicked.
* @param {HTMLDialogElement} dialog The dialog element.
* @returns {Promise<any>}
*/
/**
* @typedef {Object} DialogV2Configuration
* @property {boolean} [modal] Modal dialogs prevent interaction with the rest of the UI until they
* are dismissed or submitted.
* @property {DialogV2Button[]} buttons Button configuration.
* @property {string} [content] The dialog content.
* @property {DialogV2SubmitCallback} [submit] A function to invoke when the dialog is submitted. This will not be
* called if the dialog is dismissed.
*/
/**
* @callback DialogV2RenderCallback
* @param {Event} event The render event.
* @param {HTMLDialogElement} dialog The dialog element.
*/
/**
* @callback DialogV2CloseCallback
* @param {Event} event The close event.
* @param {DialogV2} dialog The dialog instance.
*/
/**
* @callback DialogV2SubmitCallback
* @param {any} result Either the identifier of the button that was clicked to submit the
* dialog, or the result returned by that button's callback.
* @returns {Promise<void>}
*/
/**
* @typedef {object} DialogV2WaitOptions
* @property {DialogV2RenderCallback} [render] A synchronous function to invoke whenever the dialog is rendered.
* @property {DialogV2CloseCallback} [close] A synchronous function to invoke when the dialog is closed under any
* circumstances.
* @property {boolean} [rejectClose=true] Throw a Promise rejection if the dialog is dismissed.
*/
/**
* A lightweight Application that renders a dialog containing a form with arbitrary content, and some buttons.
* @extends {ApplicationV2<ApplicationConfiguration & DialogV2Configuration>}
*
* @example Prompt the user to confirm an action.
* ```js
* const proceed = await foundry.applications.api.DialogV2.confirm({
* content: "Are you sure?",
* rejectClose: false,
* modal: true
* });
* if ( proceed ) console.log("Proceed.");
* else console.log("Do not proceed.");
* ```
*
* @example Prompt the user for some input.
* ```js
* let guess;
* try {
* guess = await foundry.applications.api.DialogV2.prompt({
* window: { title: "Guess a number between 1 and 10" },
* content: '<input name="guess" type="number" min="1" max="10" step="1" autofocus>',
* ok: {
* label: "Submit Guess",
* callback: (event, button, dialog) => button.form.elements.guess.valueAsNumber
* }
* });
* } catch {
* console.log("User did not make a guess.");
* return;
* }
* const n = Math.ceil(CONFIG.Dice.randomUniform() * 10);
* if ( n === guess ) console.log("User guessed correctly.");
* else console.log("User guessed incorrectly.");
* ```
*
* @example A custom dialog.
* ```js
* new foundry.applications.api.DialogV2({
* window: { title: "Choose an option" },
* content: `
* <label><input type="radio" name="choice" value="one" checked> Option 1</label>
* <label><input type="radio" name="choice" value="two"> Option 2</label>
* <label><input type="radio" name="choice" value="three"> Options 3</label>
* `,
* buttons: [{
* action: "choice",
* label: "Make Choice",
* default: true,
* callback: (event, button, dialog) => button.form.elements.choice.value
* }, {
* action: "all",
* label: "Take All"
* }],
* submit: result => {
* if ( result === "all" ) console.log("User picked all options.");
* else console.log(`User picked option: ${result}`);
* }
* }).render({ force: true });
* ```
*/
export default class DialogV2 extends ApplicationV2 {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: "dialog-{id}",
classes: ["dialog"],
tag: "dialog",
form: {
closeOnSubmit: true
},
window: {
frame: true,
positioned: true,
minimizable: false
}
};
/* -------------------------------------------- */
/** @inheritDoc */
_initializeApplicationOptions(options) {
options = super._initializeApplicationOptions(options);
if ( !options.buttons?.length ) throw new Error("You must define at least one entry in options.buttons");
options.buttons = options.buttons.reduce((obj, button) => {
options.actions[button.action] = this.constructor._onClickButton;
obj[button.action] = button;
return obj;
}, {});
return options;
}
/* -------------------------------------------- */
/** @override */
async _renderHTML(_context, _options) {
const form = document.createElement("form");
form.className = "dialog-form standard-form";
form.autocomplete = "off";
form.innerHTML = `
${this.options.content ? `<div class="dialog-content standard-form">${this.options.content}</div>` : ""}
<footer class="form-footer">${this._renderButtons()}</footer>
`;
form.addEventListener("submit", event => this._onSubmit(event.submitter, event));
return form;
}
/* -------------------------------------------- */
/**
* Render configured buttons.
* @returns {string}
* @protected
*/
_renderButtons() {
return Object.values(this.options.buttons).map(button => {
const { action, label, icon, default: isDefault, class: cls="" } = button;
return `
<button type="${isDefault ? "submit" : "button"}" data-action="${action}" class="${cls}"
${isDefault ? "autofocus" : ""}>
${icon ? `<i class="${icon}"></i>` : ""}
<span>${game.i18n.localize(label)}</span>
</button>
`;
}).join("");
}
/* -------------------------------------------- */
/**
* Handle submitting the dialog.
* @param {HTMLButtonElement} target The button that was clicked or the default button.
* @param {PointerEvent|SubmitEvent} event The triggering event.
* @returns {Promise<DialogV2>}
* @protected
*/
async _onSubmit(target, event) {
event.preventDefault();
const button = this.options.buttons[target?.dataset.action];
const result = (await button?.callback?.(event, target, this.element)) ?? button?.action;
await this.options.submit?.(result);
return this.options.form.closeOnSubmit ? this.close() : this;
}
/* -------------------------------------------- */
/** @override */
_onFirstRender(_context, _options) {
if ( this.options.modal ) this.element.showModal();
else this.element.show();
}
/* -------------------------------------------- */
/** @inheritDoc */
_attachFrameListeners() {
super._attachFrameListeners();
this.element.addEventListener("keydown", this._onKeyDown.bind(this));
}
/* -------------------------------------------- */
/** @override */
_replaceHTML(result, content, _options) {
content.replaceChildren(result);
}
/* -------------------------------------------- */
/**
* Handle keypresses within the dialog.
* @param {KeyboardEvent} event The triggering event.
* @protected
*/
_onKeyDown(event) {
// Capture Escape keypresses for dialogs to ensure that close is called properly.
if ( event.key === "Escape" ) {
event.preventDefault(); // Prevent default browser dialog dismiss behavior.
event.stopPropagation();
this.close();
}
}
/* -------------------------------------------- */
/**
* @this {DialogV2}
* @param {PointerEvent} event The originating click event.
* @param {HTMLButtonElement} target The button element that was clicked.
* @protected
*/
static _onClickButton(event, target) {
this._onSubmit(target, event);
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* A utility helper to generate a dialog with yes and no buttons.
* @param {Partial<ApplicationConfiguration & DialogV2Configuration & DialogV2WaitOptions>} [options]
* @param {DialogV2Button} [options.yes] Options to overwrite the default yes button configuration.
* @param {DialogV2Button} [options.no] Options to overwrite the default no button configuration.
* @returns {Promise<any>} Resolves to true if the yes button was pressed, or false if the no button
* was pressed. If additional buttons were provided, the Promise resolves to
* the identifier of the one that was pressed, or the value returned by its
* callback. If the dialog was dismissed, and rejectClose is false, the
* Promise resolves to null.
*/
static async confirm({ yes={}, no={}, ...options }={}) {
options.buttons ??= [];
options.buttons.unshift(mergeObject({
action: "yes", label: "Yes", icon: "fas fa-check", callback: () => true
}, yes), mergeObject({
action: "no", label: "No", icon: "fas fa-xmark", default: true, callback: () => false
}, no));
return this.wait(options);
}
/* -------------------------------------------- */
/**
* A utility helper to generate a dialog with a single confirmation button.
* @param {Partial<ApplicationConfiguration & DialogV2Configuration & DialogV2WaitOptions>} [options]
* @param {Partial<DialogV2Button>} [options.ok] Options to overwrite the default confirmation button configuration.
* @returns {Promise<any>} Resolves to the identifier of the button used to submit the dialog,
* or the value returned by that button's callback. If the dialog was
* dismissed, and rejectClose is false, the Promise resolves to null.
*/
static async prompt({ ok={}, ...options }={}) {
options.buttons ??= [];
options.buttons.unshift(mergeObject({
action: "ok", label: "Confirm", icon: "fas fa-check", default: true
}, ok));
return this.wait(options);
}
/* -------------------------------------------- */
/**
* Spawn a dialog and wait for it to be dismissed or submitted.
* @param {Partial<ApplicationConfiguration & DialogV2Configuration>} [options]
* @param {DialogV2RenderCallback} [options.render] A function to invoke whenever the dialog is rendered.
* @param {DialogV2CloseCallback} [options.close] A function to invoke when the dialog is closed under any
* circumstances.
* @param {boolean} [options.rejectClose=true] Throw a Promise rejection if the dialog is dismissed.
* @returns {Promise<any>} Resolves to the identifier of the button used to submit the
* dialog, or the value returned by that button's callback. If the
* dialog was dismissed, and rejectClose is false, the Promise
* resolves to null.
*/
static async wait({ rejectClose=true, close, render, ...options }={}) {
return new Promise((resolve, reject) => {
// Wrap submission handler with Promise resolution.
const originalSubmit = options.submit;
options.submit = async result => {
await originalSubmit?.(result);
resolve(result);
};
const dialog = new this(options);
dialog.addEventListener("close", event => {
if ( close instanceof Function ) close(event, dialog);
if ( rejectClose ) reject(new Error("Dialog was dismissed without pressing a button."));
else resolve(null);
}, { once: true });
if ( render instanceof Function ) {
dialog.addEventListener("render", event => render(event, dialog.element));
}
dialog.render({ force: true });
});
}
}

View File

@@ -0,0 +1,296 @@
import ApplicationV2 from "./application.mjs";
import {DOCUMENT_OWNERSHIP_LEVELS} from "../../../common/constants.mjs";
/**
* @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration
* @typedef {import("../_types.mjs").ApplicationRenderOptions} ApplicationRenderOptions
*/
/**
* @typedef {Object} DocumentSheetConfiguration
* @property {Document} document The Document instance associated with this sheet
* @property {number} viewPermission A permission level in CONST.DOCUMENT_OWNERSHIP_LEVELS
* @property {number} editPermission A permission level in CONST.DOCUMENT_OWNERSHIP_LEVELS
* @property {boolean} sheetConfig Allow sheet configuration as a header button
*/
/**
* @typedef {Object} DocumentSheetRenderOptions
* @property {string} renderContext A string with the format "{operation}{documentName}" providing context
* @property {object} renderData Data describing the document modification that occurred
*/
/**
* The Application class is responsible for rendering an HTMLElement into the Foundry Virtual Tabletop user interface.
* @extends {ApplicationV2<
* ApplicationConfiguration & DocumentSheetConfiguration,
* ApplicationRenderOptions & DocumentSheetRenderOptions
* >}
* @alias DocumentSheetV2
*/
export default class DocumentSheetV2 extends ApplicationV2 {
constructor(options={}) {
super(options);
this.#document = options.document;
}
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: "{id}",
classes: ["sheet"],
tag: "form", // Document sheets are forms by default
document: null,
viewPermission: DOCUMENT_OWNERSHIP_LEVELS.LIMITED,
editPermission: DOCUMENT_OWNERSHIP_LEVELS.OWNER,
sheetConfig: true,
actions: {
configureSheet: DocumentSheetV2.#onConfigureSheet,
copyUuid: {handler: DocumentSheetV2.#onCopyUuid, buttons: [0, 2]}
},
form: {
handler: this.#onSubmitDocumentForm,
submitOnChange: false,
closeOnSubmit: false
}
};
/* -------------------------------------------- */
/**
* The Document instance associated with the application
* @type {ClientDocument}
*/
get document() {
return this.#document;
}
#document;
/* -------------------------------------------- */
/** @override */
get title() {
const {constructor: cls, id, name, type} = this.document;
const prefix = cls.hasTypeData ? CONFIG[cls.documentName].typeLabels[type] : cls.metadata.label
return `${game.i18n.localize(prefix)}: ${name ?? id}`;
}
/* -------------------------------------------- */
/**
* Is this Document sheet visible to the current User?
* This is governed by the viewPermission threshold configured for the class.
* @type {boolean}
*/
get isVisible() {
return this.document.testUserPermission(game.user, this.options.viewPermission);
}
/* -------------------------------------------- */
/**
* Is this Document sheet editable by the current User?
* This is governed by the editPermission threshold configured for the class.
* @type {boolean}
*/
get isEditable() {
if ( this.document.pack ) {
const pack = game.packs.get(this.document.pack);
if ( pack.locked ) return false;
}
return this.document.testUserPermission(game.user, this.options.editPermission);
}
/* -------------------------------------------- */
/** @inheritDoc */
_initializeApplicationOptions(options) {
options = super._initializeApplicationOptions(options);
options.uniqueId = `${this.constructor.name}-${options.document.uuid}`;
return options;
}
/* -------------------------------------------- */
/** @inheritDoc */
*_headerControlButtons() {
for ( const control of this._getHeaderControls() ) {
if ( control.visible === false ) continue;
if ( ("ownership" in control) && !this.document.testUserPermission(game.user, control.ownership) ) continue;
yield control;
}
}
/* -------------------------------------------- */
/** @inheritDoc */
async _renderFrame(options) {
const frame = await super._renderFrame(options);
// Add form options
if ( this.options.tag === "form" ) frame.autocomplete = "off";
// Add document ID copy
const copyLabel = game.i18n.localize("SHEETS.CopyUuid");
const copyId = `<button type="button" class="header-control fa-solid fa-passport" data-action="copyUuid"
data-tooltip="${copyLabel}" aria-label="${copyLabel}"></button>`;
this.window.close.insertAdjacentHTML("beforebegin", copyId);
// Add sheet configuration button
if ( this.options.sheetConfig && this.isEditable && !this.document.getFlag("core", "sheetLock") ) {
const label = game.i18n.localize("SHEETS.ConfigureSheet");
const sheetConfig = `<button type="button" class="header-control fa-solid fa-cog" data-action="configureSheet"
data-tooltip="${label}" aria-label="${label}"></button>`;
this.window.close.insertAdjacentHTML("beforebegin", sheetConfig);
}
return frame;
}
/* -------------------------------------------- */
/* Application Life-Cycle Events */
/* -------------------------------------------- */
/** @override */
_canRender(_options) {
if ( !this.isVisible ) throw new Error(game.i18n.format("SHEETS.DocumentSheetPrivate", {
type: game.i18n.localize(this.document.constructor.metadata.label)
}));
}
/* -------------------------------------------- */
/** @inheritDoc */
_onFirstRender(context, options) {
super._onFirstRender(context, options);
this.document.apps[this.id] = this;
}
/* -------------------------------------------- */
/** @override */
_onClose(_options) {
delete this.document.apps[this.id];
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle click events to configure the sheet used for this document.
* @param {PointerEvent} event
* @this {DocumentSheetV2}
*/
static #onConfigureSheet(event) {
event.stopPropagation(); // Don't trigger other events
if ( event.detail > 1 ) return; // Ignore repeated clicks
new DocumentSheetConfig(this.document, {
top: this.position.top + 40,
left: this.position.left + ((this.position.width - DocumentSheet.defaultOptions.width) / 2)
}).render(true);
}
/* -------------------------------------------- */
/**
* Handle click events to copy the UUID of this document to clipboard.
* @param {PointerEvent} event
* @this {DocumentSheetV2}
*/
static #onCopyUuid(event) {
event.preventDefault(); // Don't open context menu
event.stopPropagation(); // Don't trigger other events
if ( event.detail > 1 ) return; // Ignore repeated clicks
const id = event.button === 2 ? this.document.id : this.document.uuid;
const type = event.button === 2 ? "id" : "uuid";
const label = game.i18n.localize(this.document.constructor.metadata.label);
game.clipboard.copyPlainText(id);
ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type, id}));
}
/* -------------------------------------------- */
/* Form Submission */
/* -------------------------------------------- */
/**
* Process form submission for the sheet
* @this {DocumentSheetV2} The handler is called with the application as its bound scope
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @returns {Promise<void>}
*/
static async #onSubmitDocumentForm(event, form, formData) {
const submitData = this._prepareSubmitData(event, form, formData);
await this._processSubmitData(event, form, submitData);
}
/* -------------------------------------------- */
/**
* Prepare data used to update the Item upon form submission.
* This data is cleaned and validated before being returned for further processing.
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @returns {object} Prepared submission data as an object
* @throws {Error} Subclasses may throw validation errors here to prevent form submission
* @protected
*/
_prepareSubmitData(event, form, formData) {
const submitData = this._processFormData(event, form, formData);
const addType = this.document.constructor.hasTypeData && !("type" in submitData);
if ( addType ) submitData.type = this.document.type;
this.document.validate({changes: submitData, clean: true, fallback: false});
if ( addType ) delete submitData.type;
return submitData;
}
/* -------------------------------------------- */
/**
* Customize how form data is extracted into an expanded object.
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @returns {object} An expanded object of processed form data
* @throws {Error} Subclasses may throw validation errors here to prevent form submission
*/
_processFormData(event, form, formData) {
return foundry.utils.expandObject(formData.object);
}
/* -------------------------------------------- */
/**
* Submit a document update based on the processed form data.
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {object} submitData Processed and validated form data to be used for a document update
* @returns {Promise<void>}
* @protected
*/
async _processSubmitData(event, form, submitData) {
await this.document.update(submitData);
}
/* -------------------------------------------- */
/**
* Programmatically submit a DocumentSheetV2 instance, providing additional data to be merged with form data.
* @param {object} options
* @param {object} [options.updateData] Additional data merged with processed form data
* @returns {Promise<void>}
*/
async submit({updateData}={}) {
const formConfig = this.options.form;
if ( !formConfig?.handler ) throw new Error(`The ${this.constructor.name} DocumentSheetV2 does not support a`
+ ` single top-level form element.`);
const form = this.element;
const event = new Event("submit");
const formData = new FormDataExtended(form);
const submitData = this._prepareSubmitData(event, form, formData);
foundry.utils.mergeObject(submitData, updateData, {inplace: true});
await this._processSubmitData(event, form, submitData);
}
}

View File

@@ -0,0 +1,243 @@
/**
* @typedef {import("../types.mjs").Constructor} Constructor
* @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration
* @typedef {import("./types.mjs").ApplicationFormSubmission} ApplicationFormSubmission
*/
/**
* @typedef {Object} HandlebarsRenderOptions
* @property {string[]} parts An array of named template parts to render
*/
/**
* @typedef {Object} HandlebarsTemplatePart
* @property {string} template The template entry-point for the part
* @property {string} [id] A CSS id to assign to the top-level element of the rendered part.
* This id string is automatically prefixed by the application id.
* @property {string[]} [classes] An array of CSS classes to apply to the top-level element of the
* rendered part.
* @property {string[]} [templates] An array of templates that are required to render the part.
* If omitted, only the entry-point is inferred as required.
* @property {string[]} [scrollable] An array of selectors within this part whose scroll positions should
* be persisted during a re-render operation. A blank string is used
* to denote that the root level of the part is scrollable.
* @property {Record<string, ApplicationFormConfiguration>} [forms] A registry of forms selectors and submission handlers.
*/
/**
* Augment an Application class with [Handlebars](https://handlebarsjs.com) template rendering behavior.
* @param {Constructor} BaseApplication
*/
export default function HandlebarsApplicationMixin(BaseApplication) {
/**
* The mixed application class augmented with [Handlebars](https://handlebarsjs.com) template rendering behavior.
* @extends {ApplicationV2<ApplicationConfiguration, HandlebarsRenderOptions>}
*/
class HandlebarsApplication extends BaseApplication {
/**
* Configure a registry of template parts which are supported for this application for partial rendering.
* @type {Record<string, HandlebarsTemplatePart>}
*/
static PARTS = {}
/**
* A record of all rendered template parts.
* @returns {Record<string, HTMLElement>}
*/
get parts() {
return this.#parts;
}
#parts = {};
/* -------------------------------------------- */
/** @inheritDoc */
_configureRenderOptions(options) {
super._configureRenderOptions(options);
options.parts ??= Object.keys(this.constructor.PARTS);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _preFirstRender(context, options) {
await super._preFirstRender(context, options);
const allTemplates = new Set();
for ( const part of Object.values(this.constructor.PARTS) ) {
const partTemplates = part.templates ?? [part.template];
for ( const template of partTemplates ) allTemplates.add(template);
}
await loadTemplates(Array.from(allTemplates));
}
/* -------------------------------------------- */
/**
* Render each configured application part using Handlebars templates.
* @param {ApplicationRenderContext} context Context data for the render operation
* @param {HandlebarsRenderOptions} options Options which configure application rendering behavior
* @returns {Promise<Record<string, HTMLElement>>} A single rendered HTMLElement for each requested part
* @protected
* @override
*/
async _renderHTML(context, options) {
const rendered = {}
for ( const partId of options.parts ) {
const part = this.constructor.PARTS[partId];
if ( !part ) {
ui.notifications.warn(`Part "${partId}" is not a supported template part for ${this.constructor.name}`);
continue;
}
const partContext = await this._preparePartContext(partId, context, options);
try {
const htmlString = await renderTemplate(part.template, partContext);
rendered[partId] = this.#parsePartHTML(partId, part, htmlString);
} catch(err) {
throw new Error(`Failed to render template part "${partId}":\n${err.message}`, {cause: err});
}
}
return rendered;
}
/* -------------------------------------------- */
/**
* Prepare context that is specific to only a single rendered part.
*
* It is recommended to augment or mutate the shared context so that downstream methods like _onRender have
* visibility into the data that was used for rendering. It is acceptable to return a different context object
* rather than mutating the shared context at the expense of this transparency.
*
* @param {string} partId The part being rendered
* @param {ApplicationRenderContext} context Shared context provided by _prepareContext
* @param {HandlebarsRenderOptions} options Options which configure application rendering behavior
* @returns {Promise<ApplicationRenderContext>} Context data for a specific part
* @protected
*/
async _preparePartContext(partId, context, options) {
context.partId = `${this.id}-${partId}`;
return context;
}
/* -------------------------------------------- */
/**
* Parse the returned HTML string from template rendering into a uniquely identified HTMLElement for insertion.
* @param {string} partId The id of the part being rendered
* @param {HandlebarsTemplatePart} part Configuration of the part being parsed
* @param {string} htmlString The string rendered for the part
* @returns {HTMLElement} The parsed HTMLElement for the part
*/
#parsePartHTML(partId, part, htmlString) {
const t = document.createElement("template");
t.innerHTML = htmlString;
if ( (t.content.children.length !== 1) ) {
throw new Error(`Template part "${partId}" must render a single HTML element.`);
}
const e = t.content.firstElementChild;
e.dataset.applicationPart = partId;
if ( part.id ) e.setAttribute("id", `${this.id}-${part.id}`);
if ( part.classes ) e.classList.add(...part.classes);
return e;
}
/* -------------------------------------------- */
/**
* Replace the HTML of the application with the result provided by Handlebars rendering.
* @param {Record<string, HTMLElement>} result The result from Handlebars template rendering
* @param {HTMLElement} content The content element into which the rendered result must be inserted
* @param {HandlebarsRenderOptions} options Options which configure application rendering behavior
* @protected
* @override
*/
_replaceHTML(result, content, options) {
for ( const [partId, htmlElement] of Object.entries(result) ) {
const priorElement = content.querySelector(`[data-application-part="${partId}"]`);
const state = {};
if ( priorElement ) {
this._preSyncPartState(partId, htmlElement, priorElement, state);
priorElement.replaceWith(htmlElement);
this._syncPartState(partId, htmlElement, priorElement, state);
}
else content.appendChild(htmlElement);
this._attachPartListeners(partId, htmlElement, options);
this.#parts[partId] = htmlElement;
}
}
/* -------------------------------------------- */
/**
* Prepare data used to synchronize the state of a template part.
* @param {string} partId The id of the part being rendered
* @param {HTMLElement} newElement The new rendered HTML element for the part
* @param {HTMLElement} priorElement The prior rendered HTML element for the part
* @param {object} state A state object which is used to synchronize after replacement
* @protected
*/
_preSyncPartState(partId, newElement, priorElement, state) {
const part = this.constructor.PARTS[partId];
// Focused element or field
const focus = priorElement.querySelector(":focus");
if ( focus?.id ) state.focus = `#${focus.id}`;
else if ( focus?.name ) state.focus = `${focus.tagName}[name="${focus.name}"]`;
else state.focus = undefined;
// Scroll positions
state.scrollPositions = [];
for ( const selector of (part.scrollable || []) ) {
const el0 = selector === "" ? priorElement : priorElement.querySelector(selector);
if ( el0 ) {
const el1 = selector === "" ? newElement : newElement.querySelector(selector);
if ( el1 ) state.scrollPositions.push([el1, el0.scrollTop, el0.scrollLeft]);
}
}
}
/* -------------------------------------------- */
/**
* Synchronize the state of a template part after it has been rendered and replaced in the DOM.
* @param {string} partId The id of the part being rendered
* @param {HTMLElement} newElement The new rendered HTML element for the part
* @param {HTMLElement} priorElement The prior rendered HTML element for the part
* @param {object} state A state object which is used to synchronize after replacement
* @protected
*/
_syncPartState(partId, newElement, priorElement, state) {
if ( state.focus ) {
const newFocus = newElement.querySelector(state.focus);
if ( newFocus ) newFocus.focus();
}
for ( const [el, scrollTop, scrollLeft] of state.scrollPositions ) Object.assign(el, {scrollTop, scrollLeft});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Attach event listeners to rendered template parts.
* @param {string} partId The id of the part being rendered
* @param {HTMLElement} htmlElement The rendered HTML element for the part
* @param {ApplicationRenderOptions} options Rendering options passed to the render method
* @protected
*/
_attachPartListeners(partId, htmlElement, options) {
const part = this.constructor.PARTS[partId];
// Attach form submission handlers
if ( part.forms ) {
for ( const [selector, formConfig] of Object.entries(part.forms) ) {
const form = htmlElement.matches(selector) ? htmlElement : htmlElement.querySelector(selector);
form.addEventListener("submit", this._onSubmitForm.bind(this, formConfig));
form.addEventListener("change", this._onChangeForm.bind(this, formConfig));
}
}
}
}
return HandlebarsApplication;
}

View File

@@ -0,0 +1,2 @@
export {default as CompendiumArtConfig} from "./compendium-art-config.mjs";
export {default as PermissionConfig} from "./permission-config.mjs";

View File

@@ -0,0 +1,105 @@
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
import ApplicationV2 from "../api/application.mjs";
/**
* An application for configuring compendium art priorities.
* @extends ApplicationV2
* @mixes HandlebarsApplication
* @alias CompendiumArtConfig
*/
export default class CompendiumArtConfig extends HandlebarsApplicationMixin(ApplicationV2) {
/** @override */
static DEFAULT_OPTIONS = {
id: "compendium-art-config",
tag: "form",
window: {
contentClasses: ["standard-form"],
icon: "fas fa-palette",
title: "COMPENDIUM.ART.SETTING.Title"
},
position: {
width: 600,
height: "auto"
},
form: {
closeOnSubmit: true,
handler: CompendiumArtConfig.#onSubmit
},
actions: {
priority: CompendiumArtConfig.#onAdjustPriority
}
};
/** @override */
static PARTS = {
priorities: {
id: "priorities",
template: "templates/apps/compendium-art-config.hbs"
},
footer: {
template: "templates/generic/form-footer.hbs"
}
};
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options={}) {
return {
config: game.compendiumArt.getPackages(),
buttons: [{ type: "submit", icon: "fas fa-save", label: "SETUP.SaveConfiguration" }]
};
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/**
* Adjust the priority of a package.
* @this {ApplicationV2}
* @param {MouseEvent} _event The click event.
* @param {HTMLButtonElement} target The button that was clicked.
*/
static async #onAdjustPriority(_event, target) {
const row = target.closest("[data-package-id]");
const { packageId } = row.dataset;
const configs = [];
for ( const element of this.element.elements ) {
const [id, key] = element.name.split(".");
if ( key === "priority" ) configs.push({ packageId: id, priority: Number(element.value) });
}
const idx = configs.findIndex(config => config.packageId === packageId);
if ( idx < 0 ) return;
const sortBefore = "increase" in target.dataset;
if ( sortBefore && (idx === 0) ) return;
if ( !sortBefore && (idx >= configs.length - 1) ) return;
const config = configs[idx];
const sortTarget = configs[sortBefore ? idx - 1 : idx + 1];
configs.splice(idx, 1);
const updates = SortingHelpers.performIntegerSort(config, {
sortBefore, target: sortTarget, siblings: configs, sortKey: "priority"
});
updates.forEach(({ target, update }) => {
this.element.elements[`${target.packageId}.priority`].value = update.priority;
});
if ( sortBefore ) row.previousElementSibling.insertAdjacentElement("beforebegin", row);
else row.nextElementSibling.insertAdjacentElement("afterend", row);
}
/* -------------------------------------------- */
/**
* Save the compendium art configuration.
* @this {ApplicationV2}
* @param {SubmitEvent} _event The form submission event.
* @param {HTMLFormElement} _form The form element that was submitted.
* @param {FormDataExtended} formData Processed data for the submitted form.
*/
static async #onSubmit(_event, _form, formData) {
await game.settings.set("core", game.compendiumArt.SETTING, foundry.utils.expandObject(formData.object));
return SettingsConfig.reloadConfirm({ world: true });
}
}

View File

@@ -0,0 +1,152 @@
import ApplicationV2 from "../api/application.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
/**
* An application for configuring the permissions which are available to each User role.
* @extends ApplicationV2
* @mixes HandlebarsApplication
* @alias PermissionConfig
*/
export default class PermissionConfig extends HandlebarsApplicationMixin(ApplicationV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: "permissions-config",
tag: "form",
window: {
contentClasses: ["standard-form"],
icon: "fa-solid fa-shield-keyhole",
title: "PERMISSION.Title",
},
position: {
width: 660,
height: "auto"
},
form: {
closeOnSubmit: true,
handler: PermissionConfig.#onSubmit
},
actions: {
reset: PermissionConfig.#onReset
}
};
/** @override */
static PARTS = {
permissions: {
id: "permissions",
template: "templates/apps/permission-config.hbs",
scrollable: [".permissions-list"]
},
footer: {
template: "templates/generic/form-footer.hbs"
}
};
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options={}) {
const current = await game.settings.get("core", "permissions");
return {
roles: Object.keys(CONST.USER_ROLES).reduce((obj, r) => {
if ( r === "NONE" ) return obj;
obj[r] = `USER.Role${r.titleCase()}`;
return obj;
}, {}),
permissions: this.#preparePermissions(current),
buttons: [
{type: "reset", action: "reset", icon: "fa-solid fa-sync", label: "PERMISSION.Reset"},
{type: "submit", icon: "fa-solid fa-save", label: "PERMISSION.Submit"}
]
};
}
/* -------------------------------------------- */
/**
* Prepare the permissions object used to render the configuration template
* @param {object} current The current permission configuration
* @returns {object[]} Permission data for sheet rendering
*/
#preparePermissions(current) {
const r = CONST.USER_ROLES;
const rgm = r.GAMEMASTER;
// Get permissions
const perms = Object.entries(CONST.USER_PERMISSIONS).reduce((arr, e) => {
const perm = foundry.utils.deepClone(e[1]);
perm.id = e[0];
perm.label = game.i18n.localize(perm.label);
perm.hint = game.i18n.localize(perm.hint);
arr.push(perm);
return arr;
}, []);
perms.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
// Configure permission roles
for ( let p of perms ) {
const roles = current[p.id] || Array.fromRange(rgm + 1).slice(p.defaultRole);
p.roles = Object.values(r).reduce((arr, role) => {
if ( role === r.NONE ) return arr;
arr.push({
name: `${p.id}.${role}`,
value: roles.includes(role),
readonly: (role === rgm) && (!p.disableGM) ? "readonly" : ""
});
return arr;
}, []);
}
return perms;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle submission
* @this {DocumentSheetV2} The handler is called with the application as its bound scope
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @returns {Promise<void>}
*/
static async #onSubmit(event, form, formData) {
const permissions = foundry.utils.expandObject(formData.object);
for ( let [k, v] of Object.entries(permissions) ) {
if ( !(k in CONST.USER_PERMISSIONS ) ) {
delete permissions[k];
continue;
}
permissions[k] = Object.entries(v).reduce((arr, r) => {
if ( r[1] === true ) arr.push(parseInt(r[0]));
return arr;
}, []);
}
await game.settings.set("core", "permissions", permissions);
ui.notifications.info("SETTINGS.PermissionUpdate", {localize: true});
}
/* -------------------------------------------- */
/**
* Handle click actions to reset all permissions back to their initial state.
* @this {PermissionConfig}
* @param {PointerEvent} event
* @returns {Promise<void>}
*/
static async #onReset(event) {
event.preventDefault();
const defaults = Object.entries(CONST.USER_PERMISSIONS).reduce((obj, [id, perm]) => {
obj[id] = Array.fromRange(CONST.USER_ROLES.GAMEMASTER + 1).slice(perm.defaultRole);
return obj;
}, {});
await game.settings.set("core", "permissions", defaults);
ui.notifications.info("SETTINGS.PermissionReset", {localize: true});
await this.render();
}
}

View File

@@ -0,0 +1 @@
export {default as RollResolver} from "./roll-resolver.mjs";

View File

@@ -0,0 +1,321 @@
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
import ApplicationV2 from "../api/application.mjs";
/**
* @typedef {object} DiceTermFulfillmentDescriptor
* @property {string} id A unique identifier for the term.
* @property {DiceTerm} term The term.
* @property {string} method The fulfillment method.
* @property {boolean} [isNew] Was the term newly-added to this resolver?
*/
/**
* An application responsible for handling unfulfilled dice terms in a roll.
* @extends {ApplicationV2<ApplicationConfiguration, ApplicationRenderOptions>}
* @mixes HandlebarsApplication
* @alias RollResolver
*/
export default class RollResolver extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(roll, options={}) {
super(options);
this.#roll = roll;
}
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: "roll-resolver-{id}",
tag: "form",
classes: ["roll-resolver"],
window: {
title: "DICE.RollResolution",
},
position: {
width: 500,
height: "auto"
},
form: {
submitOnChange: false,
closeOnSubmit: false,
handler: this._fulfillRoll
}
};
/** @override */
static PARTS = {
form: {
id: "form",
template: "templates/dice/roll-resolver.hbs"
}
};
/**
* A collection of fulfillable dice terms.
* @type {Map<string, DiceTermFulfillmentDescriptor>}
*/
get fulfillable() {
return this.#fulfillable;
}
#fulfillable = new Map();
/**
* A function to call when the first pass of fulfillment is complete.
* @type {function}
*/
#resolve;
/**
* The roll being resolved.
* @type {Roll}
*/
get roll() {
return this.#roll;
}
#roll;
/* -------------------------------------------- */
/**
* Identify any terms in this Roll that should be fulfilled externally, and prompt the user to do so.
* @returns {Promise<void>} Returns a Promise that resolves when the first pass of fulfillment is complete.
*/
async awaitFulfillment() {
const fulfillable = await this.#identifyFulfillableTerms(this.roll.terms);
if ( !fulfillable.length ) return;
Roll.defaultImplementation.RESOLVERS.set(this.roll, this);
this.render(true);
return new Promise(resolve => this.#resolve = resolve);
}
/* -------------------------------------------- */
/**
* Register a fulfilled die roll.
* @param {string} method The method used for fulfillment.
* @param {string} denomination The denomination of the fulfilled die.
* @param {number} result The rolled number.
* @returns {boolean} Whether the result was consumed.
*/
registerResult(method, denomination, result) {
const query = `label[data-denomination="${denomination}"][data-method="${method}"] > input:not(:disabled)`;
const term = Array.from(this.element.querySelectorAll(query)).find(input => input.value === "");
if ( !term ) {
ui.notifications.warn(`${denomination} roll was not needed by the resolver.`);
return false;
}
term.value = `${result}`;
const submitTerm = term.closest(".form-fields")?.querySelector("button");
if ( submitTerm ) submitTerm.dispatchEvent(new MouseEvent("click"));
else this._checkDone();
return true;
}
/* -------------------------------------------- */
/** @inheritDoc */
async close(options={}) {
if ( this.rendered ) await this.constructor._fulfillRoll.call(this, null, null, new FormDataExtended(this.element));
Roll.defaultImplementation.RESOLVERS.delete(this.roll);
this.#resolve?.();
return super.close(options);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _prepareContext(_options) {
const context = {
formula: this.roll.formula,
groups: {}
};
for ( const fulfillable of this.fulfillable.values() ) {
const { id, term, method, isNew } = fulfillable;
fulfillable.isNew = false;
const config = CONFIG.Dice.fulfillment.methods[method];
const group = context.groups[id] = {
results: [],
label: term.expression,
icon: config.icon ?? '<i class="fas fa-bluetooth"></i>',
tooltip: game.i18n.localize(config.label)
};
const { denomination, faces } = term;
const icon = CONFIG.Dice.fulfillment.dice[denomination]?.icon;
for ( let i = 0; i < Math.max(term.number ?? 1, term.results.length); i++ ) {
const result = term.results[i];
const { result: value, exploded, rerolled } = result ?? {};
group.results.push({
denomination, faces, id, method, icon, exploded, rerolled, isNew,
value: value ?? "",
readonly: method !== "manual",
disabled: !!result
});
}
}
return context;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _onSubmitForm(formConfig, event) {
this._toggleSubmission(false);
this.element.querySelectorAll("input").forEach(input => {
if ( !isNaN(input.valueAsNumber) ) return;
const { term } = this.fulfillable.get(input.name);
input.value = `${term.randomFace()}`;
});
await super._onSubmitForm(formConfig, event);
this.element?.querySelectorAll("input").forEach(input => input.disabled = true);
this.#resolve();
}
/* -------------------------------------------- */
/**
* Handle prompting for a single extra result from a term.
* @param {DiceTerm} term The term.
* @param {string} method The method used to obtain the result.
* @param {object} [options]
* @returns {Promise<number|void>}
*/
async resolveResult(term, method, { reroll=false, explode=false }={}) {
const group = this.element.querySelector(`fieldset[data-term-id="${term._id}"]`);
if ( !group ) {
console.warn("Attempted to resolve a single result for an unregistered DiceTerm.");
return;
}
const fields = document.createElement("div");
fields.classList.add("form-fields");
fields.innerHTML = `
<label class="icon die-input new-addition" data-denomination="${term.denomination}" data-method="${method}">
<input type="number" min="1" max="${term.faces}" step="1" name="${term._id}"
${method === "manual" ? "" : "readonly"} placeholder="${game.i18n.localize(term.denomination)}">
${reroll ? '<i class="fas fa-arrow-rotate-right"></i>' : ""}
${explode ? '<i class="fas fa-burst"></i>' : ""}
${CONFIG.Dice.fulfillment.dice[term.denomination]?.icon ?? ""}
</label>
<button type="button" class="submit-result" data-tooltip="DICE.SubmitRoll"
aria-label="${game.i18n.localize("DICE.SubmitRoll")}">
<i class="fas fa-arrow-right"></i>
</button>
`;
group.appendChild(fields);
this.setPosition({ height: "auto" });
return new Promise(resolve => {
const button = fields.querySelector("button");
const input = fields.querySelector("input");
button.addEventListener("click", () => {
if ( !input.validity.valid ) {
input.form.reportValidity();
return;
}
let value = input.valueAsNumber;
if ( !value ) value = term.randomFace();
input.value = `${value}`;
input.disabled = true;
button.remove();
resolve(value);
});
});
}
/* -------------------------------------------- */
/**
* Update the Roll instance with the fulfilled results.
* @this {RollResolver}
* @param {SubmitEvent} event The originating form submission event.
* @param {HTMLFormElement} form The form element that was submitted.
* @param {FormDataExtended} formData Processed data for the submitted form.
* @returns {Promise<void>}
* @protected
*/
static async _fulfillRoll(event, form, formData) {
// Update the DiceTerms with the fulfilled values.
for ( let [id, results] of Object.entries(formData.object) ) {
const { term } = this.fulfillable.get(id);
if ( !Array.isArray(results) ) results = [results];
for ( const result of results ) {
const roll = { result: undefined, active: true };
// A null value indicates the user wishes to skip external fulfillment and fall back to the digital roll.
if ( result === null ) roll.result = term.randomFace();
else roll.result = result;
term.results.push(roll);
}
}
}
/* -------------------------------------------- */
/**
* Identify any of the given terms which should be fulfilled externally.
* @param {RollTerm[]} terms The terms.
* @param {object} [options]
* @param {boolean} [options.isNew=false] Whether this term is a new addition to the already-rendered RollResolver.
* @returns {Promise<DiceTerm[]>}
*/
async #identifyFulfillableTerms(terms, { isNew=false }={}) {
const config = game.settings.get("core", "diceConfiguration");
const fulfillable = Roll.defaultImplementation.identifyFulfillableTerms(terms);
fulfillable.forEach(term => {
if ( term._id ) return;
const method = config[term.denomination] || CONFIG.Dice.fulfillment.defaultMethod;
const id = foundry.utils.randomID();
term._id = id;
term.method = method;
this.fulfillable.set(id, { id, term, method, isNew });
});
return fulfillable;
}
/* -------------------------------------------- */
/**
* Add a new term to the resolver.
* @param {DiceTerm} term The term.
* @returns {Promise<void>} Returns a Promise that resolves when the term's results have been externally fulfilled.
*/
async addTerm(term) {
if ( !(term instanceof foundry.dice.terms.DiceTerm) ) {
throw new Error("Only DiceTerm instances may be added to the RollResolver.");
}
const fulfillable = await this.#identifyFulfillableTerms([term], { isNew: true });
if ( !fulfillable.length ) return;
this.render({ force: true, position: { height: "auto" } });
return new Promise(resolve => this.#resolve = resolve);
}
/* -------------------------------------------- */
/**
* Check if all rolls have been fulfilled.
* @protected
*/
_checkDone() {
// If the form has already in the submission state, we don't need to re-submit.
const submitter = this.element.querySelector('button[type="submit"]');
if ( submitter.disabled ) return;
// If there are any manual inputs, or if there are any empty inputs, then fulfillment is not done.
if ( this.element.querySelector("input:not([readonly], :disabled)") ) return;
for ( const input of this.element.querySelectorAll("input[readonly]:not(:disabled)") ) {
if ( input.value === "" ) return;
}
this.element.requestSubmit(submitter);
}
/* -------------------------------------------- */
/**
* Toggle the state of the submit button.
* @param {boolean} enabled Whether the button is enabled.
* @protected
*/
_toggleSubmission(enabled) {
const submit = this.element.querySelector('button[type="submit"]');
const icon = submit.querySelector("i");
icon.className = `fas ${enabled ? "fa-check" : "fa-spinner fa-pulse"}`;
submit.disabled = !enabled;
}
}

View File

@@ -0,0 +1,34 @@
/**
* Custom HTMLElement implementations for use in template rendering.
* @module elements
*/
import HTMLDocumentTagsElement from "./document-tags.mjs";
import HTMLFilePickerElement from "./file-picker.mjs";
import HTMLHueSelectorSlider from "./hue-slider.mjs";
import {HTMLMultiSelectElement, HTMLMultiCheckboxElement} from "./multi-select.mjs";
import HTMLStringTagsElement from "./string-tags.mjs";
import HTMLColorPickerElement from "./color-picker.mjs";
import HTMLRangePickerElement from "./range-picker.mjs";
import HTMLProseMirrorElement from "./prosemirror-editor.mjs";
export {default as AbstractFormInputElement} from "./form-element.mjs";
export {default as HTMLColorPickerElement} from "./color-picker.mjs";
export {default as HTMLDocumentTagsElement} from "./document-tags.mjs";
export {default as HTMLFilePickerElement} from "./file-picker.mjs";
export {default as HTMLHueSelectorSlider} from "./hue-slider.mjs"
export {default as HTMLRangePickerElement} from "./range-picker.mjs"
export {default as HTMLStringTagsElement} from "./string-tags.mjs"
export {default as HTMLProseMirrorElement} from "./prosemirror-editor.mjs";
export * from "./multi-select.mjs";
// Define custom elements
window.customElements.define(HTMLColorPickerElement.tagName, HTMLColorPickerElement);
window.customElements.define(HTMLDocumentTagsElement.tagName, HTMLDocumentTagsElement);
window.customElements.define(HTMLFilePickerElement.tagName, HTMLFilePickerElement);
window.customElements.define(HTMLHueSelectorSlider.tagName, HTMLHueSelectorSlider);
window.customElements.define(HTMLMultiSelectElement.tagName, HTMLMultiSelectElement);
window.customElements.define(HTMLMultiCheckboxElement.tagName, HTMLMultiCheckboxElement);
window.customElements.define(HTMLRangePickerElement.tagName, HTMLRangePickerElement);
window.customElements.define(HTMLStringTagsElement.tagName, HTMLStringTagsElement);
window.customElements.define(HTMLProseMirrorElement.tagName, HTMLProseMirrorElement);

View File

@@ -0,0 +1,103 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* A custom HTMLElement used to select a color using a linked pair of input fields.
* @extends {AbstractFormInputElement<string>}
*/
export default class HTMLColorPickerElement extends AbstractFormInputElement {
constructor() {
super();
this._setValue(this.getAttribute("value")); // Initialize existing color value
}
/** @override */
static tagName = "color-picker";
/* -------------------------------------------- */
/**
* The button element to add a new document.
* @type {HTMLInputElement}
*/
#colorSelector;
/**
* The input element to define a Document UUID.
* @type {HTMLInputElement}
*/
#colorString;
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create string input element
this.#colorString = this._primaryInput = document.createElement("input");
this.#colorString.type = "text";
this.#colorString.placeholder = this.getAttribute("placeholder") || "";
this._applyInputAttributes(this.#colorString);
// Create color selector element
this.#colorSelector = document.createElement("input");
this.#colorSelector.type = "color";
this._applyInputAttributes(this.#colorSelector);
return [this.#colorString, this.#colorSelector];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
if ( !this.#colorString ) return; // Not yet connected
this.#colorString.value = this._value;
this.#colorSelector.value = this._value || this.#colorString.placeholder || "#000000";
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
const onChange = this.#onChangeInput.bind(this);
this.#colorString.addEventListener("change", onChange);
this.#colorSelector.addEventListener("change", onChange);
}
/* -------------------------------------------- */
/**
* Handle changes to one of the inputs of the color picker element.
* @param {InputEvent} event The originating input change event
*/
#onChangeInput(event) {
event.stopPropagation();
this.value = event.currentTarget.value;
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#colorString.toggleAttribute("disabled", disabled);
this.#colorSelector.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLColorPickerElement using provided configuration data.
* @param {FormInputConfig} config
* @returns {HTMLColorPickerElement}
*/
static create(config) {
const picker = document.createElement(HTMLColorPickerElement.tagName);
picker.name = config.name;
picker.setAttribute("value", config.value ?? "");
foundry.applications.fields.setInputAttributes(picker, config);
return picker;
}
}

View File

@@ -0,0 +1,344 @@
import AbstractFormInputElement from "./form-element.mjs";
import HTMLStringTagsElement from "./string-tags.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} DocumentTagsInputConfig
* @property {string} [type] A specific document type in CONST.ALL_DOCUMENT_TYPES
* @property {boolean} [single] Only allow referencing a single document. In this case the submitted form value will
* be a single UUID string rather than an array
* @property {number} [max] Only allow attaching a maximum number of documents
*/
/**
* A custom HTMLElement used to render a set of associated Documents referenced by UUID.
* @extends {AbstractFormInputElement<string|string[]|null>}
*/
export default class HTMLDocumentTagsElement extends AbstractFormInputElement {
constructor() {
super();
this._initializeTags();
}
/** @override */
static tagName = "document-tags";
/* -------------------------------------------- */
/**
* @override
* @type {Record<string, string>}
* @protected
*/
_value = {};
/**
* The button element to add a new document.
* @type {HTMLButtonElement}
*/
#button;
/**
* The input element to define a Document UUID.
* @type {HTMLInputElement}
*/
#input;
/**
* The list of tagged documents.
* @type {HTMLDivElement}
*/
#tags;
/* -------------------------------------------- */
/**
* Restrict this element to documents of a particular type.
* @type {string|null}
*/
get type() {
return this.getAttribute("type");
}
set type(value) {
if ( !value ) return this.removeAttribute("type");
if ( !CONST.ALL_DOCUMENT_TYPES.includes(value) ) {
throw new Error(`"${value}" is not a valid Document type in CONST.ALL_DOCUMENT_TYPES`);
}
this.setAttribute("type", value);
}
/* -------------------------------------------- */
/**
* Restrict to only allow referencing a single Document instead of an array of documents.
* @type {boolean}
*/
get single() {
return this.hasAttribute("single");
}
set single(value) {
this.toggleAttribute("single", value === true);
}
/* -------------------------------------------- */
/**
* Allow a maximum number of documents to be tagged to the element.
* @type {number}
*/
get max() {
const max = parseInt(this.getAttribute("max"));
return isNaN(max) ? Infinity : max;
}
set max(value) {
if ( Number.isInteger(value) && (value > 0) ) this.setAttribute("max", String(value));
else this.removeAttribute("max");
}
/* -------------------------------------------- */
/**
* Initialize innerText or an initial value attribute of the element as a serialized JSON array.
* @protected
*/
_initializeTags() {
const initial = this.getAttribute("value") || this.innerText || "";
const tags = initial ? initial.split(",") : [];
for ( const t of tags ) {
try {
this.#add(t);
} catch(err) {
this._value[t] = `${t} [INVALID]`; // Display invalid UUIDs as a raw string
}
}
this.innerText = "";
this.removeAttribute("value");
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create tags list
this.#tags = document.createElement("div");
this.#tags.className = "tags input-element-tags";
// Create input element
this.#input = this._primaryInput = document.createElement("input");
this.#input.type = "text";
this.#input.placeholder = game.i18n.format("HTMLDocumentTagsElement.PLACEHOLDER", {
type: game.i18n.localize(this.type ? getDocumentClass(this.type).metadata.label : "DOCUMENT.Document")});
// Create button
this.#button = document.createElement("button");
this.#button.type = "button"
this.#button.className = "icon fa-solid fa-file-plus";
this.#button.dataset.tooltip = game.i18n.localize("ELEMENTS.DOCUMENT_TAGS.Add");
this.#button.setAttribute("aria-label", this.#button.dataset.tooltip);
return [this.#tags, this.#input, this.#button];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
if ( !this.#tags ) return; // Not yet connected
const tags = Object.entries(this._value).map(([k, v]) => this.constructor.renderTag(k, v, this.editable));
this.#tags.replaceChildren(...tags);
}
/* -------------------------------------------- */
/**
* Create an HTML string fragment for a single document tag.
* @param {string} uuid The document UUID
* @param {string} name The document name
* @param {boolean} [editable=true] Is the tag editable?
* @returns {HTMLDivElement}
*/
static renderTag(uuid, name, editable=true) {
const div = HTMLStringTagsElement.renderTag(uuid, TextEditor.truncateText(name, {maxLength: 32}), editable);
div.classList.add("document-tag");
div.querySelector("span").dataset.tooltip = uuid;
if ( editable ) {
const t = game.i18n.localize("ELEMENTS.DOCUMENT_TAGS.Remove");
const a = div.querySelector("a");
a.dataset.tooltip = t;
a.ariaLabel = t;
}
return div;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
this.#button.addEventListener("click", () => this.#tryAdd(this.#input.value));
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
this.#input.addEventListener("keydown", this.#onKeydown.bind(this));
this.addEventListener("drop", this.#onDrop.bind(this));
}
/* -------------------------------------------- */
/**
* Remove a single coefficient by clicking on its tag.
* @param {PointerEvent} event
*/
#onClickTag(event) {
if ( !event.target.classList.contains("remove") ) return;
const tag = event.target.closest(".tag");
delete this._value[tag.dataset.key];
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
/* -------------------------------------------- */
/**
* Add a new document tag by pressing the ENTER key in the UUID input field.
* @param {KeyboardEvent} event
*/
#onKeydown(event) {
if ( event.key !== "Enter" ) return;
event.preventDefault();
event.stopPropagation();
this.#tryAdd(this.#input.value);
}
/* -------------------------------------------- */
/**
* Handle data dropped onto the form element.
* @param {DragEvent} event
*/
#onDrop(event) {
event.preventDefault();
const dropData = TextEditor.getDragEventData(event);
if ( dropData.uuid ) this.#tryAdd(dropData.uuid);
}
/* -------------------------------------------- */
/**
* Add a Document to the tagged set using the value of the input field.
* @param {string} uuid The UUID to attempt to add
*/
#tryAdd(uuid) {
try {
this.#add(uuid);
this._refresh();
} catch(err) {
ui.notifications.error(err.message);
}
this.#input.value = "";
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this.#input.focus();
}
/* -------------------------------------------- */
/**
* Validate that the tagged document is allowed to be added to this field.
* Subclasses may impose more strict validation as to which types of documents are allowed.
* @param {foundry.abstract.Document|object} document A candidate document or compendium index entry to tag
* @throws {Error} An error if the candidate document is not allowed
*/
_validateDocument(document) {
const {type, max} = this;
if ( type && (document.documentName !== type) ) throw new Error(`Incorrect document type "${document.documentName}"`
+ ` provided to document tag field which requires "${type}" documents.`);
const n = Object.keys(this._value).length;
if ( n >= max ) throw new Error(`You may only attach at most ${max} Documents to the "${this.name}" field`);
}
/* -------------------------------------------- */
/**
* Add a new UUID to the tagged set, throwing an error if the UUID is not valid.
* @param {string} uuid The UUID to add
* @throws {Error} If the UUID is not valid
*/
#add(uuid) {
// Require the UUID to exist
let record;
const {id} = foundry.utils.parseUuid(uuid);
if ( id ) record = fromUuidSync(uuid);
else if ( this.type ) {
const collection = game.collections.get(this.type);
record = collection.get(uuid);
}
if ( !record ) throw new Error(`Invalid document UUID "${uuid}" provided to document tag field.`);
// Require a certain type of document
this._validateDocument(record);
// Replace singleton
if ( this.single ) {
for ( const k of Object.keys(this._value) ) delete this._value[k];
}
// Record the document
this._value[uuid] = record.name;
}
/* -------------------------------------------- */
/* Form Handling */
/* -------------------------------------------- */
/** @override */
_getValue() {
const uuids = Object.keys(this._value);
if ( this.single ) return uuids[0] ?? null;
else return uuids;
}
/** @override */
_setValue(value) {
this._value = {};
if ( !value ) return;
if ( typeof value === "string" ) value = [value];
for ( const uuid of value ) this.#add(uuid);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#input?.toggleAttribute("disabled", disabled);
this.#button?.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLDocumentTagsElement using provided configuration data.
* @param {FormInputConfig & DocumentTagsInputConfig} config
* @returns {HTMLDocumentTagsElement}
*/
static create(config) {
const tags = /** @type {HTMLDocumentTagsElement} */ document.createElement(HTMLDocumentTagsElement.tagName);
tags.name = config.name;
// Coerce value to an array
let values;
if ( config.value instanceof Set ) values = Array.from(config.value);
else if ( !Array.isArray(config.value) ) values = [config.value];
else values = config.value;
tags.setAttribute("value", values);
tags.type = config.type;
tags.max = config.max;
tags.single = config.single;
foundry.applications.fields.setInputAttributes(tags, config);
return tags;
}
}

View File

@@ -0,0 +1,158 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} FilePickerInputConfig
* @property {FilePickerOptions.type} [type]
* @property {string} [placeholder]
* @property {boolean} [noupload]
*/
/**
* A custom HTML element responsible for rendering a file input field and associated FilePicker button.
* @extends {AbstractFormInputElement<string>}
*/
export default class HTMLFilePickerElement extends AbstractFormInputElement {
/** @override */
static tagName = "file-picker";
/**
* The file path selected.
* @type {HTMLInputElement}
*/
input;
/**
* A button to open the file picker interface.
* @type {HTMLButtonElement}
*/
button;
/**
* A reference to the FilePicker application instance originated by this element.
* @type {FilePicker}
*/
picker;
/* -------------------------------------------- */
/**
* A type of file which can be selected in this field.
* @see {@link FilePicker.FILE_TYPES}
* @type {FilePickerOptions.type}
*/
get type() {
return this.getAttribute("type") ?? "any";
}
set type(value) {
if ( !FilePicker.FILE_TYPES.includes(value) ) throw new Error(`Invalid type "${value}" provided which must be a `
+ "value in FilePicker.TYPES");
this.setAttribute("type", value);
}
/* -------------------------------------------- */
/**
* Prevent uploading new files as part of this element's FilePicker dialog.
* @type {boolean}
*/
get noupload() {
return this.hasAttribute("noupload");
}
set noupload(value) {
this.toggleAttribute("noupload", value === true);
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Initialize existing value
this._value ??= this.getAttribute("value") || this.innerText || "";
this.removeAttribute("value");
// Create an input field
const elements = [];
this.input = this._primaryInput = document.createElement("input");
this.input.className = "image";
this.input.type = "text";
this.input.placeholder = this.getAttribute("placeholder") ?? "path/to/file.ext";
elements.push(this.input);
// Disallow browsing for some users
if ( game.world && !game.user.can("FILES_BROWSE") ) return elements;
// Create a FilePicker button
this.button = document.createElement("button");
this.button.className = "fa-solid fa-file-import fa-fw";
this.button.type = "button";
this.button.dataset.tooltip = game.i18n.localize("FILES.BrowseTooltip");
this.button.setAttribute("aria-label", this.button.dataset.tooltip);
this.button.tabIndex = -1;
elements.push(this.button);
return elements;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
this.input.value = this._value;
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.input.disabled = disabled;
if ( this.button ) this.button.disabled = disabled;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
this.input.addEventListener("input", () => this._value = this.input.value);
this.button?.addEventListener("click", this.#onClickButton.bind(this));
}
/* -------------------------------------------- */
/**
* Handle clicks on the button element to render the FilePicker UI.
* @param {PointerEvent} event The initiating click event
*/
#onClickButton(event) {
event.preventDefault();
this.picker = new FilePicker({
type: this.type,
current: this.value,
allowUpload: !this.noupload,
callback: src => this.value = src
});
return this.picker.browse();
}
/* -------------------------------------------- */
/**
* Create a HTMLFilePickerElement using provided configuration data.
* @param {FormInputConfig<string> & FilePickerInputConfig} config
*/
static create(config) {
const picker = document.createElement(this.tagName);
picker.name = config.name;
picker.setAttribute("value", config.value || "");
picker.type = config.type;
picker.noupload = config.noupload;
foundry.applications.fields.setInputAttributes(picker, config);
return picker;
}
}

View File

@@ -0,0 +1,214 @@
/**
* An abstract custom HTMLElement designed for use with form inputs.
* @abstract
* @template {any} FormInputValueType
*
* @fires {Event} input An "input" event when the value of the input changes
* @fires {Event} change A "change" event when the value of the element changes
*/
export default class AbstractFormInputElement extends HTMLElement {
constructor() {
super();
this._internals = this.attachInternals();
}
/**
* The HTML tag name used by this element.
* @type {string}
*/
static tagName;
/**
* Declare that this custom element provides form element functionality.
* @type {boolean}
*/
static formAssociated = true;
/**
* Attached ElementInternals which provides form handling functionality.
* @type {ElementInternals}
* @protected
*/
_internals;
/**
* The primary input (if any). Used to determine what element should receive focus when an associated label is clicked
* on.
* @type {HTMLElement}
* @protected
*/
_primaryInput;
/**
* The form this element belongs to.
* @type {HTMLFormElement}
*/
get form() {
return this._internals.form;
}
/* -------------------------------------------- */
/* Element Properties */
/* -------------------------------------------- */
/**
* The input element name.
* @type {string}
*/
get name() {
return this.getAttribute("name");
}
set name(value) {
this.setAttribute("name", value);
}
/* -------------------------------------------- */
/**
* The value of the input element.
* @type {FormInputValueType}
*/
get value() {
return this._getValue();
}
set value(value) {
this._setValue(value);
this.dispatchEvent(new Event("input", {bubbles: true, cancelable: true}));
this.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
this._refresh();
}
/**
* The underlying value of the element.
* @type {FormInputValueType}
* @protected
*/
_value;
/* -------------------------------------------- */
/**
* Return the value of the input element which should be submitted to the form.
* @returns {FormInputValueType}
* @protected
*/
_getValue() {
return this._value;
}
/* -------------------------------------------- */
/**
* Translate user-provided input value into the format that should be stored.
* @param {FormInputValueType} value A new value to assign to the element
* @throws {Error} An error if the provided value is invalid
* @protected
*/
_setValue(value) {
this._value = value;
}
/* -------------------------------------------- */
/**
* Is this element disabled?
* @type {boolean}
*/
get disabled() {
return this.hasAttribute("disabled");
}
set disabled(value) {
this.toggleAttribute("disabled", value);
this._toggleDisabled(!this.editable);
}
/* -------------------------------------------- */
/**
* Is this field editable? The field can be neither disabled nor readonly.
* @type {boolean}
*/
get editable() {
return !(this.hasAttribute("disabled") || this.hasAttribute("readonly"));
}
/* -------------------------------------------- */
/**
* Special behaviors that the subclass should implement when toggling the disabled state of the input.
* @param {boolean} disabled The new disabled state
* @protected
*/
_toggleDisabled(disabled) {}
/* -------------------------------------------- */
/* Element Lifecycle */
/* -------------------------------------------- */
/**
* Initialize the custom element, constructing its HTML.
*/
connectedCallback() {
const elements = this._buildElements();
this.replaceChildren(...elements);
this._refresh();
this._toggleDisabled(!this.editable);
this.addEventListener("click", this._onClick.bind(this));
this._activateListeners();
}
/* -------------------------------------------- */
/**
* Create the HTML elements that should be included in this custom element.
* Elements are returned as an array of ordered children.
* @returns {HTMLElement[]}
* @protected
*/
_buildElements() {
return [];
}
/* -------------------------------------------- */
/**
* Refresh the active state of the custom element.
* @protected
*/
_refresh() {}
/* -------------------------------------------- */
/**
* Apply key attributes on the containing custom HTML element to input elements contained within it.
* @internal
*/
_applyInputAttributes(input) {
input.toggleAttribute("required", this.hasAttribute("required"));
input.toggleAttribute("disabled", this.hasAttribute("disabled"));
input.toggleAttribute("readonly", this.hasAttribute("readonly"));
}
/* -------------------------------------------- */
/**
* Activate event listeners which add dynamic behavior to the custom element.
* @protected
*/
_activateListeners() {}
/* -------------------------------------------- */
/**
* Special handling when the custom element is clicked. This should be implemented to transfer focus to an
* appropriate internal element.
* @param {PointerEvent} event
* @protected
*/
_onClick(event) {
if ( event.target === this ) this._primaryInput?.focus?.();
}
}

View File

@@ -0,0 +1,88 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* A class designed to standardize the behavior for a hue selector UI component.
* @extends {AbstractFormInputElement<number>}
*/
export default class HTMLHueSelectorSlider extends AbstractFormInputElement {
/** @override */
static tagName = "hue-slider";
/**
* The color range associated with this element.
* @type {HTMLInputElement|null}
*/
#input;
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Initialize existing value
this._setValue(this.getAttribute("value"));
// Build elements
this.#input = this._primaryInput = document.createElement("input");
this.#input.className = "color-range";
this.#input.type = "range";
this.#input.min = "0";
this.#input.max = "360";
this.#input.step = "1";
this.#input.disabled = this.disabled;
this.#input.value = this._value * 360;
return [this.#input];
}
/* -------------------------------------------- */
/**
* Refresh the active state of the custom element.
* @protected
*/
_refresh() {
this.#input.style.setProperty("--color-thumb", Color.fromHSL([this._value, 1, 0.5]).css);
}
/* -------------------------------------------- */
/**
* Activate event listeners which add dynamic behavior to the custom element.
* @protected
*/
_activateListeners() {
this.#input.oninput = this.#onInputColorRange.bind(this);
}
/* -------------------------------------------- */
/**
* Update the thumb and the value.
* @param {FormDataEvent} event
*/
#onInputColorRange(event) {
event.preventDefault();
event.stopImmediatePropagation();
this.value = this.#input.value / 360;
}
/* -------------------------------------------- */
/* Form Handling
/* -------------------------------------------- */
/** @override */
_setValue(value) {
value = Number(value);
if ( !value.between(0, 1) ) throw new Error("The value of a hue-slider must be on the range [0,1]");
this._value = value;
this.setAttribute("value", String(value));
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#input.disabled = disabled;
}
}

View File

@@ -0,0 +1,361 @@
import AbstractFormInputElement from "./form-element.mjs";
import HTMLStringTagsElement from "./string-tags.mjs";
/**
* An abstract base class designed to standardize the behavior for a multi-select UI component.
* Multi-select components return an array of values as part of form submission.
* Different implementations may provide different experiences around how inputs are presented to the user.
* @extends {AbstractFormInputElement<Set<string>>}
*/
export class AbstractMultiSelectElement extends AbstractFormInputElement {
constructor() {
super();
this._value = new Set();
this._initialize();
}
/**
* Predefined <option> and <optgroup> elements which were defined in the original HTML.
* @type {(HTMLOptionElement|HTMLOptGroupElement)[]}
* @protected
*/
_options;
/**
* An object which maps option values to displayed labels.
* @type {Record<string, string>}
* @protected
*/
_choices = {};
/* -------------------------------------------- */
/**
* Preserve existing <option> and <optgroup> elements which are defined in the original HTML.
* @protected
*/
_initialize() {
this._options = [...this.children];
for ( const option of this.querySelectorAll("option") ) {
if ( !option.value ) continue; // Skip predefined options which are already blank
this._choices[option.value] = option.innerText;
if ( option.selected ) {
this._value.add(option.value);
option.selected = false;
}
}
}
/* -------------------------------------------- */
/**
* Mark a choice as selected.
* @param {string} value The value to add to the chosen set
*/
select(value) {
const exists = this._value.has(value);
if ( !exists ) {
if ( !(value in this._choices) ) {
throw new Error(`"${value}" is not an option allowed by this multi-select element`);
}
this._value.add(value);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
}
/* -------------------------------------------- */
/**
* Mark a choice as un-selected.
* @param {string} value The value to delete from the chosen set
*/
unselect(value) {
const exists = this._value.has(value);
if ( exists ) {
this._value.delete(value);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
}
/* -------------------------------------------- */
/* Form Handling */
/* -------------------------------------------- */
/** @override */
_getValue() {
return Array.from(this._value);
}
/** @override */
_setValue(value) {
if ( !Array.isArray(value) ) {
throw new Error("The value assigned to a multi-select element must be an array.");
}
if ( value.some(v => !(v in this._choices)) ) {
throw new Error("The values assigned to a multi-select element must all be valid options.");
}
this._value.clear();
for ( const v of value ) this._value.add(v);
}
}
/* -------------------------------------------- */
/**
* Provide a multi-select workflow using a select element as the input mechanism.
*
* @example Multi-Select HTML Markup
* ```html
* <multi-select name="select-many-things">
* <optgroup label="Basic Options">
* <option value="foo">Foo</option>
* <option value="bar">Bar</option>
* <option value="baz">Baz</option>
* </optgroup>
* <optgroup label="Advanced Options">
* <option value="fizz">Fizz</option>
* <option value="buzz">Buzz</option>
* </optgroup>
* </multi-select>
* ```
*/
export class HTMLMultiSelectElement extends AbstractMultiSelectElement {
/** @override */
static tagName = "multi-select";
/**
* A select element used to choose options.
* @type {HTMLSelectElement}
*/
#select;
/**
* A display element which lists the chosen options.
* @type {HTMLDivElement}
*/
#tags;
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create select element
this.#select = this._primaryInput = document.createElement("select");
this.#select.insertAdjacentHTML("afterbegin", '<option value=""></option>');
this.#select.append(...this._options);
this.#select.disabled = !this.editable;
// Create a div element for display
this.#tags = document.createElement("div");
this.#tags.className = "tags input-element-tags";
return [this.#tags, this.#select];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
// Update the displayed tags
const tags = Array.from(this._value).map(id => {
return HTMLStringTagsElement.renderTag(id, this._choices[id], this.editable);
});
this.#tags.replaceChildren(...tags);
// Disable selected options
for ( const option of this.#select.querySelectorAll("option") ) {
option.disabled = this._value.has(option.value);
}
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
this.#select.addEventListener("change", this.#onChangeSelect.bind(this));
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
}
/* -------------------------------------------- */
/**
* Handle changes to the Select input, marking the selected option as a chosen value.
* @param {Event} event The change event on the select element
*/
#onChangeSelect(event) {
event.preventDefault();
event.stopImmediatePropagation();
const select = event.currentTarget;
if ( !select.value ) return; // Ignore selection of the blank value
this.select(select.value);
select.value = "";
}
/* -------------------------------------------- */
/**
* Handle click events on a tagged value, removing it from the chosen set.
* @param {PointerEvent} event The originating click event on a chosen tag
*/
#onClickTag(event) {
event.preventDefault();
if ( !event.target.classList.contains("remove") ) return;
if ( !this.editable ) return;
const tag = event.target.closest(".tag");
this.unselect(tag.dataset.key);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#select.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLMultiSelectElement using provided configuration data.
* @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
* @returns {HTMLMultiSelectElement}
*/
static create(config) {
return foundry.applications.fields.createMultiSelectInput(config);
}
}
/* -------------------------------------------- */
/**
* Provide a multi-select workflow as a grid of input checkbox elements.
*
* @example Multi-Checkbox HTML Markup
* ```html
* <multi-checkbox name="check-many-boxes">
* <optgroup label="Basic Options">
* <option value="foo">Foo</option>
* <option value="bar">Bar</option>
* <option value="baz">Baz</option>
* </optgroup>
* <optgroup label="Advanced Options">
* <option value="fizz">Fizz</option>
* <option value="buzz">Buzz</option>
* </optgroup>
* </multi-checkbox>
* ```
*/
export class HTMLMultiCheckboxElement extends AbstractMultiSelectElement {
/** @override */
static tagName = "multi-checkbox";
/**
* The checkbox elements used to select inputs
* @type {HTMLInputElement[]}
*/
#checkboxes;
/* -------------------------------------------- */
/** @override */
_buildElements() {
this.#checkboxes = [];
const children = [];
for ( const option of this._options ) {
if ( option instanceof HTMLOptGroupElement ) children.push(this.#buildGroup(option));
else children.push(this.#buildOption(option));
}
return children;
}
/* -------------------------------------------- */
/**
* Translate an input <optgroup> element into a <fieldset> of checkboxes.
* @param {HTMLOptGroupElement} optgroup The originally configured optgroup
* @returns {HTMLFieldSetElement} The created fieldset grouping
*/
#buildGroup(optgroup) {
// Create fieldset group
const group = document.createElement("fieldset");
group.classList.add("checkbox-group");
const legend = document.createElement("legend");
legend.innerText = optgroup.label;
group.append(legend);
// Add child options
for ( const option of optgroup.children ) {
if ( option instanceof HTMLOptionElement ) {
group.append(this.#buildOption(option));
}
}
return group;
}
/* -------------------------------------------- */
/**
* Build an input <option> element into a <label class="checkbox"> element.
* @param {HTMLOptionElement} option The originally configured option
* @returns {HTMLLabelElement} The created labeled checkbox element
*/
#buildOption(option) {
const label = document.createElement("label");
label.classList.add("checkbox");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = option.value;
checkbox.checked = this._value.has(option.value);
checkbox.disabled = this.disabled;
label.append(checkbox, option.innerText);
this.#checkboxes.push(checkbox);
return label;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
for ( const checkbox of this.#checkboxes ) {
checkbox.checked = this._value.has(checkbox.value);
}
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
for ( const checkbox of this.#checkboxes ) {
checkbox.addEventListener("change", this.#onChangeCheckbox.bind(this));
}
}
/* -------------------------------------------- */
/**
* Handle changes to a checkbox input, marking the selected option as a chosen value.
* @param {Event} event The change event on the checkbox input element
*/
#onChangeCheckbox(event) {
event.preventDefault();
event.stopImmediatePropagation();
const checkbox = event.currentTarget;
if ( checkbox.checked ) this.select(checkbox.value);
else this.unselect(checkbox.value);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
for ( const checkbox of this.#checkboxes ) {
checkbox.disabled = disabled;
}
}
}

View File

@@ -0,0 +1,248 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} ProseMirrorInputConfig
* @property {boolean} toggled Is this editor toggled (true) or always active (false)
* @property {string} [enriched] If the editor is toggled, provide the enrichedHTML which is displayed while
* the editor is not active.
* @property {boolean} collaborate Does this editor instance support collaborative editing?
* @property {boolean} compact Should the editor be presented in compact mode?
* @property {string} documentUUID A Document UUID. Required for collaborative editing
*/
/**
* A custom HTML element responsible displaying a ProseMirror rich text editor.
* @extends {AbstractFormInputElement<string>}
*/
export default class HTMLProseMirrorElement extends AbstractFormInputElement {
constructor() {
super();
// Initialize raw content
this._setValue(this.getAttribute("value") || "");
this.removeAttribute("value");
// Initialize enriched content
this.#toggled = this.hasAttribute("toggled");
this.#enriched = this.innerHTML;
}
/** @override */
static tagName = "prose-mirror";
/**
* Is the editor in active edit mode?
* @type {boolean}
*/
#active = false;
/**
* The ProseMirror editor instance.
* @type {ProseMirrorEditor}
*/
#editor;
/**
* Current editor contents
* @type {HTMLDivElement}
*/
#content;
/**
* Does this editor function via a toggle button? Or is it always active?
* @type {boolean}
*/
#toggled;
/**
* Enriched content which is optionally used if the editor is toggled.
* @type {string}
*/
#enriched;
/**
* An optional edit button which activates edit mode for the editor
* @type {HTMLButtonElement|null}
*/
#button = null;
/* -------------------------------------------- */
/**
* Actions to take when the custom element is removed from the document.
*/
disconnectedCallback() {
this.#editor?.destroy();
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
this.classList.add("editor", "prosemirror", "inactive");
const elements = [];
this.#content = document.createElement("div");
this.#content.className = "editor-content";
elements.push(this.#content);
if ( this.#toggled ) {
this.#button = document.createElement("button");
this.#button.type = "button";
this.#button.className = "icon toggle";
this.#button.innerHTML = `<i class="fa-solid fa-edit"></i>`;
elements.push(this.#button);
}
return elements;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
if ( this.#active ) return; // It is not safe to replace the content while the editor is active
if ( this.#toggled ) this.#content.innerHTML = this.#enriched ?? this._value;
else this.#content.innerHTML = this._value;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
if ( this.#toggled ) this.#button.addEventListener("click", this.#onClickButton.bind(this));
else this.#activateEditor();
}
/* -------------------------------------------- */
/** @override */
_getValue() {
if ( this.#active ) return ProseMirror.dom.serializeString(this.#editor.view.state.doc.content);
return this._value;
}
/* -------------------------------------------- */
/**
* Activate the ProseMirror editor.
* @returns {Promise<void>}
*/
async #activateEditor() {
// If the editor was toggled, replace with raw editable content
if ( this.#toggled ) this.#content.innerHTML = this._value;
// Create the TextEditor instance
const document = await fromUuid(this.dataset.documentUuid ?? this.dataset.documentUUID);
this.#editor = await TextEditor.create({
engine: "prosemirror",
plugins: this._configurePlugins(),
fieldName: this.name,
collaborate: this.hasAttribute("collaborate"),
target: this.#content,
document
}, this._getValue());
// Toggle active state
this.#active = true;
if ( this.#button ) this.#button.disabled = true;
this.classList.add("active");
this.classList.remove("inactive");
}
/* -------------------------------------------- */
/**
* Configure ProseMirror editor plugins.
* @returns {Record<string, ProseMirror.Plugin>}
* @protected
*/
_configurePlugins() {
return {
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
compact: this.hasAttribute("compact"),
destroyOnSave: this.#toggled,
onSave: this.#save.bind(this)
}),
keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema, {
onSave: this.#save.bind(this)
})
};
}
/* -------------------------------------------- */
/**
* Handle clicking the editor activation button.
* @param {PointerEvent} event The triggering event.
*/
#onClickButton(event) {
event.preventDefault();
this.#activateEditor();
}
/* -------------------------------------------- */
/**
* Handle saving the editor content.
* Store new parsed HTML into the _value attribute of the element.
* If the editor is toggled, also deactivate editing mode.
*/
#save() {
const value = ProseMirror.dom.serializeString(this.#editor.view.state.doc.content);
if ( value !== this._value ) {
this._setValue(value);
this.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
}
// Deactivate a toggled editor
if ( this.#toggled ) {
this.#button.disabled = this.disabled;
this.#active = false;
this.#editor.destroy();
this.classList.remove("active");
this.classList.add("inactive");
this.replaceChildren(this.#button, this.#content);
this._refresh();
this.dispatchEvent(new Event("close", {bubbles: true, cancelable: true}));
}
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
if ( this.#toggled ) this.#button.disabled = disabled;
}
/* -------------------------------------------- */
/**
* Create a HTMLProseMirrorElement using provided configuration data.
* @param {FormInputConfig & ProseMirrorInputConfig} config
* @returns {HTMLProseMirrorElement}
*/
static create(config) {
const editor = document.createElement(HTMLProseMirrorElement.tagName);
editor.name = config.name;
// Configure editor properties
editor.toggleAttribute("collaborate", config.collaborate ?? false);
editor.toggleAttribute("compact", config.compact ?? false);
editor.toggleAttribute("toggled", config.toggled ?? false);
if ( "documentUUID" in config ) Object.assign(editor.dataset, {
documentUuid: config.documentUUID,
documentUUID: config.documentUUID
});
if ( Number.isNumeric(config.height) ) editor.style.height = `${config.height}px`;
// Un-enriched content gets temporarily assigned to the value property of the element
editor.setAttribute("value", config.value);
// Enriched content gets temporarily assigned as the innerHTML of the element
if ( config.toggled && config.enriched ) editor.innerHTML = config.enriched;
return editor;
}
}

View File

@@ -0,0 +1,166 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} RangePickerInputConfig
* @property {number} min
* @property {number} max
* @property {number} [step]
*/
/**
* A custom HTML element responsible selecting a value on a range slider with a linked number input field.
* @extends {AbstractFormInputElement<number>}
*/
export default class HTMLRangePickerElement extends AbstractFormInputElement {
constructor() {
super();
this.#min = Number(this.getAttribute("min")) ?? 0;
this.#max = Number(this.getAttribute("max")) ?? 1;
this.#step = Number(this.getAttribute("step")) || undefined;
this._setValue(Number(this.getAttribute("value"))); // Initialize existing value
}
/** @override */
static tagName = "range-picker";
/**
* The range input.
* @type {HTMLInputElement}
*/
#rangeInput;
/**
* The number input.
* @type {HTMLInputElement}
*/
#numberInput;
/**
* The minimum allowed value for the range.
* @type {number}
*/
#min;
/**
* The maximum allowed value for the range.
* @type {number}
*/
#max;
/**
* A required step size for the range.
* @type {number}
*/
#step;
/* -------------------------------------------- */
/**
* The value of the input element.
* @type {number}
*/
get valueAsNumber() {
return this._getValue();
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create range input element
const r = this.#rangeInput = document.createElement("input");
r.type = "range";
r.min = String(this.#min);
r.max = String(this.#max);
r.step = String(this.#step ?? 0.1);
this._applyInputAttributes(r);
// Create the number input element
const n = this.#numberInput = this._primaryInput = document.createElement("input");
n.type = "number";
n.min = String(this.#min);
n.max = String(this.#max);
n.step = this.#step ?? "any";
this._applyInputAttributes(n);
return [this.#rangeInput, this.#numberInput];
}
/* -------------------------------------------- */
/** @override */
_setValue(value) {
value = Math.clamp(value, this.#min, this.#max);
if ( this.#step ) value = value.toNearest(this.#step);
this._value = value;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
if ( !this.#rangeInput ) return; // Not yet connected
this.#rangeInput.valueAsNumber = this.#numberInput.valueAsNumber = this._value;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
const onChange = this.#onChangeInput.bind(this);
this.#rangeInput.addEventListener("input", this.#onDragSlider.bind(this));
this.#rangeInput.addEventListener("change", onChange);
this.#numberInput.addEventListener("change", onChange);
}
/* -------------------------------------------- */
/**
* Update display of the number input as the range slider is actively changed.
* @param {InputEvent} event The originating input event
*/
#onDragSlider(event) {
event.preventDefault();
this.#numberInput.valueAsNumber = this.#rangeInput.valueAsNumber;
}
/* -------------------------------------------- */
/**
* Handle changes to one of the inputs of the range picker element.
* @param {InputEvent} event The originating input change event
*/
#onChangeInput(event) {
event.stopPropagation();
this.value = event.currentTarget.valueAsNumber;
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#rangeInput.toggleAttribute("disabled", disabled);
this.#numberInput.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLRangePickerElement using provided configuration data.
* @param {FormInputConfig & RangePickerInputConfig} config
* @returns {HTMLRangePickerElement}
*/
static create(config) {
const picker = document.createElement(HTMLRangePickerElement.tagName);
picker.name = config.name;
for ( const attr of ["value", "min", "max", "step"] ) {
if ( attr in config ) picker.setAttribute(attr, config[attr]);
}
foundry.applications.fields.setInputAttributes(picker, config);
return picker;
}
}

View File

@@ -0,0 +1,275 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} StringTagsInputConfig
* @property {boolean} slug Automatically slugify provided strings?
*/
/**
* A custom HTML element which allows for arbitrary assignment of a set of string tags.
* This element may be used directly or subclassed to impose additional validation or functionality.
* @extends {AbstractFormInputElement<Set<string>>}
*/
export default class HTMLStringTagsElement extends AbstractFormInputElement {
constructor() {
super();
this.#slug = this.hasAttribute("slug");
this._value = new Set();
this._initializeTags();
}
/** @override */
static tagName = "string-tags";
static icons = {
add: "fa-solid fa-tag",
remove: "fa-solid fa-times"
}
static labels = {
add: "ELEMENTS.TAGS.Add",
remove: "ELEMENTS.TAGS.Remove",
placeholder: ""
}
/**
* The button element to add a new tag.
* @type {HTMLButtonElement}
*/
#button;
/**
* The input element to enter a new tag.
* @type {HTMLInputElement}
*/
#input;
/**
* The tags list of assigned tags.
* @type {HTMLDivElement}
*/
#tags;
/**
* Automatically slugify all strings provided to the element?
* @type {boolean}
*/
#slug;
/* -------------------------------------------- */
/**
* Initialize innerText or an initial value attribute of the element as a comma-separated list of currently assigned
* string tags.
* @protected
*/
_initializeTags() {
const initial = this.getAttribute("value") || this.innerText || "";
const tags = initial ? initial.split(",") : [];
for ( let tag of tags ) {
tag = tag.trim();
if ( tag ) {
if ( this.#slug ) tag = tag.slugify({strict: true});
try {
this._validateTag(tag);
} catch ( err ) {
console.warn(err.message);
continue;
}
this._value.add(tag);
}
}
this.innerText = "";
this.removeAttribute("value");
}
/* -------------------------------------------- */
/**
* Subclasses may impose more strict validation on what tags are allowed.
* @param {string} tag A candidate tag
* @throws {Error} An error if the candidate tag is not allowed
* @protected
*/
_validateTag(tag) {
if ( !tag ) throw new Error(game.i18n.localize("ELEMENTS.TAGS.ErrorBlank"));
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create tags list
const tags = document.createElement("div");
tags.className = "tags input-element-tags";
this.#tags = tags;
// Create input element
const input = document.createElement("input");
input.type = "text";
input.placeholder = game.i18n.localize(this.constructor.labels.placeholder);
this.#input = this._primaryInput = input;
// Create button
const button = document.createElement("button");
button.type = "button"
button.className = `icon ${this.constructor.icons.add}`;
button.dataset.tooltip = this.constructor.labels.add;
button.ariaLabel = game.i18n.localize(this.constructor.labels.add);
this.#button = button;
return [this.#tags, this.#input, this.#button];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
const tags = this.value.map(tag => this.constructor.renderTag(tag, tag, this.editable));
this.#tags.replaceChildren(...tags);
}
/* -------------------------------------------- */
/**
* Render the tagged string as an HTML element.
* @param {string} tag The raw tag value
* @param {string} [label] An optional tag label
* @param {boolean} [editable=true] Is the tag editable?
* @returns {HTMLDivElement} A rendered HTML element for the tag
*/
static renderTag(tag, label, editable=true) {
const div = document.createElement("div");
div.className = "tag";
div.dataset.key = tag;
const span = document.createElement("span");
span.textContent = label ?? tag;
div.append(span);
if ( editable ) {
const t = game.i18n.localize(this.labels.remove);
const a = `<a class="remove ${this.icons.remove}" data-tooltip="${t}" aria-label="${t}"></a>`;
div.insertAdjacentHTML("beforeend", a);
}
return div;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
this.#button.addEventListener("click", this.#addTag.bind(this));
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
this.#input.addEventListener("keydown", this.#onKeydown.bind(this));
}
/* -------------------------------------------- */
/**
* Remove a tag from the set when its removal button is clicked.
* @param {PointerEvent} event
*/
#onClickTag(event) {
if ( !event.target.classList.contains("remove") ) return;
const tag = event.target.closest(".tag");
this._value.delete(tag.dataset.key);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
/* -------------------------------------------- */
/**
* Add a tag to the set when the ENTER key is pressed in the text input.
* @param {KeyboardEvent} event
*/
#onKeydown(event) {
if ( event.key !== "Enter" ) return;
event.preventDefault();
event.stopPropagation();
this.#addTag();
}
/* -------------------------------------------- */
/**
* Add a new tag to the set upon user input.
*/
#addTag() {
let tag = this.#input.value.trim();
if ( this.#slug ) tag = tag.slugify({strict: true});
// Validate the proposed code
try {
this._validateTag(tag);
} catch(err) {
ui.notifications.error(err.message);
this.#input.value = "";
return;
}
// Ensure uniqueness
if ( this._value.has(tag) ) {
const message = game.i18n.format("ELEMENTS.TAGS.ErrorNonUnique", {tag});
ui.notifications.error(message);
this.#input.value = "";
return;
}
// Add hex
this._value.add(tag);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this.#input.value = "";
this._refresh();
}
/* -------------------------------------------- */
/* Form Handling */
/* -------------------------------------------- */
/** @override */
_getValue() {
return Array.from(this._value);
}
/* -------------------------------------------- */
/** @override */
_setValue(value) {
this._value.clear();
const toAdd = [];
for ( let v of value ) {
if ( this.#slug ) v = v.slugify({strict: true});
this._validateTag(v);
toAdd.push(v);
}
for ( const v of toAdd ) this._value.add(v);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#input.toggleAttribute("disabled", disabled);
this.#button.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLStringTagsElement using provided configuration data.
* @param {FormInputConfig & StringTagsInputConfig} config
*/
static create(config) {
const tags = document.createElement(this.tagName);
tags.name = config.name;
const value = Array.from(config.value || []).join(",");
tags.toggleAttribute("slug", !!config.slug)
tags.setAttribute("value", value);
foundry.applications.fields.setInputAttributes(tags, config);
return tags;
}
}

View File

@@ -0,0 +1,434 @@
/**
* @callback CustomFormGroup
* @param {DataField} field
* @param {FormGroupConfig} groupConfig
* @param {FormInputConfig} inputConfig
* @returns {HTMLDivElement}
*/
/**
* @callback CustomFormInput
* @param {DataField} field
* @param {FormInputConfig} config
* @returns {HTMLElement|HTMLCollection}
*/
/**
* @typedef {Object} FormGroupConfig
* @property {string} label A text label to apply to the form group
* @property {string} [units] An optional units string which is appended to the label
* @property {HTMLElement|HTMLCollection} input An HTML element or collection of elements which provide the inputs
* for the group
* @property {string} [hint] Hint text displayed as part of the form group
* @property {string} [rootId] Some parent CSS id within which field names are unique. If provided,
* this root ID is used to automatically assign "id" attributes to input
* elements and "for" attributes to corresponding labels
* @property {string[]} [classes] An array of CSS classes applied to the form group element
* @property {boolean} [stacked=false] Is the "stacked" class applied to the form group
* @property {boolean} [localize=false] Should labels or other elements within this form group be
* automatically localized?
* @property {CustomFormGroup} [widget] A custom form group widget function which replaces the default
* group HTML generation
*/
/**
* @template FormInputValue
* @typedef {Object} FormInputConfig
* @property {string} name The name of the form element
* @property {FormInputValue} [value] The current value of the form element
* @property {boolean} [required=false] Is the field required?
* @property {boolean} [disabled=false] Is the field disabled?
* @property {boolean} [readonly=false] Is the field readonly?
* @property {boolean} [autofocus=false] Is the field autofocused?
* @property {boolean} [localize=false] Localize values of this field?
* @property {Record<string,string>} [dataset] Additional dataset attributes to assign to the input
* @property {string} [placeholder] A placeholder value, if supported by the element type
* @property {CustomFormInput} [input]
*/
/**
* Create a standardized form field group.
* @param {FormGroupConfig} config
* @returns {HTMLDivElement}
*/
export function createFormGroup(config) {
let {classes, hint, label, input, rootId, stacked, localize, units} = config;
classes ||= [];
if ( stacked ) classes.unshift("stacked");
classes.unshift("form-group");
// Assign identifiers to each input
input = input instanceof HTMLCollection ? input : [input];
let labelFor;
if ( rootId ) {
for ( const [i, el] of input.entries() ) {
const id = [rootId, el.name, input.length > 1 ? i : ""].filterJoin("-");
labelFor ||= id;
el.setAttribute("id", id);
}
}
// Create the group element
const group = document.createElement("div");
group.className = classes.join(" ");
// Label element
const lbl = document.createElement("label");
lbl.innerText = localize ? game.i18n.localize(label) : label;
if ( labelFor ) lbl.setAttribute("for", labelFor);
if ( units ) lbl.insertAdjacentHTML("beforeend", ` <span class="units">(${game.i18n.localize(units)})</span>`);
group.prepend(lbl);
// Form fields and inputs
const fields = document.createElement("div");
fields.className = "form-fields";
fields.append(...input);
group.append(fields);
// Hint element
if ( hint ) {
const h = document.createElement("p");
h.className = "hint";
h.innerText = localize ? game.i18n.localize(hint) : hint;
group.append(h);
}
return group;
}
/* ---------------------------------------- */
/**
* Create an `<input type="checkbox">` element for a BooleanField.
* @param {FormInputConfig<boolean>} config
* @returns {HTMLInputElement}
*/
export function createCheckboxInput(config) {
const input = document.createElement("input");
input.type = "checkbox";
input.name = config.name;
if ( config.value ) input.setAttribute("checked", "");
setInputAttributes(input, config);
return input;
}
/* ---------------------------------------- */
/**
* @typedef {Object} EditorInputConfig
* @property {string} [engine="prosemirror"]
* @property {number} [height]
* @property {boolean} [editable=true]
* @property {boolean} [button=false]
* @property {boolean} [collaborate=false]
*/
/**
* Create a `<div class="editor">` element for a StringField.
* @param {FormInputConfig<string> & EditorInputConfig} config
* @returns {HTMLDivElement}
*/
export function createEditorInput(config) {
const {engine="prosemirror", editable=true, button=false, collaborate=false, height} = config;
const editor = document.createElement("div");
editor.className = "editor";
if ( height !== undefined ) editor.style.height = `${height}px`;
// Dataset attributes
let dataset = { engine, collaborate };
if ( editable ) dataset.edit = config.name;
dataset = Object.entries(dataset).map(([k, v]) => `data-${k}="${v}"`).join(" ");
// Editor HTML
let editorHTML = "";
if ( button && editable ) editorHTML += '<a class="editor-edit"><i class="fa-solid fa-edit"></i></a>';
editorHTML += `<div class="editor-content" ${dataset}>${config.value ?? ""}</div>`;
editor.innerHTML = editorHTML;
return editor;
}
/* ---------------------------------------- */
/**
* Create a `<multi-select>` element for a StringField.
* @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
* @returns {HTMLSelectElement}
*/
export function createMultiSelectInput(config) {
const tagName = config.type === "checkboxes" ? "multi-checkbox" : "multi-select";
const select = document.createElement(tagName);
select.name = config.name;
setInputAttributes(select, config);
const groups = prepareSelectOptionGroups(config);
for ( const g of groups ) {
let parent = select;
if ( g.group ) parent = _appendOptgroup(g.group, select);
for ( const o of g.options ) _appendOption(o, parent);
}
return select;
}
/* ---------------------------------------- */
/**
* @typedef {Object} NumberInputConfig
* @property {number} min
* @property {number} max
* @property {number|"any"} step
* @property {"range"|"number"} [type]
*/
/**
* Create an `<input type="number">` element for a NumberField.
* @param {FormInputConfig<number> & NumberInputConfig} config
* @returns {HTMLInputElement}
*/
export function createNumberInput(config) {
const input = document.createElement("input");
input.type = "number";
if ( config.name ) input.name = config.name;
// Assign value
let step = typeof config.step === "number" ? config.step : "any";
let value = config.value;
if ( Number.isNumeric(value) ) {
if ( typeof config.step === "number" ) value = value.toNearest(config.step);
input.setAttribute("value", String(value));
}
else input.setAttribute("value", "");
// Min, max, and step size
if ( typeof config.min === "number" ) input.setAttribute("min", String(config.min));
if ( typeof config.max === "number" ) input.setAttribute("max", String(config.max));
input.setAttribute("step", String(step));
setInputAttributes(input, config);
return input;
}
/* ---------------------------------------- */
/**
* @typedef {Object} FormSelectOption
* @property {string} [value]
* @property {string} [label]
* @property {string} [group]
* @property {boolean} [disabled]
* @property {boolean} [selected]
* @property {boolean} [rule]
*/
/**
* @typedef {Object} SelectInputConfig
* @property {FormSelectOption[]} options
* @property {string[]} [groups] An option to control the order and display of optgroup elements. The order of
* strings defines the displayed order of optgroup elements.
* A blank string may be used to define the position of ungrouped options.
* If not defined, the order of groups corresponds to the order of options.
* @property {string} [blank]
* @property {string} [valueAttr] An alternative value key of the object passed to the options array
* @property {string} [labelAttr] An alternative label key of the object passed to the options array
* @property {boolean} [localize=false] Localize value labels
* @property {boolean} [sort=false] Sort options alphabetically by label within groups
* @property {"single"|"multi"|"checkboxes"} [type] Customize the type of select that is created
*/
/**
* Create a `<select>` element for a StringField.
* @param {FormInputConfig<string> & SelectInputConfig} config
* @returns {HTMLSelectElement}
*/
export function createSelectInput(config) {
const select = document.createElement("select");
select.name = config.name;
setInputAttributes(select, config);
const groups = prepareSelectOptionGroups(config);
for ( const g of groups ) {
let parent = select;
if ( g.group ) parent = _appendOptgroup(g.group, select);
for ( const o of g.options ) _appendOption(o, parent);
}
return select;
}
/* ---------------------------------------- */
/**
* @typedef {Object} TextAreaInputConfig
* @property {number} rows
*/
/**
* Create a `<textarea>` element for a StringField.
* @param {FormInputConfig<string> & TextAreaInputConfig} config
* @returns {HTMLTextAreaElement}
*/
export function createTextareaInput(config) {
const textarea = document.createElement("textarea");
textarea.name = config.name;
textarea.textContent = config.value ?? "";
if ( config.rows ) textarea.setAttribute("rows", String(config.rows));
setInputAttributes(textarea, config);
return textarea;
}
/* ---------------------------------------- */
/**
* Create an `<input type="text">` element for a StringField.
* @param {FormInputConfig<string>} config
* @returns {HTMLInputElement}
*/
export function createTextInput(config) {
const input = document.createElement("input");
input.type = "text";
input.name = config.name;
input.setAttribute("value", config.value ?? "");
setInputAttributes(input, config);
return input;
}
/* ---------------------------------------- */
/* Helper Methods */
/* ---------------------------------------- */
/**
* Structure a provided array of select options into a standardized format for rendering optgroup and option elements.
* @param {FormInputConfig & SelectInputConfig} config
* @returns {{group: string, options: FormSelectOption[]}[]}
*
* @example
* const options = [
* {value: "bar", label: "Bar", selected: true, group: "Good Options"},
* {value: "foo", label: "Foo", disabled: true, group: "Bad Options"},
* {value: "baz", label: "Baz", group: "Good Options"}
* ];
* const groups = ["Good Options", "Bad Options", "Unused Options"];
* const optgroups = foundry.applications.fields.prepareSelectOptionGroups({options, groups, blank: true, sort: true});
*/
export function prepareSelectOptionGroups(config) {
// Coerce values to string array
let values = [];
if ( (config.value === undefined) || (config.value === null) ) values = [];
else if ( typeof config.value === "object" ) {
for ( const v of config.value ) values.push(String(v));
}
else values = [String(config.value)];
const isSelected = value => values.includes(value);
// Organize options into groups
let hasBlank = false;
const groups = {};
for ( const option of (config.options || []) ) {
let {group, value, label, disabled, rule} = option;
// Value
if ( config.valueAttr ) value = option[config.valueAttr];
if ( value !== undefined ) {
value = String(value);
if ( value === "" ) hasBlank = true;
}
// Label
if ( config.labelAttr ) label = option[config.labelAttr];
label ??= value;
if ( label !== undefined ) {
if ( typeof label !== "string" ) label = label.toString();
if ( config.localize ) label = game.i18n.localize(label);
}
const selected = option.selected || isSelected(value);
disabled = !!disabled;
// Add to group
group ||= "";
groups[group] ||= [];
groups[group].push({type: "option", value, label, selected, disabled, rule})
}
// Add groups into an explicitly desired order
const result = [];
if ( config.groups instanceof Array ) {
for ( let group of config.groups ) {
const options = groups[group] ?? [];
delete groups[group];
if ( config.localize ) group = game.i18n.localize(group);
result.push({group, options});
}
}
// Add remaining groups
for ( let [groupName, options] of Object.entries(groups) ) {
if ( groupName && config.localize ) groupName = game.i18n.localize(groupName);
result.push({group: groupName, options});
}
// Sort options
if ( config.sort ) {
for ( const group of result ) group.options.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
}
// A blank option always comes first
if ( (typeof config.blank === "string") && !hasBlank ) result.unshift({group: "", options: [{
value: "",
label: config.localize ? game.i18n.localize(config.blank) : config.blank,
selected: isSelected("")
}]});
return result;
}
/* ---------------------------------------- */
/**
* Create and append an option element to a parent select or optgroup.
* @param {FormSelectOption} option
* @param {HTMLSelectElement|HTMLOptGroupElement} parent
* @internal
*/
function _appendOption(option, parent) {
const { value, label, selected, disabled, rule } = option;
if ( (value !== undefined) && (label !== undefined) ) {
const o = document.createElement("option");
o.value = value;
o.innerText = label;
if ( selected ) o.toggleAttribute("selected", true);
if ( disabled ) o.toggleAttribute("disabled", true);
parent.appendChild(o);
}
if ( rule ) parent.insertAdjacentHTML("beforeend", "<hr>");
}
/* ---------------------------------------- */
/**
* Create and append an optgroup element to a parent select.
* @param {string} label
* @param {HTMLSelectElement} parent
* @returns {HTMLOptGroupElement}
* @internal
*/
function _appendOptgroup(label, parent) {
const g = document.createElement("optgroup");
g.label = label;
parent.appendChild(g);
return g;
}
/* ---------------------------------------- */
/**
* Apply standard attributes to all input elements.
* @param {HTMLElement} input The element being configured
* @param {FormInputConfig<*>} config Configuration for the element
*/
export function setInputAttributes(input, config) {
input.toggleAttribute("required", config.required === true);
input.toggleAttribute("disabled", config.disabled === true);
input.toggleAttribute("readonly", config.readonly === true);
input.toggleAttribute("autofocus", config.autofocus === true);
if ( config.placeholder ) input.setAttribute("placeholder", config.placeholder);
if ( "dataset" in config ) {
for ( const [k, v] of Object.entries(config.dataset) ) {
input.dataset[k] = v;
}
}
}

View File

@@ -0,0 +1,7 @@
export {default as ActorSheetV2} from "./actor-sheet.mjs";
export {default as AmbientSoundConfig} from "./ambient-sound-config.mjs";
export {default as AmbientLightConfig} from "./ambient-light-config.mjs";
export {default as ItemSheetV2} from "./item-sheet.mjs";
export {default as RegionBehaviorConfig} from "./region-behavior-config.mjs";
export {default as RegionConfig} from "./region-config.mjs";
export {default as UserConfig} from "./user-config.mjs";

View File

@@ -0,0 +1,130 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
/**
* A base class for providing Actor Sheet behavior using ApplicationV2.
*/
export default class ActorSheetV2 extends DocumentSheetV2 {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
position: {
width: 600
},
window: {
controls: [
{
action: "configurePrototypeToken",
icon: "fa-solid fa-user-circle",
label: "TOKEN.TitlePrototype",
ownership: "OWNER"
},
{
action: "showPortraitArtwork",
icon: "fa-solid fa-image",
label: "SIDEBAR.CharArt",
ownership: "OWNER"
},
{
action: "showTokenArtwork",
icon: "fa-solid fa-image",
label: "SIDEBAR.TokenArt",
ownership: "OWNER"
}
]
},
actions: {
configurePrototypeToken: ActorSheetV2.#onConfigurePrototypeToken,
showPortraitArtwork: ActorSheetV2.#onShowPortraitArtwork,
showTokenArtwork: ActorSheetV2.#onShowTokenArtwork,
}
};
/**
* The Actor document managed by this sheet.
* @type {ClientDocument}
*/
get actor() {
return this.document;
}
/* -------------------------------------------- */
/**
* If this sheet manages the ActorDelta of an unlinked Token, reference that Token document.
* @type {TokenDocument|null}
*/
get token() {
return this.document.token || null;
}
/* -------------------------------------------- */
/** @override */
_getHeaderControls() {
const controls = this.options.window.controls;
// Portrait image
const img = this.actor.img;
if ( img === CONST.DEFAULT_TOKEN ) controls.findSplice(c => c.action === "showPortraitArtwork");
// Token image
const pt = this.actor.prototypeToken;
const tex = pt.texture.src;
if ( pt.randomImg || [null, undefined, CONST.DEFAULT_TOKEN].includes(tex) ) {
controls.findSplice(c => c.action === "showTokenArtwork");
}
return controls;
}
/* -------------------------------------------- */
async _renderHTML(context, options) {
return `<p>TESTING</p>`;
}
_replaceHTML(result, content, options) {
content.insertAdjacentHTML("beforeend", result);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle header control button clicks to render the Prototype Token configuration sheet.
* @this {ActorSheetV2}
* @param {PointerEvent} event
*/
static #onConfigurePrototypeToken(event) {
event.preventDefault();
const renderOptions = {
left: Math.max(this.position.left - 560 - 10, 10),
top: this.position.top
};
new CONFIG.Token.prototypeSheetClass(this.actor.prototypeToken, renderOptions).render(true);
}
/* -------------------------------------------- */
/**
* Handle header control button clicks to display actor portrait artwork.
* @this {ActorSheetV2}
* @param {PointerEvent} event
*/
static #onShowPortraitArtwork(event) {
const {img, name, uuid} = this.actor;
new ImagePopout(img, {title: name, uuid: uuid}).render(true);
}
/* -------------------------------------------- */
/**
* Handle header control button clicks to display actor portrait artwork.
* @this {ActorSheetV2}
* @param {PointerEvent} event
*/
static #onShowTokenArtwork(event) {
const {prototypeToken, name, uuid} = this.actor;
new ImagePopout(prototypeToken.texture.src, {title: name, uuid: uuid}).render(true);
}
}

View File

@@ -0,0 +1,259 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
/**
* The AmbientLight configuration application.
* @extends DocumentSheetV2
* @mixes HandlebarsApplication
* @alias AmbientLightConfig
*/
export default class AmbientLightConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
classes: ["ambient-light-config"],
window: {
contentClasses: ["standard-form"]
},
position: {
width: 560,
height: "auto"
},
form: {
handler: this.#onSubmit,
closeOnSubmit: true
},
actions:{
reset: this.#onReset
}
};
/** @override */
static PARTS = {
tabs: {
template: "templates/generic/tab-navigation.hbs"
},
basic: {
template: "templates/scene/parts/light-basic.hbs"
},
animation: {
template: "templates/scene/parts/light-animation.hbs"
},
advanced: {
template: "templates/scene/parts/light-advanced.hbs"
},
footer: {
template: "templates/generic/form-footer.hbs"
}
}
/**
* Maintain a copy of the original to show a real-time preview of changes.
* @type {AmbientLightDocument}
*/
preview;
/** @override */
tabGroups = {
sheet: "basic"
}
/* -------------------------------------------- */
/* Application Rendering */
/* -------------------------------------------- */
/** @inheritDoc */
async _preRender(context, options) {
await super._preRender(context, options);
if ( this.preview?.rendered ) {
await this.preview.object.draw();
this.document.object.initializeLightSource({deleted: true});
this.preview.object.layer.preview.addChild(this.preview.object);
this._previewChanges();
}
}
/* -------------------------------------------- */
/** @inheritDoc */
_onRender(context, options) {
super._onRender(context, options);
this.#toggleReset();
}
/* -------------------------------------------- */
/** @override */
_onClose(options) {
super._onClose(options);
if ( this.preview ) this._resetPreview();
if ( this.document.rendered ) this.document.object.initializeLightSource();
}
/* -------------------------------------------- */
/** @override */
async _prepareContext(options) {
// Create the preview on first render
if ( options.isFirstRender && this.document.object ) {
const clone = this.document.object.clone();
this.preview = clone.document;
}
// Prepare context
const document = this.preview ?? this.document;
const isDarkness = document.config.negative;
return {
light: document,
source: document.toObject(),
fields: document.schema.fields,
colorationTechniques: AdaptiveLightingShader.SHADER_TECHNIQUES,
gridUnits: document.parent.grid.units || game.i18n.localize("GridUnits"),
isDarkness,
lightAnimations: isDarkness ? CONFIG.Canvas.darknessAnimations : CONFIG.Canvas.lightAnimations,
tabs: this.#getTabs(),
buttons: [
{
type: "reset",
action: "reset",
icon: "fa-solid fa-undo",
label: "AMBIENT_LIGHT.ACTIONS.RESET"
},
{
type: "submit",
icon: "fa-solid fa-save",
label: this.document.id ? "AMBIENT_LIGHT.ACTIONS.UPDATE" : "AMBIENT_LIGHT.ACTIONS.CREATE"
}
]
}
}
/* -------------------------------------------- */
/**
* Prepare an array of form header tabs.
* @returns {Record<string, Partial<ApplicationTab>>}
*/
#getTabs() {
const tabs = {
basic: {id: "basic", group: "sheet", icon: "fa-solid fa-lightbulb", label: "AMBIENT_LIGHT.SECTIONS.BASIC"},
animation: {id: "animation", group: "sheet", icon: "fa-solid fa-play", label: "AMBIENT_LIGHT.SECTIONS.ANIMATION"},
advanced: {id: "advanced", group: "sheet", icon: "fa-solid fa-cogs", label: "AMBIENT_LIGHT.SECTIONS.ADVANCED"}
}
for ( const v of Object.values(tabs) ) {
v.active = this.tabGroups[v.group] === v.id;
v.cssClass = v.active ? "active" : "";
}
return tabs;
}
/* -------------------------------------------- */
/**
* Toggle visibility of the reset button which is only visible on the advanced tab.
*/
#toggleReset() {
const reset = this.element.querySelector("button[data-action=reset]");
reset.classList.toggle("hidden", this.tabGroups.sheet !== "advanced");
}
/* -------------------------------------------- */
/** @inheritDoc */
changeTab(...args) {
super.changeTab(...args);
this.#toggleReset();
}
/* -------------------------------------------- */
/* Real-Time Preview */
/* -------------------------------------------- */
/** @inheritDoc */
_onChangeForm(formConfig, event) {
super._onChangeForm(formConfig, event);
const formData = new FormDataExtended(this.element);
this._previewChanges(formData.object);
// Special handling for darkness state change
if ( event.target.name === "config.negative") this.render({parts: ["animation", "advanced"]});
}
/* -------------------------------------------- */
/**
* Preview changes to the AmbientLight document as if they were true document updates.
* @param {object} [change] A change to preview.
* @protected
*/
_previewChanges(change) {
if ( !this.preview ) return;
if ( change ) this.preview.updateSource(change);
if ( this.preview?.rendered ) {
this.preview.object.renderFlags.set({refresh: true});
this.preview.object.initializeLightSource();
}
}
/* -------------------------------------------- */
/**
* Restore the true data for the AmbientLight document when the form is submitted or closed.
* @protected
*/
_resetPreview() {
if ( !this.preview ) return;
if ( this.preview.rendered ) {
this.preview.object.destroy({children: true});
}
this.preview = null;
if ( this.document.rendered ) {
const object = this.document.object;
object.renderable = true;
object.initializeLightSource();
object.renderFlags.set({refresh: true});
}
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Process form submission for the sheet.
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @this {AmbientLightConfig}
* @returns {Promise<void>}
*/
static async #onSubmit(event, form, formData) {
const submitData = this._prepareSubmitData(event, form, formData);
if ( this.document.id ) await this.document.update(submitData);
else await this.document.constructor.create(submitData, {parent: canvas.scene});
}
/* -------------------------------------------- */
/**
* Process reset button click
* @param {PointerEvent} event The originating button click
* @this {AmbientLightConfig}
* @returns {Promise<void>}
*/
static async #onReset(event) {
event.preventDefault();
const defaults = AmbientLightDocument.cleanData();
const keys = ["vision", "config"];
const configKeys = ["coloration", "contrast", "attenuation", "luminosity", "saturation", "shadows"];
for ( const k in defaults ) {
if ( !keys.includes(k) ) delete defaults[k];
}
for ( const k in defaults.config ) {
if ( !configKeys.includes(k) ) delete defaults.config[k];
}
this._previewChanges(defaults);
await this.render();
}
}

View File

@@ -0,0 +1,114 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
/**
* The AmbientSound configuration application.
* @extends DocumentSheetV2
* @mixes HandlebarsApplication
* @alias AmbientSoundConfig
*/
export default class AmbientSoundConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
classes: ["ambient-sound-config"],
window: {
contentClasses: ["standard-form"]
},
position: {
width: 560,
height: "auto"
},
form: {
handler: this.#onSubmit,
closeOnSubmit: true
}
};
/** @override */
static PARTS = {
body: {
template: "templates/scene/ambient-sound-config.hbs"
},
footer: {
template: "templates/generic/form-footer.hbs"
}
}
/* -------------------------------------------- */
/** @inheritDoc */
get title() {
if ( !this.document.id ) return game.i18n.localize("AMBIENT_SOUND.ACTIONS.CREATE");
return super.title;
}
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options) {
return {
sound: this.document,
source: this.document.toObject(),
fields: this.document.schema.fields,
gridUnits: this.document.parent.grid.units || game.i18n.localize("GridUnits"),
soundEffects: CONFIG.soundEffects,
buttons: [{
type: "submit",
icon: "fa-solid fa-save",
label: game.i18n.localize(this.document.id ? "AMBIENT_SOUND.ACTIONS.UPDATE" : "AMBIENT_SOUND.ACTIONS.CREATE")
}]
}
}
/* -------------------------------------------- */
/** @inheritDoc */
_onRender(context, options) {
this.#toggleDisabledFields();
return super._onRender(context, options);
}
/* -------------------------------------------- */
/** @override */
_onClose(_options) {
if ( !this.document.id ) canvas.sounds.clearPreviewContainer();
}
/* -------------------------------------------- */
/** @inheritDoc */
_onChangeForm(formConfig, event) {
this.#toggleDisabledFields();
return super._onChangeForm(formConfig, event);
}
/* -------------------------------------------- */
/**
* Special logic to toggle the disabled state of form fields depending on the values of other fields.
*/
#toggleDisabledFields() {
const form = this.element;
form["effects.base.intensity"].disabled = !form["effects.base.type"].value;
form["effects.muffled.type"].disabled = form.walls.checked;
form["effects.muffled.intensity"].disabled = form.walls.checked || !form["effects.muffled.type"].value;
}
/* -------------------------------------------- */
/**
* Process form submission for the sheet.
* @param {SubmitEvent} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @this {AmbientSoundConfig}
* @returns {Promise<void>}
*/
static async #onSubmit(event, form, formData) {
const submitData = this._prepareSubmitData(event, form, formData);
if ( this.document.id ) await this.document.update(submitData);
else await this.document.constructor.create(submitData, {parent: canvas.scene});
}
}

View File

@@ -0,0 +1,30 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
/**
* A base class for providing Item Sheet behavior using ApplicationV2.
*/
export default class ItemSheetV2 extends DocumentSheetV2 {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
position: {
width: 480
}
};
/**
* The Item document managed by this sheet.
* @type {ClientDocument}
*/
get item() {
return this.document;
}
/**
* The Actor instance which owns this Item, if any.
* @type {Actor|null}
*/
get actor() {
return this.document.actor;
}
}

View File

@@ -0,0 +1,140 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
/**
* @typedef {import("../_types.mjs").FormNode} FormNode
* @typedef {import("../_types.mjs").FormFooterButton} FormFooterButton
*/
/**
* The Scene Region configuration application.
* @extends DocumentSheetV2
* @mixes HandlebarsApplication
* @alias RegionBehaviorConfig
*/
export default class RegionBehaviorConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
constructor(options) {
super(options);
this.options.window.icon = CONFIG.RegionBehavior.typeIcons[this.document.type];
}
/** @inheritDoc */
static DEFAULT_OPTIONS = {
classes: ["region-behavior-config"],
window: {
contentClasses: ["standard-form"],
icon: undefined // Defined in constructor
},
position: {
width: 480,
height: "auto"
},
form: {
closeOnSubmit: true
}
};
/** @override */
static PARTS = {
form: {
template: "templates/generic/form-fields.hbs"
},
footer: {
template: "templates/generic/form-footer.hbs"
}
}
/* -------------------------------------------- */
/* Context Preparation */
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options) {
const doc = this.document;
return {
region: doc,
source: doc._source,
fields: this._getFields(),
buttons: this._getButtons()
}
}
/* -------------------------------------------- */
/**
* Prepare form field structure for rendering.
* @returns {FormNode[]}
*/
_getFields() {
const doc = this.document;
const source = doc._source;
const fields = doc.schema.fields;
const {events, ...systemFields} = CONFIG.RegionBehavior.dataModels[doc.type]?.schema.fields;
const fieldsets = [];
// Identity
fieldsets.push({
fieldset: true,
legend: "BEHAVIOR.SECTIONS.identity",
fields: [
{field: fields.name, value: source.name}
]
});
// Status
fieldsets.push({
fieldset: true,
legend: "BEHAVIOR.SECTIONS.status",
fields: [
{field: fields.disabled, value: source.disabled}
]
});
// Subscribed events
if ( events ) {
fieldsets.push({
fieldset: true,
legend: "BEHAVIOR.TYPES.base.SECTIONS.events",
fields: [
{field: events, value: source.system.events}
]
});
}
// Other system fields
const sf = {fieldset: true, legend: CONFIG.RegionBehavior.typeLabels[doc.type], fields: []};
this.#addSystemFields(sf, systemFields, source);
if ( sf.fields.length ) fieldsets.push(sf);
return fieldsets;
}
/* -------------------------------------------- */
/**
* Recursively add system model fields to the fieldset.
*/
#addSystemFields(fieldset, schema, source, _path="system") {
for ( const field of Object.values(schema) ) {
const path = `${_path}.${field.name}`;
if ( field instanceof foundry.data.fields.SchemaField ) {
this.#addSystemFields(fieldset, field.fields, source, path);
}
else if ( field.constructor.hasFormSupport ) {
fieldset.fields.push({field, value: foundry.utils.getProperty(source, path)});
}
}
}
/* -------------------------------------------- */
/**
* Get footer buttons for this behavior config sheet.
* @returns {FormFooterButton[]}
* @protected
*/
_getButtons() {
return [
{type: "submit", icon: "fa-solid fa-save", label: "BEHAVIOR.ACTIONS.update"}
]
}
}

View File

@@ -0,0 +1,383 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
import {DOCUMENT_OWNERSHIP_LEVELS} from "../../../common/constants.mjs";
/**
* @typedef {import("../_types.mjs").ApplicationTab} ApplicationTab
* @typedef {import("../_types.mjs").FormFooterButton} FormFooterButton
*/
/**
* The Scene Region configuration application.
* @extends DocumentSheetV2
* @mixes HandlebarsApplication
* @alias RegionConfig
*/
export default class RegionConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
classes: ["region-config"],
window: {
contentClasses: ["standard-form"],
icon: "fa-regular fa-game-board"
},
position: {
width: 480,
height: "auto"
},
form: {
closeOnSubmit: true
},
viewPermission: DOCUMENT_OWNERSHIP_LEVELS.OWNER,
actions: {
shapeCreateFromWalls: RegionConfig.#onShapeCreateFromWalls,
shapeToggleHole: RegionConfig.#onShapeToggleHole,
shapeMoveUp: RegionConfig.#onShapeMoveUp,
shapeMoveDown: RegionConfig.#onShapeMoveDown,
shapeRemove: RegionConfig.#onShapeRemove,
behaviorCreate: RegionConfig.#onBehaviorCreate,
behaviorDelete: RegionConfig.#onBehaviorDelete,
behaviorEdit: RegionConfig.#onBehaviorEdit,
behaviorToggle: RegionConfig.#onBehaviorToggle
}
};
/** @override */
static PARTS = {
tabs: {
template: "templates/generic/tab-navigation.hbs"
},
identity: {
template: "templates/scene/parts/region-identity.hbs"
},
shapes: {
template: "templates/scene/parts/region-shapes.hbs",
scrollable: [".scrollable"]
},
behaviors: {
template: "templates/scene/parts/region-behaviors.hbs",
scrollable: [".scrollable"]
},
footer: {
template: "templates/generic/form-footer.hbs"
}
}
/** @override */
tabGroups = {
sheet: "identity"
}
/* -------------------------------------------- */
/* Context Preparation */
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options) {
const doc = this.document;
return {
region: doc,
source: doc.toObject(),
fields: doc.schema.fields,
tabs: this.#getTabs(),
}
}
/* -------------------------------------------- */
/** @override */
async _preparePartContext(partId, context) {
const doc = this.document;
switch ( partId ) {
case "footer":
context.buttons = this.#getFooterButtons();
break;
case "behaviors":
context.tab = context.tabs.behaviors;
context.behaviors = doc.behaviors.map(b => ({
id: b.id,
name: b.name,
typeLabel: game.i18n.localize(CONFIG.RegionBehavior.typeLabels[b.type]),
typeIcon: CONFIG.RegionBehavior.typeIcons[b.type] || "fa-regular fa-notdef",
disabled: b.disabled
})).sort((a, b) => (a.disabled - b.disabled) || a.name.localeCompare(b.name, game.i18n.lang));
break;
case "identity":
context.tab = context.tabs.identity;
break;
case "shapes":
context.tab = context.tabs.shapes;
break;
}
return context;
}
/* -------------------------------------------- */
/** @inheritDoc */
_onRender(context, options) {
super._onRender(context, options);
this.element.querySelectorAll(".region-shape").forEach(e => {
e.addEventListener("mouseover", this.#onShapeHoverIn.bind(this));
e.addEventListener("mouseout", this.#onShapeHoverOut.bind(this));
});
this.document.object?.renderFlags.set({refreshState: true});
}
/* -------------------------------------------- */
/** @inheritDoc */
_onClose(options) {
super._onClose(options);
this.document.object?.renderFlags.set({refreshState: true});
}
/* -------------------------------------------- */
/**
* Prepare an array of form header tabs.
* @returns {Record<string, Partial<ApplicationTab>>}
*/
#getTabs() {
const tabs = {
identity: {id: "identity", group: "sheet", icon: "fa-solid fa-tag", label: "REGION.SECTIONS.identity"},
shapes: {id: "shapes", group: "sheet", icon: "fa-solid fa-shapes", label: "REGION.SECTIONS.shapes"},
behaviors: {id: "behaviors", group: "sheet", icon: "fa-solid fa-child-reaching", label: "REGION.SECTIONS.behaviors"}
}
for ( const v of Object.values(tabs) ) {
v.active = this.tabGroups[v.group] === v.id;
v.cssClass = v.active ? "active" : "";
}
return tabs;
}
/* -------------------------------------------- */
/**
* Prepare an array of form footer buttons.
* @returns {Partial<FormFooterButton>[]}
*/
#getFooterButtons() {
return [
{type: "submit", icon: "fa-solid fa-save", label: "REGION.ACTIONS.update"}
]
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle mouse-hover events on a shape.
*/
#onShapeHoverIn(event) {
event.preventDefault();
if ( !this.document.parent.isView ) return;
const index = this.#getControlShapeIndex(event);
canvas.regions._highlightShape(this.document.shapes[index]);
}
/* -------------------------------------------- */
/**
* Handle mouse-unhover events for shape.
*/
#onShapeHoverOut(event) {
event.preventDefault();
if ( !this.document.parent.isView ) return;
canvas.regions._highlightShape(null);
}
/* -------------------------------------------- */
/**
* Handle button clicks to move the shape up.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onShapeMoveUp(event) {
if ( this.document.shapes.length <= 1 ) return;
const index = this.#getControlShapeIndex(event);
if ( index === 0 ) return;
const shapes = [...this.document.shapes];
[shapes[index - 1], shapes[index]] = [shapes[index], shapes[index - 1]];
await this.document.update({shapes});
}
/* -------------------------------------------- */
/**
* Handle button clicks to move the shape down.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onShapeMoveDown(event) {
if ( this.document.shapes.length <= 1 ) return;
const index = this.#getControlShapeIndex(event);
if ( index === this.document.shapes.length - 1 ) return;
const shapes = [...this.document.shapes];
[shapes[index], shapes[index + 1]] = [shapes[index + 1], shapes[index]];
await this.document.update({shapes});
}
/* -------------------------------------------- */
/**
* Handle button clicks to create shapes from the controlled walls.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onShapeCreateFromWalls(event) {
event.preventDefault(); // Don't open context menu
event.stopPropagation(); // Don't trigger other events
if ( !canvas.ready || (event.detail > 1) ) return; // Ignore repeated clicks
// If no walls are controlled, inform the user they need to control walls
if ( !canvas.walls.controlled.length ) {
if ( canvas.walls.active ) {
ui.notifications.error("REGION.NOTIFICATIONS.NoControlledWalls", {localize: true});
}
else {
canvas.walls.activate({tool: "select"});
ui.notifications.info("REGION.NOTIFICATIONS.ControlWalls", {localize: true});
}
return;
}
// Create the shape
const polygons = canvas.walls.identifyInteriorArea(canvas.walls.controlled);
if ( polygons.length === 0 ) {
ui.notifications.error("REGION.NOTIFICATIONS.EmptyEnclosedArea", {localize: true});
return;
}
const shapes = polygons.map(p => new foundry.data.PolygonShapeData({points: p.points}));
// Merge the new shape with form submission data
const form = this.element;
const formData = new FormDataExtended(form);
const submitData = this._prepareSubmitData(event, form, formData);
submitData.shapes = [...this.document._source.shapes, ...shapes];
// Update the region
await this.document.update(submitData);
}
/* -------------------------------------------- */
/**
* Handle button clicks to toggle the hold field of a shape.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onShapeToggleHole(event) {
const index = this.#getControlShapeIndex(event);
const shapes = this.document.shapes.map(s => s.toObject());
shapes[index].hole = !shapes[index].hole;
await this.document.update({shapes});
}
/* -------------------------------------------- */
/**
* Handle button clicks to remove a shape.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onShapeRemove(event) {
const index = this.#getControlShapeIndex(event);
let shapes = this.document.shapes;
return foundry.applications.api.DialogV2.confirm({
window: {
title: game.i18n.localize("REGION.ACTIONS.shapeRemove")
},
content: `<p>${game.i18n.localize("AreYouSure")}</p>`,
rejectClose: false,
yes: {
callback: () => {
// Test that there haven't been any changes to the shapes since the dialog the button was clicked
if ( this.document.shapes !== shapes ) return false;
shapes = [...shapes];
shapes.splice(index, 1);
this.document.update({shapes});
return true;
}
}
});
}
/* -------------------------------------------- */
/**
* Get the shape index from a control button click.
* @param {PointerEvent} event The button-click event
* @returns {number} The shape index
*/
#getControlShapeIndex(event) {
const button = event.target;
const li = button.closest(".region-shape");
return Number(li.dataset.shapeIndex);
}
/* -------------------------------------------- */
/**
* Handle button clicks to create a new behavior.
* @this {RegionConfig}
*/
static async #onBehaviorCreate(_event) {
await RegionBehavior.implementation.createDialog({}, {parent: this.document});
}
/* -------------------------------------------- */
/**
* Handle button clicks to delete a behavior.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onBehaviorDelete(event) {
const behavior = this.#getControlBehavior(event);
await behavior.deleteDialog();
}
/* -------------------------------------------- */
/**
* Handle button clicks to edit a behavior.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onBehaviorEdit(event) {
const target = event.target;
if ( target.closest(".region-element-name") && (event.detail !== 2) ) return; // Double-click on name
const behavior = this.#getControlBehavior(event);
await behavior.sheet.render(true);
}
/* -------------------------------------------- */
/**
* Handle button clicks to toggle a behavior.
* @param {PointerEvent} event
* @this {RegionConfig}
*/
static async #onBehaviorToggle(event) {
const behavior = this.#getControlBehavior(event);
await behavior.update({disabled: !behavior.disabled});
}
/* -------------------------------------------- */
/**
* Get the RegionBehavior document from a control button click.
* @param {PointerEvent} event The button-click event
* @returns {RegionBehavior} The region behavior document
*/
#getControlBehavior(event) {
const button = event.target;
const li = button.closest(".region-behavior");
return this.document.behaviors.get(li.dataset.behaviorId);
}
}

View File

@@ -0,0 +1,118 @@
import DocumentSheetV2 from "../api/document-sheet.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
/**
* The User configuration application.
* @extends DocumentSheetV2
* @mixes HandlebarsApplication
* @alias UserConfig
*/
export default class UserConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
classes: ["user-config"],
position: {
width: 480,
height: "auto"
},
actions: {
releaseCharacter: UserConfig.#onReleaseCharacter
},
form: {
closeOnSubmit: true
}
};
/** @override */
static PARTS = {
form: {
id: "form",
template: "templates/sheets/user-config.hbs"
}
}
/* -------------------------------------------- */
/** @inheritDoc */
get title() {
return `${game.i18n.localize("PLAYERS.ConfigTitle")}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options) {
return {
user: this.document,
source: this.document.toObject(),
fields: this.document.schema.fields,
characterWidget: this.#characterChoiceWidget.bind(this)
}
}
/* -------------------------------------------- */
/**
* Render the Character field as a choice between observed Actors.
* @returns {HTMLDivElement}
*/
#characterChoiceWidget(field, _groupConfig, inputConfig) {
// Create the form field
const fg = document.createElement("div");
fg.className = "form-group stacked character";
const ff = fg.appendChild(document.createElement("div"));
ff.className = "form-fields";
fg.insertAdjacentHTML("beforeend", `<p class="hint">${field.hint}</p>`);
// Actor select
const others = game.users.reduce((s, u) => {
if ( u.character && !u.isSelf ) s.add(u.character.id);
return s;
}, new Set());
const options = [];
const ownerGroup = game.i18n.localize("OWNERSHIP.OWNER");
const observerGroup = game.i18n.localize("OWNERSHIP.OBSERVER");
for ( const actor of game.actors ) {
if ( !actor.testUserPermission(this.document, "OBSERVER") ) continue;
const a = {value: actor.id, label: actor.name, disabled: others.has(actor.id)};
options.push({group: actor.isOwner ? ownerGroup : observerGroup, ...a});
}
const input = foundry.applications.fields.createSelectInput({...inputConfig,
name: field.fieldPath,
options,
blank: "",
sort: true
});
ff.appendChild(input);
// Player character
const c = this.document.character;
if ( c ) {
ff.insertAdjacentHTML("afterbegin", `<img class="avatar" src="${c.img}" alt="${c.name}">`);
const release = `<button type="button" class="icon fa-solid fa-ban" data-action="releaseCharacter"
data-tooltip="USER.SHEET.BUTTONS.RELEASE"></button>`
ff.insertAdjacentHTML("beforeend", release);
}
return fg;
}
/* -------------------------------------------- */
/**
* Handle button clicks to release the currently selected character.
* @param {PointerEvent} event
*/
static #onReleaseCharacter(event) {
event.preventDefault();
const button = event.target;
const fields = button.parentElement;
fields.querySelector("select[name=character]").value = "";
fields.querySelector("img.avatar").remove();
button.remove();
this.setPosition({height: "auto"});
}
}

View File

@@ -0,0 +1 @@
export {default as RegionLegend} from "./region-legend.mjs";

View File

@@ -0,0 +1,389 @@
import ApplicationV2 from "../api/application.mjs";
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
/**
* Scene Region Legend.
* @extends ApplicationV2
* @mixes HandlebarsApplication
* @alias RegionLegend
*/
export default class RegionLegend extends HandlebarsApplicationMixin(ApplicationV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: "region-legend",
tag: "aside",
position: {
width: 320,
height: "auto"
},
window: {
title: "REGION.LEGEND.title",
icon: "fa-regular fa-game-board",
minimizable: false
},
actions: {
config: RegionLegend.#onConfig,
control: RegionLegend.#onControl,
create: RegionLegend.#onCreate,
delete: RegionLegend.#onDelete,
lock: RegionLegend.#onLock
},
};
/** @override */
static PARTS = {
list: {
id: "list",
template: "templates/scene/region-legend.hbs",
scrollable: ["ol.region-list"]
}
}
/* -------------------------------------------- */
/**
* The currently filtered Regions.
* @type {{bottom: number, top: number}}
*/
#visibleRegions = new Set();
/* -------------------------------------------- */
/**
* The currently viewed elevation range.
* @type {{bottom: number, top: number}}
*/
elevation = {bottom: -Infinity, top: Infinity};
/* -------------------------------------------- */
/** @type {SearchFilter} */
#searchFilter = new SearchFilter({
inputSelector: 'input[name="search"]',
contentSelector: ".region-list",
callback: this.#onSearchFilter.bind(this)
});
/* -------------------------------------------- */
/**
* Record a reference to the currently highlighted Region.
* @type {Region|null}
*/
#hoveredRegion = null;
/* -------------------------------------------- */
/** @override */
_configureRenderOptions(options) {
super._configureRenderOptions(options);
if ( options.isFirstRender ) {
options.position.left ??= ui.nav?.element[0].getBoundingClientRect().left;
options.position.top ??= ui.controls?.element[0].getBoundingClientRect().top;
}
}
/* -------------------------------------------- */
/** @override */
_canRender(options) {
const rc = options.renderContext;
if ( rc && !["createregions", "updateregions", "deleteregions"].includes(rc) ) return false;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _renderFrame(options) {
const frame = await super._renderFrame(options);
this.window.close.remove(); // Prevent closing
return frame;
}
/* -------------------------------------------- */
/** @inheritDoc */
async close(options={}) {
if ( !options.closeKey ) return super.close(options);
return this;
}
/* -------------------------------------------- */
/** @inheritDoc */
_onFirstRender(context, options) {
super._onFirstRender(context, options);
canvas.scene.apps[this.id] = this;
}
/* -------------------------------------------- */
/** @inheritDoc */
_onRender(context, options) {
super._onRender(context, options);
this.#searchFilter.bind(this.element);
for ( const li of this.element.querySelectorAll(".region") ) {
li.addEventListener("mouseover", this.#onRegionHoverIn.bind(this));
li.addEventListener("mouseout", this.#onRegionHoverOut.bind(this));
}
this.element.querySelector(`input[name="elevationBottom"]`)
.addEventListener("change", this.#onElevationBottomChange.bind(this));
this.element.querySelector(`input[name="elevationTop"]`)
.addEventListener("change", this.#onElevationTopChange.bind(this));
this.#updateVisibleRegions();
}
/* -------------------------------------------- */
/** @override */
_onClose(options) {
super._onClose(options);
this.#visibleRegions.clear();
this.elevation.bottom = -Infinity;
this.elevation.top = Infinity;
delete canvas.scene.apps[this.id];
}
/* -------------------------------------------- */
/** @override */
async _prepareContext(_options) {
const regions = canvas.scene.regions.map(r => this.#prepareRegion(r));
regions.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
return {
regions,
elevation: {
bottom: Number.isFinite(this.elevation.bottom) ? this.elevation.bottom : "",
top: Number.isFinite(this.elevation.top) ? this.elevation.top : "",
}
};
}
/*-------------------------------------------- */
/**
* Prepare each Region for rendering in the legend.
* @param {Region} region
* @returns {object}
*/
#prepareRegion(region) {
const hasElv = (region.elevation.bottom !== null) || (region.elevation.top !== null);
return {
id: region.id,
name: region.name,
color: region.color.css,
elevation: region.elevation,
elevationLabel: hasElv ? `[${region.elevation.bottom ?? "&infin;"}, ${region.elevation.top ?? "&infin;"}]` : "",
empty: !region.shapes.length,
locked: region.locked,
controlled: region.object?.controlled,
hover: region.object?.hover,
buttons: [
{
action: "config",
icon: "fa-cogs",
tooltip: game.i18n.localize("REGION.LEGEND.config"),
disabled: ""
},
{
action: "lock",
icon: region.locked ? "fa-lock" : "fa-unlock",
tooltip: game.i18n.localize(region.locked ? "REGION.LEGEND.unlock" : "REGION.LEGEND.lock"),
disabled: ""
},
{
action: "delete",
icon: "fa-trash",
tooltip: game.i18n.localize("REGION.LEGEND.delete"),
disabled: region.locked ? "disabled" : ""
}
]
}
}
/* -------------------------------------------- */
/**
* Update the region list and hide regions that are not visible.
*/
#updateVisibleRegions() {
this.#visibleRegions.clear();
for ( const li of this.element.querySelectorAll(".region-list > .region") ) {
const id = li.dataset.regionId;
const region = canvas.scene.regions.get(id);
const hidden = !((this.#searchFilter.rgx?.test(SearchFilter.cleanQuery(region.name)) !== false)
&& (Math.max(region.object.bottom, this.elevation.bottom) <= Math.min(region.object.top, this.elevation.top)));
if ( !hidden ) this.#visibleRegions.add(region);
li.classList.toggle("hidden", hidden);
}
this.setPosition({height: "auto"});
for ( const region of canvas.regions.placeables ) region.renderFlags.set({refreshState: true});
}
/* -------------------------------------------- */
/**
* Filter regions.
* @param {KeyboardEvent} event The key-up event from keyboard input
* @param {string} query The raw string input to the search field
* @param {RegExp} rgx The regular expression to test against
* @param {HTMLElement} html The HTML element which should be filtered
*/
#onSearchFilter(event, query, rgx, html) {
if ( !this.rendered ) return;
this.#updateVisibleRegions();
}
/* -------------------------------------------- */
/**
* Handle change events of the elevation range (bottom) input.
* @param {KeyboardEvent} event
*/
#onElevationBottomChange(event) {
this.elevation.bottom = Number(event.currentTarget.value || -Infinity);
this.#updateVisibleRegions();
}
/* -------------------------------------------- */
/**
* Handle change events of the elevation range (top) input.
* @param {KeyboardEvent} event
*/
#onElevationTopChange(event) {
this.elevation.top = Number(event.currentTarget.value || Infinity);
this.#updateVisibleRegions();
}
/* -------------------------------------------- */
/**
* Is this Region visible in this RegionLegend?
* @param {Region} region The region
* @returns {boolean}
* @internal
*/
_isRegionVisible(region) {
if ( !this.rendered ) return true;
return this.#visibleRegions.has(region.document);
}
/* -------------------------------------------- */
/**
* Handle mouse-in events on a region in the legend.
* @param {PointerEvent} event
*/
#onRegionHoverIn(event) {
event.preventDefault();
if ( !canvas.ready ) return;
const li = event.currentTarget.closest(".region");
const region = canvas.regions.get(li.dataset.regionId);
region._onHoverIn(event, {hoverOutOthers: true, updateLegend: false});
this.#hoveredRegion = region;
li.classList.add("hovered");
}
/* -------------------------------------------- */
/**
* Handle mouse-out events for a region in the legend.
* @param {PointerEvent} event
*/
#onRegionHoverOut(event) {
event.preventDefault();
const li = event.currentTarget.closest(".region");
this.#hoveredRegion?._onHoverOut(event, {updateLegend: false});
this.#hoveredRegion = null;
li.classList.remove("hovered");
}
/* -------------------------------------------- */
/**
* Highlight a hovered region in the legend.
* @param {Region} region The Region
* @param {boolean} hover Whether they are being hovered in or out.
* @internal
*/
_hoverRegion(region, hover) {
if ( !this.rendered ) return;
const li = this.element.querySelector(`.region[data-region-id="${region.id}"]`);
if ( !li ) return;
if ( hover ) li.classList.add("hovered");
else li.classList.remove("hovered");
}
/* -------------------------------------------- */
/**
* Handle clicks to configure a Region.
* @param {PointerEvent} event
*/
static #onConfig(event) {
const regionId = event.target.closest(".region").dataset.regionId;
const region = canvas.scene.regions.get(regionId);
region.sheet.render({force: true});
}
/* -------------------------------------------- */
/**
* Handle clicks to assume control over a Region.
* @param {PointerEvent} event
*/
static #onControl(event) {
const regionId = event.target.closest(".region").dataset.regionId;
const region = canvas.scene.regions.get(regionId);
// Double-click = toggle sheet
if ( event.detail === 2 ) {
region.object.control({releaseOthers: true});
region.sheet.render({force: true});
}
// Single-click = toggle control
else if ( event.detail === 1 ) {
if ( region.object.controlled ) region.object.release();
else region.object.control({releaseOthers: true});
}
}
/* -------------------------------------------- */
/**
* Handle button clicks to create a new Region.
* @param {PointerEvent} event
*/
static async #onCreate(event) {
await canvas.scene.createEmbeddedDocuments("Region", [{
name: RegionDocument.implementation.defaultName({parent: canvas.scene})
}]);
}
/* -------------------------------------------- */
/**
* Handle clicks to delete a Region.
* @param {PointerEvent} event
*/
static async #onDelete(event) {
const regionId = event.target.closest(".region").dataset.regionId;
const region = canvas.scene.regions.get(regionId);
await region.deleteDialog();
}
/* -------------------------------------------- */
/**
* Handle clicks to toggle the locked state of a Region.
* @param {PointerEvent} event
*/
static async #onLock(event) {
const regionId = event.target.closest(".region").dataset.regionId;
const region = canvas.scene.regions.get(regionId);
await region.update({locked: !region.locked});
}
}