Files
Foundry-VTT-Docker/resources/app/client-esm/applications/api/application.mjs
2025-01-04 00:34:03 +01:00

1415 lines
50 KiB
JavaScript

import EventEmitterMixin from "../../../common/utils/event-emitter.mjs";
import Semaphore from "../../../common/utils/semaphore.mjs";
/**
* @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration
* @typedef {import("../_types.mjs").ApplicationRenderOptions} ApplicationRenderOptions
* @typedef {import("../_types.mjs").ApplicationRenderContext} ApplicationRenderContext
* @typedef {import("../_types.mjs").ApplicationClosingOptions} ApplicationClosingOptions
* @typedef {import("../_types.mjs").ApplicationPosition} ApplicationPosition
* @typedef {import("../_types.mjs").ApplicationHeaderControlsEntry} ApplicationHeaderControlsEntry
*/
/**
* The Application class is responsible for rendering an HTMLElement into the Foundry Virtual Tabletop user interface.
* @template {ApplicationConfiguration} Configuration
* @template {ApplicationRenderOptions} RenderOptions
* @alias ApplicationV2
*/
export default class ApplicationV2 extends EventEmitterMixin(Object) {
/**
* Applications are constructed by providing an object of configuration options.
* @param {Partial<Configuration>} [options] Options used to configure the Application instance
*/
constructor(options={}) {
super();
// Configure Application Options
this.options = Object.freeze(this._initializeApplicationOptions(options));
this.#id = this.options.id.replace("{id}", this.options.uniqueId);
Object.assign(this.#position, this.options.position);
// Verify the Application class is renderable
this.#renderable = (this._renderHTML !== ApplicationV2.prototype._renderHTML)
&& (this._replaceHTML !== ApplicationV2.prototype._replaceHTML);
}
/**
* Designates which upstream Application class in this class' inheritance chain is the base application.
* Any DEFAULT_OPTIONS of super-classes further upstream of the BASE_APPLICATION are ignored.
* Hook events for super-classes further upstream of the BASE_APPLICATION are not dispatched.
* @type {typeof ApplicationV2}
*/
static BASE_APPLICATION = ApplicationV2;
/**
* The default configuration options which are assigned to every instance of this Application class.
* @type {Partial<Configuration>}
*/
static DEFAULT_OPTIONS = {
id: "app-{id}",
classes: [],
tag: "div",
window: {
frame: true,
positioned: true,
title: "",
icon: "",
controls: [],
minimizable: true,
resizable: false,
contentTag: "section",
contentClasses: []
},
actions: {},
form: {
handler: undefined,
submitOnChange: false,
closeOnSubmit: false
},
position: {}
}
/**
* The sequence of rendering states that describe the Application life-cycle.
* @enum {number}
*/
static RENDER_STATES = Object.freeze({
ERROR: -3,
CLOSING: -2,
CLOSED: -1,
NONE: 0,
RENDERING: 1,
RENDERED: 2
});
/**
* Which application is currently "in front" with the maximum z-index
* @type {ApplicationV2}
*/
static #frontApp;
/** @override */
static emittedEvents = Object.freeze(["render", "close", "position"]);
/**
* Application instance configuration options.
* @type {Configuration}
*/
options;
/**
* @type {string}
*/
#id;
/**
* Flag that this Application instance is renderable.
* Applications are not renderable unless a subclass defines the _renderHTML and _replaceHTML methods.
*/
#renderable = true;
/**
* The outermost HTMLElement of this rendered Application.
* For window applications this is ApplicationV2##frame.
* For non-window applications this ApplicationV2##content.
* @type {HTMLDivElement}
*/
#element;
/**
* The HTMLElement within which inner HTML is rendered.
* For non-window applications this is the same as ApplicationV2##element.
* @type {HTMLElement}
*/
#content;
/**
* Data pertaining to the minimization status of the Application.
* @type {{
* active: boolean,
* [priorWidth]: number,
* [priorHeight]: number,
* [priorBoundingWidth]: number,
* [priorBoundingHeight]: number
* }}
*/
#minimization = Object.seal({
active: false,
priorWidth: undefined,
priorHeight: undefined,
priorBoundingWidth: undefined,
priorBoundingHeight: undefined
});
/**
* The rendered position of the Application.
* @type {ApplicationPosition}
*/
#position = Object.seal({
top: undefined,
left: undefined,
width: undefined,
height: "auto",
scale: 1,
zIndex: _maxZ
});
/**
* @type {ApplicationV2.RENDER_STATES}
*/
#state = ApplicationV2.RENDER_STATES.NONE;
/**
* A Semaphore used to enqueue asynchronous operations.
* @type {Semaphore}
*/
#semaphore = new Semaphore(1);
/**
* Convenience references to window header elements.
* @type {{
* header: HTMLElement,
* resize: HTMLElement,
* title: HTMLHeadingElement,
* icon: HTMLElement,
* close: HTMLButtonElement,
* controls: HTMLButtonElement,
* controlsDropdown: HTMLDivElement,
* onDrag: Function,
* onResize: Function,
* pointerStartPosition: ApplicationPosition,
* pointerMoveThrottle: boolean
* }}
*/
get window() {
return this.#window;
}
#window = {
title: undefined,
icon: undefined,
close: undefined,
controls: undefined,
controlsDropdown: undefined,
onDrag: this.#onWindowDragMove.bind(this),
onResize: this.#onWindowResizeMove.bind(this),
pointerStartPosition: undefined,
pointerMoveThrottle: false
};
/**
* If this Application uses tabbed navigation groups, this mapping is updated whenever the changeTab method is called.
* Reports the active tab for each group.
* Subclasses may override this property to define default tabs for each group.
* @type {Record<string, string>}
*/
tabGroups = {};
/* -------------------------------------------- */
/* Application Properties */
/* -------------------------------------------- */
/**
* The CSS class list of this Application instance
* @type {DOMTokenList}
*/
get classList() {
return this.#element?.classList;
}
/**
* The HTML element ID of this Application instance.
* @type {string}
*/
get id() {
return this.#id;
}
/**
* A convenience reference to the title of the Application window.
* @type {string}
*/
get title() {
return game.i18n.localize(this.options.window.title);
}
/**
* The HTMLElement which renders this Application into the DOM.
* @type {HTMLElement}
*/
get element() {
return this.#element;
}
/**
* Is this Application instance currently minimized?
* @type {boolean}
*/
get minimized() {
return this.#minimization.active;
}
/**
* The current position of the application with respect to the window.document.body.
* @type {ApplicationPosition}
*/
position = new Proxy(this.#position, {
set: (obj, prop, value) => {
if ( prop in obj ) {
obj[prop] = value;
this._updatePosition(this.#position);
return value;
}
}
});
/**
* Is this Application instance currently rendered?
* @type {boolean}
*/
get rendered() {
return this.#state === ApplicationV2.RENDER_STATES.RENDERED;
}
/**
* The current render state of the Application.
* @type {ApplicationV2.RENDER_STATES}
*/
get state() {
return this.#state;
}
/**
* Does this Application instance render within an outer window frame?
* @type {boolean}
*/
get hasFrame() {
return this.options.window.frame;
}
/* -------------------------------------------- */
/* Initialization */
/* -------------------------------------------- */
/**
* Iterate over the inheritance chain of this Application.
* The chain includes this Application itself and all parents until the base application is encountered.
* @see ApplicationV2.BASE_APPLICATION
* @generator
* @yields {typeof ApplicationV2}
*/
static *inheritanceChain() {
let cls = this;
while ( cls ) {
yield cls;
if ( cls === this.BASE_APPLICATION ) return;
cls = Object.getPrototypeOf(cls);
}
}
/* -------------------------------------------- */
/**
* Initialize configuration options for the Application instance.
* The default behavior of this method is to intelligently merge options for each class with those of their parents.
* - Array-based options are concatenated
* - Inner objects are merged
* - Otherwise, properties in the subclass replace those defined by a parent
* @param {Partial<ApplicationConfiguration>} options Options provided directly to the constructor
* @returns {ApplicationConfiguration} Configured options for the application instance
* @protected
*/
_initializeApplicationOptions(options) {
// Options initialization order
const order = [options];
for ( const cls of this.constructor.inheritanceChain() ) {
order.unshift(cls.DEFAULT_OPTIONS);
}
// Intelligently merge with parent class options
const applicationOptions = {};
for ( const opts of order ) {
for ( const [k, v] of Object.entries(opts) ) {
if ( (k in applicationOptions) ) {
const v0 = applicationOptions[k];
if ( Array.isArray(v0) ) applicationOptions[k].push(...v); // Concatenate arrays
else if ( foundry.utils.getType(v0) === "Object") Object.assign(v0, v); // Merge objects
else applicationOptions[k] = foundry.utils.deepClone(v); // Override option
}
else applicationOptions[k] = foundry.utils.deepClone(v);
}
}
// Unique application ID
applicationOptions.uniqueId = String(++globalThis._appId);
// Special handling for classes
if ( applicationOptions.window.frame ) applicationOptions.classes.unshift("application");
applicationOptions.classes = Array.from(new Set(applicationOptions.classes));
return applicationOptions;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Render the Application, creating its HTMLElement and replacing its innerHTML.
* Add it to the DOM if it is not currently rendered and rendering is forced. Otherwise, re-render its contents.
* @param {boolean|RenderOptions} [options] Options which configure application rendering behavior.
* A boolean is interpreted as the "force" option.
* @param {RenderOptions} [_options] Legacy options for backwards-compatibility with the original
* ApplicationV1#render signature.
* @returns {Promise<ApplicationV2>} A Promise which resolves to the rendered Application instance
*/
async render(options={}, _options={}) {
if ( typeof options === "boolean" ) options = Object.assign(_options, {force: options});
return this.#semaphore.add(this.#render.bind(this), options);
}
/* -------------------------------------------- */
/**
* Manage the rendering step of the Application life-cycle.
* This private method delegates out to several protected methods which can be defined by the subclass.
* @param {RenderOptions} [options] Options which configure application rendering behavior
* @returns {Promise<ApplicationV2>} A Promise which resolves to the rendered Application instance
*/
async #render(options) {
const states = ApplicationV2.RENDER_STATES;
if ( !this.#renderable ) throw new Error(`The ${this.constructor.name} Application class is not renderable because`
+ " it does not define the _renderHTML and _replaceHTML methods which are required.");
// Verify that the Application is allowed to be rendered
try {
const canRender = this._canRender(options);
if ( canRender === false ) return this;
} catch(err) {
ui.notifications.warn(err.message);
return this;
}
options.isFirstRender = this.#state <= states.NONE;
// Prepare rendering context data
this._configureRenderOptions(options);
const context = await this._prepareContext(options);
// Pre-render life-cycle events (awaited)
if ( options.isFirstRender ) {
if ( !options.force ) return this;
await this.#doEvent(this._preFirstRender, {async: true, handlerArgs: [context, options],
debugText: "Before first render"});
}
await this.#doEvent(this._preRender, {async: true, handlerArgs: [context, options],
debugText: "Before render"});
// Render the Application frame
this.#state = states.RENDERING;
if ( options.isFirstRender ) {
this.#element = await this._renderFrame(options);
this.#content = this.hasFrame ? this.#element.querySelector(".window-content") : this.#element;
this._attachFrameListeners();
}
// Render Application content
try {
const result = await this._renderHTML(context, options);
this._replaceHTML(result, this.#content, options);
}
catch(err) {
if ( this.#element ) {
this.#element.remove();
this.#element = null;
}
this.#state = states.ERROR;
throw new Error(`Failed to render Application "${this.id}":\n${err.message}`, { cause: err });
}
// Register the rendered Application
if ( options.isFirstRender ) {
foundry.applications.instances.set(this.#id, this);
this._insertElement(this.#element);
}
if ( this.hasFrame ) this._updateFrame(options);
this.#state = states.RENDERED;
// Post-render life-cycle events (not awaited)
if ( options.isFirstRender ) {
// noinspection ES6MissingAwait
this.#doEvent(this._onFirstRender, {handlerArgs: [context, options], debugText: "After first render"});
}
// noinspection ES6MissingAwait
this.#doEvent(this._onRender, {handlerArgs: [context, options], debugText: "After render", eventName: "render",
hookName: "render", hookArgs: [this.#element]});
// Update application position
if ( "position" in options ) this.setPosition(options.position);
if ( options.force && this.minimized ) this.maximize();
return this;
}
/* -------------------------------------------- */
/**
* Modify the provided options passed to a render request.
* @param {RenderOptions} options Options which configure application rendering behavior
* @protected
*/
_configureRenderOptions(options) {
const isFirstRender = this.#state <= ApplicationV2.RENDER_STATES.NONE;
const {window, position} = this.options;
// Initial frame options
if ( isFirstRender ) {
if ( this.hasFrame ) {
options.window ||= {};
options.window.title ||= this.title;
options.window.icon ||= window.icon;
options.window.controls = true;
options.window.resizable = window.resizable;
}
}
// Automatic repositioning
if ( isFirstRender ) options.position = Object.assign(this.#position, options.position);
else {
if ( position.width === "auto" ) options.position = Object.assign({width: "auto"}, options.position);
if ( position.height === "auto" ) options.position = Object.assign({height: "auto"}, options.position);
}
}
/* -------------------------------------------- */
/**
* Prepare application rendering context data for a given render request.
* @param {RenderOptions} options Options which configure application rendering behavior
* @returns {Promise<ApplicationRenderContext>} Context data for the render operation
* @protected
*/
async _prepareContext(options) {
return {};
}
/* -------------------------------------------- */
/**
* Configure the array of header control menu options
* @returns {ApplicationHeaderControlsEntry[]}
* @protected
*/
_getHeaderControls() {
return this.options.window.controls || [];
}
/* -------------------------------------------- */
/**
* Iterate over header control buttons, filtering for controls which are visible for the current client.
* @returns {Generator<ApplicationHeaderControlsEntry>}
* @yields {ApplicationHeaderControlsEntry}
* @protected
*/
*_headerControlButtons() {
for ( const control of this._getHeaderControls() ) {
if ( control.visible === false ) continue;
yield control;
}
}
/* -------------------------------------------- */
/**
* Render an HTMLElement for the Application.
* An Application subclass must implement this method in order for the Application to be renderable.
* @param {ApplicationRenderContext} context Context data for the render operation
* @param {RenderOptions} options Options which configure application rendering behavior
* @returns {Promise<any>} The result of HTML rendering may be implementation specific.
* Whatever value is returned here is passed to _replaceHTML
* @abstract
*/
async _renderHTML(context, options) {}
/* -------------------------------------------- */
/**
* Replace the HTML of the application with the result provided by the rendering backend.
* An Application subclass should implement this method in order for the Application to be renderable.
* @param {any} result The result returned by the application rendering backend
* @param {HTMLElement} content The content element into which the rendered result must be inserted
* @param {RenderOptions} options Options which configure application rendering behavior
* @protected
*/
_replaceHTML(result, content, options) {}
/* -------------------------------------------- */
/**
* Render the outer framing HTMLElement which wraps the inner HTML of the Application.
* @param {RenderOptions} options Options which configure application rendering behavior
* @returns {Promise<HTMLElement>}
* @protected
*/
async _renderFrame(options) {
const frame = document.createElement(this.options.tag);
frame.id = this.#id;
if ( this.options.classes.length ) frame.className = this.options.classes.join(" ");
if ( !this.hasFrame ) return frame;
// Window applications
const labels = {
controls: game.i18n.localize("APPLICATION.TOOLS.ControlsMenu"),
toggleControls: game.i18n.localize("APPLICATION.TOOLS.ToggleControls"),
close: game.i18n.localize("APPLICATION.TOOLS.Close")
}
const contentClasses = ["window-content", ...this.options.window.contentClasses].join(" ");
frame.innerHTML = `<header class="window-header">
<i class="window-icon hidden"></i>
<h1 class="window-title"></h1>
<button type="button" class="header-control fa-solid fa-ellipsis-vertical"
data-tooltip="${labels.toggleControls}" aria-label="${labels.toggleControls}"
data-action="toggleControls"></button>
<button type="button" class="header-control fa-solid fa-times"
data-tooltip="${labels.close}" aria-label="${labels.close}" data-action="close"></button>
</header>
<menu class="controls-dropdown"></menu>
<${this.options.window.contentTag} class="${contentClasses}"></section>
${this.options.window.resizable ? `<div class="window-resize-handle"></div>` : ""}`;
// Reference elements
this.#window.header = frame.querySelector(".window-header");
this.#window.title = frame.querySelector(".window-title");
this.#window.icon = frame.querySelector(".window-icon");
this.#window.resize = frame.querySelector(".window-resize-handle");
this.#window.close = frame.querySelector("button[data-action=close]");
this.#window.controls = frame.querySelector("button[data-action=toggleControls]");
this.#window.controlsDropdown = frame.querySelector(".controls-dropdown");
return frame;
}
/* -------------------------------------------- */
/**
* Render a header control button.
* @param {ApplicationHeaderControlsEntry} control
* @returns {HTMLLIElement}
* @protected
*/
_renderHeaderControl(control) {
const li = document.createElement("li");
li.className = "header-control";
li.dataset.action = control.action;
const label = game.i18n.localize(control.label);
li.innerHTML = `<button type="button" class="control">
<i class="control-icon fa-fw ${control.icon}"></i><span class="control-label">${label}</span>
</button>`;
return li;
}
/* -------------------------------------------- */
/**
* When the Application is rendered, optionally update aspects of the window frame.
* @param {RenderOptions} options Options provided at render-time
* @protected
*/
_updateFrame(options) {
const window = options.window;
if ( !window ) return;
if ( "title" in window ) this.#window.title.innerText = window.title;
if ( "icon" in window ) this.#window.icon.className = `window-icon fa-fw ${window.icon || "hidden"}`;
// Window header controls
if ( "controls" in window ) {
const controls = [];
for ( const c of this._headerControlButtons() ) {
controls.push(this._renderHeaderControl(c));
}
this.#window.controlsDropdown.replaceChildren(...controls);
this.#window.controls.classList.toggle("hidden", !controls.length);
}
}
/* -------------------------------------------- */
/**
* Insert the application HTML element into the DOM.
* Subclasses may override this method to customize how the application is inserted.
* @param {HTMLElement} element The element to insert
* @protected
*/
_insertElement(element) {
const existing = document.getElementById(element.id);
if ( existing ) existing.replaceWith(element);
else document.body.append(element);
element.querySelector("[autofocus]")?.focus();
}
/* -------------------------------------------- */
/* Closing */
/* -------------------------------------------- */
/**
* Close the Application, removing it from the DOM.
* @param {ApplicationClosingOptions} [options] Options which modify how the application is closed.
* @returns {Promise<ApplicationV2>} A Promise which resolves to the closed Application instance
*/
async close(options={}) {
return this.#semaphore.add(this.#close.bind(this), options);
}
/* -------------------------------------------- */
/**
* Manage the closing step of the Application life-cycle.
* This private method delegates out to several protected methods which can be defined by the subclass.
* @param {ApplicationClosingOptions} [options] Options which modify how the application is closed
* @returns {Promise<ApplicationV2>} A Promise which resolves to the rendered Application instance
*/
async #close(options) {
const states = ApplicationV2.RENDER_STATES;
if ( !this.#element ) {
this.#state = states.CLOSED;
return this;
}
// Pre-close life-cycle events (awaited)
await this.#doEvent(this._preClose, {async: true, handlerArgs: [options], debugText: "Before close"});
// Set explicit dimensions for the transition.
if ( options.animate !== false ) {
const { width, height } = this.#element.getBoundingClientRect();
this.#applyPosition({ ...this.#position, width, height });
}
// Remove the application element
this.#element.classList.add("minimizing")
this.#element.style.maxHeight = "0px";
this.#state = states.CLOSING;
if ( options.animate !== false ) await this._awaitTransition(this.#element, 1000);
// Remove the closed element
this._removeElement(this.#element);
this.#element = null;
this.#state = states.CLOSED;
foundry.applications.instances.delete(this.#id);
// Reset minimization state
this.#minimization.active = false;
// Post-close life-cycle events (not awaited)
// noinspection ES6MissingAwait
this.#doEvent(this._onClose, {handlerArgs: [options], debugText: "After close", eventName: "close",
hookName: "close"});
return this;
}
/* -------------------------------------------- */
/**
* Remove the application HTML element from the DOM.
* Subclasses may override this method to customize how the application element is removed.
* @param {HTMLElement} element The element to be removed
* @protected
*/
_removeElement(element) {
element.remove();
}
/* -------------------------------------------- */
/* Positioning */
/* -------------------------------------------- */
/**
* Update the Application element position using provided data which is merged with the prior position.
* @param {Partial<ApplicationPosition>} [position] New Application positioning data
* @returns {ApplicationPosition} The updated application position
*/
setPosition(position) {
if ( !this.options.window.positioned ) return;
position = Object.assign(this.#position, position);
this.#doEvent(this._prePosition, {handlerArgs: [position], debugText: "Before reposition"});
// Update resolved position
const updated = this._updatePosition(position);
Object.assign(this.#position, updated);
// Assign CSS styles
this.#applyPosition(updated);
this.#doEvent(this._onPosition, {handlerArgs: [position], debugText: "After reposition", eventName: "position"});
return position;
}
/* -------------------------------------------- */
/**
* Translate a requested application position updated into a resolved allowed position for the Application.
* Subclasses may override this method to implement more advanced positioning behavior.
* @param {ApplicationPosition} position Requested Application positioning data
* @returns {ApplicationPosition} Resolved Application positioning data
* @protected
*/
_updatePosition(position) {
if ( !this.#element ) return position;
const el = this.#element;
let {width, height, left, top, scale} = position;
scale ??= 1.0;
const computedStyle = getComputedStyle(el);
let minWidth = ApplicationV2.parseCSSDimension(computedStyle.minWidth, el.parentElement.offsetWidth) || 0;
let maxWidth = ApplicationV2.parseCSSDimension(computedStyle.maxWidth, el.parentElement.offsetWidth) || Infinity;
let minHeight = ApplicationV2.parseCSSDimension(computedStyle.minHeight, el.parentElement.offsetHeight) || 0;
let maxHeight = ApplicationV2.parseCSSDimension(computedStyle.maxHeight, el.parentElement.offsetHeight) || Infinity;
let bounds = el.getBoundingClientRect();
const {clientWidth, clientHeight} = document.documentElement;
// Explicit width
const autoWidth = width === "auto";
if ( !autoWidth ) {
const targetWidth = Number(width || bounds.width);
minWidth = parseInt(minWidth) || 0;
maxWidth = parseInt(maxWidth) || (clientWidth / scale);
width = Math.clamp(targetWidth, minWidth, maxWidth);
}
// Explicit height
const autoHeight = height === "auto";
if ( !autoHeight ) {
const targetHeight = Number(height || bounds.height);
minHeight = parseInt(minHeight) || 0;
maxHeight = parseInt(maxHeight) || (clientHeight / scale);
height = Math.clamp(targetHeight, minHeight, maxHeight);
}
// Implicit height
if ( autoHeight ) {
Object.assign(el.style, {width: `${width}px`, height: ""});
bounds = el.getBoundingClientRect();
height = bounds.height;
}
// Implicit width
if ( autoWidth ) {
Object.assign(el.style, {height: `${height}px`, width: ""});
bounds = el.getBoundingClientRect();
width = bounds.width;
}
// Left Offset
const scaledWidth = width * scale;
const targetLeft = left ?? ((clientWidth - scaledWidth) / 2);
const maxLeft = Math.max(clientWidth - scaledWidth, 0);
left = Math.clamp(targetLeft, 0, maxLeft);
// Top Offset
const scaledHeight = height * scale;
const targetTop = top ?? ((clientHeight - scaledHeight) / 2);
const maxTop = Math.max(clientHeight - scaledHeight, 0);
top = Math.clamp(targetTop, 0, maxTop);
// Scale
scale ??= 1.0;
return {width: autoWidth ? "auto" : width, height: autoHeight ? "auto" : height, left, top, scale};
}
/* -------------------------------------------- */
/**
* Apply validated position changes to the element.
* @param {ApplicationPosition} position The new position data to apply.
*/
#applyPosition(position) {
Object.assign(this.#element.style, {
width: position.width === "auto" ? "" : `${position.width}px`,
height: position.height === "auto" ? "" : `${position.height}px`,
left: `${position.left}px`,
top: `${position.top}px`,
transform: position.scale === 1 ? "" : `scale(${position.scale})`
});
}
/* -------------------------------------------- */
/* Other Public Methods */
/* -------------------------------------------- */
/**
* Is the window control buttons menu currently expanded?
* @type {boolean}
*/
#controlsExpanded = false;
/**
* Toggle display of the Application controls menu.
* Only applicable to window Applications.
* @param {boolean} [expanded] Set the controls visibility to a specific state.
* Otherwise, the visible state is toggled from its current value
*/
toggleControls(expanded) {
expanded ??= !this.#controlsExpanded;
if ( expanded === this.#controlsExpanded ) return;
const dropdown = this.#element.querySelector(".controls-dropdown")
dropdown.classList.toggle("expanded", expanded);
this.#controlsExpanded = expanded;
game.tooltip.deactivate();
}
/* -------------------------------------------- */
/**
* Minimize the Application, collapsing it to a minimal header.
* @returns {Promise<void>}
*/
async minimize() {
if ( this.minimized || !this.hasFrame || !this.options.window.minimizable ) return;
this.#minimization.active = true;
// Set explicit dimensions for the transition.
const { width, height } = this.#element.getBoundingClientRect();
this.#applyPosition({ ...this.#position, width, height });
// Record pre-minimization data
this.#minimization.priorWidth = this.#position.width;
this.#minimization.priorHeight = this.#position.height;
this.#minimization.priorBoundingWidth = width;
this.#minimization.priorBoundingHeight = height;
// Animate to collapsed size
this.#element.classList.add("minimizing");
this.#element.style.maxWidth = "var(--minimized-width)";
this.#element.style.maxHeight = "var(--header-height)";
await this._awaitTransition(this.#element, 1000);
this.#element.classList.add("minimized");
this.#element.classList.remove("minimizing");
}
/* -------------------------------------------- */
/**
* Restore the Application to its original dimensions.
* @returns {Promise<void>}
*/
async maximize() {
if ( !this.minimized ) return;
this.#minimization.active = false;
// Animate back to full size
const { priorBoundingWidth: width, priorBoundingHeight: height } = this.#minimization;
this.#element.classList.remove("minimized");
this.#element.classList.add("maximizing");
this.#element.style.maxWidth = "";
this.#element.style.maxHeight = "";
this.#applyPosition({ ...this.#position, width, height });
await this._awaitTransition(this.#element, 1000);
this.#element.classList.remove("maximizing");
// Restore the application position
this._updatePosition(Object.assign(this.#position, {
width: this.#minimization.priorWidth,
height: this.#minimization.priorHeight
}));
}
/* -------------------------------------------- */
/**
* Bring this Application window to the front of the rendering stack by increasing its z-index.
* Once ApplicationV1 is deprecated we should switch from _maxZ to ApplicationV2#maxZ
* We should also eliminate ui.activeWindow in favor of only ApplicationV2#frontApp
*/
bringToFront() {
if ( !((ApplicationV2.#frontApp === this) && (ui.activeWindow === this)) ) this.#position.zIndex = ++_maxZ;
this.#element.style.zIndex = String(this.#position.zIndex);
ApplicationV2.#frontApp = this;
ui.activeWindow = this; // ApplicationV1 compatibility
}
/* -------------------------------------------- */
/**
* Change the active tab within a tab group in this Application instance.
* @param {string} tab The name of the tab which should become active
* @param {string} group The name of the tab group which defines the set of tabs
* @param {object} [options] Additional options which affect tab navigation
* @param {Event} [options.event] An interaction event which caused the tab change, if any
* @param {HTMLElement} [options.navElement] An explicit navigation element being modified
* @param {boolean} [options.force=false] Force changing the tab even if the new tab is already active
* @param {boolean} [options.updatePosition=true] Update application position after changing the tab?
*/
changeTab(tab, group, {event, navElement, force=false, updatePosition=true}={}) {
if ( !tab || !group ) throw new Error("You must pass both the tab and tab group identifier");
if ( (this.tabGroups[group] === tab) && !force ) return; // No change necessary
const tabElement = this.#content.querySelector(`.tabs > [data-group="${group}"][data-tab="${tab}"]`);
if ( !tabElement ) throw new Error(`No matching tab element found for group "${group}" and tab "${tab}"`);
// Update tab navigation
for ( const t of this.#content.querySelectorAll(`.tabs > [data-group="${group}"]`) ) {
t.classList.toggle("active", t.dataset.tab === tab);
}
// Update tab contents
for ( const section of this.#content.querySelectorAll(`.tab[data-group="${group}"]`) ) {
section.classList.toggle("active", section.dataset.tab === tab);
}
this.tabGroups[group] = tab;
// Update automatic width or height
if ( !updatePosition ) return;
const positionUpdate = {};
if ( this.options.position.width === "auto" ) positionUpdate.width = "auto";
if ( this.options.position.height === "auto" ) positionUpdate.height = "auto";
if ( !foundry.utils.isEmpty(positionUpdate) ) this.setPosition(positionUpdate);
}
/* -------------------------------------------- */
/* Life-Cycle Handlers */
/* -------------------------------------------- */
/**
* Perform an event in the application life-cycle.
* Await an internal life-cycle method defined by the class.
* Optionally dispatch an event for any registered listeners.
* @param {Function} handler A handler function to call
* @param {object} options Options which configure event handling
* @param {boolean} [options.async] Await the result of the handler function?
* @param {any[]} [options.handlerArgs] Arguments passed to the handler function
* @param {string} [options.debugText] Debugging text to log for the event
* @param {string} [options.eventName] An event name to dispatch for registered listeners
* @param {string} [options.hookName] A hook name to dispatch for this and all parent classes
* @param {any[]} [options.hookArgs] Arguments passed to the requested hook function
* @returns {Promise<void>} A promise which resoles once the handler is complete
*/
async #doEvent(handler, {async=false, handlerArgs, debugText, eventName, hookName, hookArgs=[]}={}) {
// Debug logging
if ( debugText && CONFIG.debug.applications ) {
console.debug(`${this.constructor.name} | ${debugText}`);
}
// Call handler function
const response = handler.call(this, ...handlerArgs);
if ( async ) await response;
// Dispatch event for this Application instance
if ( eventName ) this.dispatchEvent(new Event(eventName, { bubbles: true, cancelable: true }));
// Call hooks for this Application class
if ( hookName ) {
for ( const cls of this.constructor.inheritanceChain() ) {
if ( !cls.name ) continue;
Hooks.callAll(`${hookName}${cls.name}`, this, ...hookArgs);
}
}
return response;
}
/* -------------------------------------------- */
/* Rendering Life-Cycle Methods */
/* -------------------------------------------- */
/**
* Test whether this Application is allowed to be rendered.
* @param {RenderOptions} options Provided render options
* @returns {false|void} Return false to prevent rendering
* @throws {Error} An Error to display a warning message
* @protected
*/
_canRender(options) {}
/**
* Actions performed before a first render of the Application.
* @param {ApplicationRenderContext} context Prepared context data
* @param {RenderOptions} options Provided render options
* @returns {Promise<void>}
* @protected
*/
async _preFirstRender(context, options) {}
/**
* Actions performed after a first render of the Application.
* Post-render steps are not awaited by the render process.
* @param {ApplicationRenderContext} context Prepared context data
* @param {RenderOptions} options Provided render options
* @protected
*/
_onFirstRender(context, options) {}
/**
* Actions performed before any render of the Application.
* Pre-render steps are awaited by the render process.
* @param {ApplicationRenderContext} context Prepared context data
* @param {RenderOptions} options Provided render options
* @returns {Promise<void>}
* @protected
*/
async _preRender(context, options) {}
/**
* Actions performed after any render of the Application.
* Post-render steps are not awaited by the render process.
* @param {ApplicationRenderContext} context Prepared context data
* @param {RenderOptions} options Provided render options
* @protected
*/
_onRender(context, options) {}
/**
* Actions performed before closing the Application.
* Pre-close steps are awaited by the close process.
* @param {RenderOptions} options Provided render options
* @returns {Promise<void>}
* @protected
*/
async _preClose(options) {}
/**
* Actions performed after closing the Application.
* Post-close steps are not awaited by the close process.
* @param {RenderOptions} options Provided render options
* @protected
*/
_onClose(options) {}
/**
* Actions performed before the Application is re-positioned.
* Pre-position steps are not awaited because setPosition is synchronous.
* @param {ApplicationPosition} position The requested application position
* @protected
*/
_prePosition(position) {}
/**
* Actions performed after the Application is re-positioned.
* @param {ApplicationPosition} position The requested application position
* @protected
*/
_onPosition(position) {}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Attach event listeners to the Application frame.
* @protected
*/
_attachFrameListeners() {
// Application Click Events
this.#element.addEventListener("pointerdown", this.#onPointerDown.bind(this), {capture: true});
const click = this.#onClick.bind(this);
this.#element.addEventListener("click", click);
this.#element.addEventListener("contextmenu", click);
if ( this.hasFrame ) {
this.bringToFront();
this.#window.header.addEventListener("pointerdown", this.#onWindowDragStart.bind(this));
this.#window.header.addEventListener("dblclick", this.#onWindowDoubleClick.bind(this));
this.#window.resize?.addEventListener("pointerdown", this.#onWindowResizeStart.bind(this));
}
// Form handlers
if ( this.options.tag === "form" ) {
this.#element.addEventListener("submit", this._onSubmitForm.bind(this, this.options.form));
this.#element.addEventListener("change", this._onChangeForm.bind(this, this.options.form));
}
}
/* -------------------------------------------- */
/**
* Handle initial pointerdown events inside a rendered Application.
* @param {PointerEvent} event
*/
async #onPointerDown(event) {
if ( this.hasFrame ) this.bringToFront();
}
/* -------------------------------------------- */
/**
* Centralized handling of click events which occur on or within the Application frame.
* @param {PointerEvent} event
*/
async #onClick(event) {
const target = event.target;
const actionButton = target.closest("[data-action]");
if ( actionButton ) return this.#onClickAction(event, actionButton);
}
/* -------------------------------------------- */
/**
* Handle a click event on an element which defines a [data-action] handler.
* @param {PointerEvent} event The originating click event
* @param {HTMLElement} target The capturing HTML element which defined a [data-action]
*/
#onClickAction(event, target) {
const action = target.dataset.action;
switch ( action ) {
case "close":
event.stopPropagation();
if ( event.button === 0 ) this.close();
break;
case "tab":
if ( event.button === 0 ) this.#onClickTab(event);
break;
case "toggleControls":
event.stopPropagation();
if ( event.button === 0 ) this.toggleControls();
break;
default:
let handler = this.options.actions[action];
// No defined handler
if ( !handler ) {
this._onClickAction(event, target);
break;
}
// Defined handler
let buttons = [0];
if ( typeof handler === "object" ) {
buttons = handler.buttons;
handler = handler.handler;
}
if ( buttons.includes(event.button) ) handler?.call(this, event, target);
break;
}
}
/* -------------------------------------------- */
/**
* Handle click events on a tab within the Application.
* @param {PointerEvent} event
*/
#onClickTab(event) {
const button = event.target;
const tab = button.dataset.tab;
if ( !tab || button.classList.contains("active") ) return;
const group = button.dataset.group;
const navElement = button.closest(".tabs");
this.changeTab(tab, group, {event, navElement});
}
/* -------------------------------------------- */
/**
* A generic event handler for action clicks which can be extended by subclasses.
* Action handlers defined in DEFAULT_OPTIONS are called first. This method is only called for actions which have
* no defined handler.
* @param {PointerEvent} event The originating click event
* @param {HTMLElement} target The capturing HTML element which defined a [data-action]
* @protected
*/
_onClickAction(event, target) {}
/* -------------------------------------------- */
/**
* Begin capturing pointer events on the application frame.
* @param {PointerEvent} event The triggering event.
* @param {function} callback The callback to attach to pointer move events.
*/
#startPointerCapture(event, callback) {
this.#window.pointerStartPosition = Object.assign(foundry.utils.deepClone(this.#position), {
clientX: event.clientX, clientY: event.clientY
});
this.#element.addEventListener("pointermove", callback, { passive: true });
this.#element.addEventListener("pointerup", event => this.#endPointerCapture(event, callback), {
capture: true, once: true
});
}
/* -------------------------------------------- */
/**
* End capturing pointer events on the application frame.
* @param {PointerEvent} event The triggering event.
* @param {function} callback The callback to remove from pointer move events.
*/
#endPointerCapture(event, callback) {
this.#element.releasePointerCapture(event.pointerId);
this.#element.removeEventListener("pointermove", callback);
delete this.#window.pointerStartPosition;
this.#window.pointerMoveThrottle = false;
}
/* -------------------------------------------- */
/**
* Handle a pointer move event while dragging or resizing the window frame.
* @param {PointerEvent} event
* @returns {{dx: number, dy: number}|void} The amount the cursor has moved since the last frame, or undefined if
* the movement occurred between frames.
*/
#onPointerMove(event) {
if ( this.#window.pointerMoveThrottle ) return;
this.#window.pointerMoveThrottle = true;
const dx = event.clientX - this.#window.pointerStartPosition.clientX;
const dy = event.clientY - this.#window.pointerStartPosition.clientY;
requestAnimationFrame(() => this.#window.pointerMoveThrottle = false);
return { dx, dy };
}
/* -------------------------------------------- */
/**
* Begin dragging the Application position.
* @param {PointerEvent} event
*/
#onWindowDragStart(event) {
if ( event.target.closest(".header-control") ) return;
this.#endPointerCapture(event, this.#window.onDrag);
this.#startPointerCapture(event, this.#window.onDrag);
}
/* -------------------------------------------- */
/**
* Begin resizing the Application.
* @param {PointerEvent} event
*/
#onWindowResizeStart(event) {
this.#endPointerCapture(event, this.#window.onResize);
this.#startPointerCapture(event, this.#window.onResize);
}
/* -------------------------------------------- */
/**
* Drag the Application position during mouse movement.
* @param {PointerEvent} event
*/
#onWindowDragMove(event) {
if ( !this.#window.header.hasPointerCapture(event.pointerId) ) {
this.#window.header.setPointerCapture(event.pointerId);
}
const delta = this.#onPointerMove(event);
if ( !delta ) return;
const { pointerStartPosition } = this.#window;
let { top, left, height, width } = pointerStartPosition;
left += delta.dx;
top += delta.dy;
this.setPosition({ top, left, height, width });
}
/* -------------------------------------------- */
/**
* Resize the Application during mouse movement.
* @param {PointerEvent} event
*/
#onWindowResizeMove(event) {
if ( !this.#window.resize.hasPointerCapture(event.pointerId) ) {
this.#window.resize.setPointerCapture(event.pointerId);
}
const delta = this.#onPointerMove(event);
if ( !delta ) return;
const { scale } = this.#position;
const { pointerStartPosition } = this.#window;
let { top, left, height, width } = pointerStartPosition;
if ( width !== "auto" ) width += delta.dx / scale;
if ( height !== "auto" ) height += delta.dy / scale;
this.setPosition({ top, left, width, height });
}
/* -------------------------------------------- */
/**
* Double-click events on the window title are used to minimize or maximize the application.
* @param {PointerEvent} event
*/
#onWindowDoubleClick(event) {
event.preventDefault();
if ( event.target.dataset.action ) return; // Ignore double clicks on buttons which perform an action
if ( !this.options.window.minimizable ) return;
if ( this.minimized ) this.maximize();
else this.minimize();
}
/* -------------------------------------------- */
/**
* Handle submission for an Application which uses the form element.
* @param {ApplicationFormConfiguration} formConfig The form configuration for which this handler is bound
* @param {Event|SubmitEvent} event The form submission event
* @returns {Promise<void>}
* @protected
*/
async _onSubmitForm(formConfig, event) {
event.preventDefault();
const form = event.currentTarget;
const {handler, closeOnSubmit} = formConfig;
const formData = new FormDataExtended(form);
if ( handler instanceof Function ) {
try {
await handler.call(this, event, form, formData);
} catch(err){
ui.notifications.error(err, {console: true});
return; // Do not close
}
}
if ( closeOnSubmit ) await this.close();
}
/* -------------------------------------------- */
/**
* Handle changes to an input element within the form.
* @param {ApplicationFormConfiguration} formConfig The form configuration for which this handler is bound
* @param {Event} event An input change event within the form
*/
_onChangeForm(formConfig, event) {
if ( formConfig.submitOnChange ) this._onSubmitForm(formConfig, event);
}
/* -------------------------------------------- */
/* Helper Methods */
/* -------------------------------------------- */
/**
* Parse a CSS style rule into a number of pixels which apply to that dimension.
* @param {string} style The CSS style rule
* @param {number} parentDimension The relevant dimension of the parent element
* @returns {number} The parsed style dimension in pixels
*/
static parseCSSDimension(style, parentDimension) {
if ( style.includes("px") ) return parseInt(style.replace("px", ""));
if ( style.includes("%") ) {
const p = parseInt(style.replace("%", "")) / 100;
return parentDimension * p;
}
}
/* -------------------------------------------- */
/**
* Wait for a CSS transition to complete for an element.
* @param {HTMLElement} element The element which is transitioning
* @param {number} timeout A timeout in milliseconds in case the transitionend event does not occur
* @returns {Promise<void>}
* @internal
*/
async _awaitTransition(element, timeout) {
return Promise.race([
new Promise(resolve => element.addEventListener("transitionend", resolve, {once: true})),
new Promise(resolve => window.setTimeout(resolve, timeout))
]);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
bringToTop() {
foundry.utils.logCompatibilityWarning(`ApplicationV2#bringToTop is not a valid function and redirects to
ApplicationV2#bringToFront. This shim will be removed in v14.`, {since: 12, until: 14});
return this.bringToFront();
}
}