Files
2025-01-04 00:34:03 +01:00

997 lines
34 KiB
JavaScript

/**
* A namespace containing the user interface applications which are defined throughout the Foundry VTT ecosystem.
* @namespace applications
*/
let _appId = globalThis._appId = 0;
let _maxZ = Number(getComputedStyle(document.body).getPropertyValue("--z-index-window") ?? 100);
const MIN_WINDOW_WIDTH = 200;
const MIN_WINDOW_HEIGHT = 50;
/**
* @typedef {object} ApplicationOptions
* @property {string|null} [baseApplication] A named "base application" which generates an additional hook
* @property {number|null} [width] The default pixel width for the rendered HTML
* @property {number|string|null} [height] The default pixel height for the rendered HTML
* @property {number|null} [top] The default offset-top position for the rendered HTML
* @property {number|null} [left] The default offset-left position for the rendered HTML
* @property {number|null} [scale] A transformation scale for the rendered HTML
* @property {boolean} [popOut] Whether to display the application as a pop-out container
* @property {boolean} [minimizable] Whether the rendered application can be minimized (popOut only)
* @property {boolean} [resizable] Whether the rendered application can be drag-resized (popOut only)
* @property {string} [id] The default CSS id to assign to the rendered HTML
* @property {string[]} [classes] An array of CSS string classes to apply to the rendered HTML
* @property {string} [title] A default window title string (popOut only)
* @property {string|null} [template] The default HTML template path to render for this Application
* @property {string[]} [scrollY] A list of unique CSS selectors which target containers that should have their
* vertical scroll positions preserved during a re-render.
* @property {TabsConfiguration[]} [tabs] An array of tabbed container configurations which should be enabled for the
* application.
* @property {DragDropConfiguration[]} dragDrop An array of CSS selectors for configuring the application's
* {@link DragDrop} behaviour.
* @property {SearchFilterConfiguration[]} filters An array of {@link SearchFilter} configuration objects.
*/
/**
* The standard application window that is rendered for a large variety of UI elements in Foundry VTT.
* @abstract
* @param {ApplicationOptions} [options] Configuration options which control how the application is rendered.
* Application subclasses may add additional supported options, but these base
* configurations are supported for all Applications. The values passed to the
* constructor are combined with the defaultOptions defined at the class level.
*/
class Application {
constructor(options={}) {
/**
* The options provided to this application upon initialization
* @type {object}
*/
this.options = foundry.utils.mergeObject(this.constructor.defaultOptions, options, {
insertKeys: true,
insertValues: true,
overwrite: true,
inplace: false
});
/**
* An internal reference to the HTML element this application renders
* @type {jQuery}
*/
this._element = null;
/**
* Track the current position and dimensions of the Application UI
* @type {object}
*/
this.position = {
width: this.options.width,
height: this.options.height,
left: this.options.left,
top: this.options.top,
scale: this.options.scale,
zIndex: 0
};
/**
* DragDrop workflow handlers which are active for this Application
* @type {DragDrop[]}
*/
this._dragDrop = this._createDragDropHandlers();
/**
* Tab navigation handlers which are active for this Application
* @type {Tabs[]}
*/
this._tabs = this._createTabHandlers();
/**
* SearchFilter handlers which are active for this Application
* @type {SearchFilter[]}
*/
this._searchFilters = this._createSearchFilters();
/**
* Track whether the Application is currently minimized
* @type {boolean|null}
*/
this._minimized = false;
/**
* The current render state of the Application
* @see {Application.RENDER_STATES}
* @type {number}
* @protected
*/
this._state = Application.RENDER_STATES.NONE;
/**
* The prior render state of this Application.
* This allows for rendering logic to understand if the application is being rendered for the first time.
* @see {Application.RENDER_STATES}
* @type {number}
* @protected
*/
this._priorState = this._state;
/**
* Track the most recent scroll positions for any vertically scrolling containers
* @type {object | null}
*/
this._scrollPositions = null;
}
/**
* The application ID is a unique incrementing integer which is used to identify every application window
* drawn by the VTT
* @type {number}
*/
appId;
/**
* The sequence of rendering states that track the Application life-cycle.
* @enum {number}
*/
static RENDER_STATES = Object.freeze({
ERROR: -3,
CLOSING: -2,
CLOSED: -1,
NONE: 0,
RENDERING: 1,
RENDERED: 2
});
/* -------------------------------------------- */
/**
* Create drag-and-drop workflow handlers for this Application
* @returns {DragDrop[]} An array of DragDrop handlers
* @private
*/
_createDragDropHandlers() {
return this.options.dragDrop.map(d => {
d.permissions = {
dragstart: this._canDragStart.bind(this),
drop: this._canDragDrop.bind(this)
};
d.callbacks = {
dragstart: this._onDragStart.bind(this),
dragover: this._onDragOver.bind(this),
drop: this._onDrop.bind(this)
};
return new DragDrop(d);
});
}
/* -------------------------------------------- */
/**
* Create tabbed navigation handlers for this Application
* @returns {Tabs[]} An array of Tabs handlers
* @private
*/
_createTabHandlers() {
return this.options.tabs.map(t => {
t.callback = this._onChangeTab.bind(this);
return new Tabs(t);
});
}
/* -------------------------------------------- */
/**
* Create search filter handlers for this Application
* @returns {SearchFilter[]} An array of SearchFilter handlers
* @private
*/
_createSearchFilters() {
return this.options.filters.map(f => {
f.callback = this._onSearchFilter.bind(this);
return new SearchFilter(f);
});
}
/* -------------------------------------------- */
/**
* Assign the default options configuration which is used by this Application class. The options and values defined
* in this object are merged with any provided option values which are passed to the constructor upon initialization.
* Application subclasses may include additional options which are specific to their usage.
* @returns {ApplicationOptions}
*/
static get defaultOptions() {
return {
baseApplication: null,
width: null,
height: null,
top: null,
left: null,
scale: null,
popOut: true,
minimizable: true,
resizable: false,
id: "",
classes: [],
dragDrop: [],
tabs: [],
filters: [],
title: "",
template: null,
scrollY: []
};
}
/* -------------------------------------------- */
/**
* Return the CSS application ID which uniquely references this UI element
* @type {string}
*/
get id() {
return this.options.id ? this.options.id : `app-${this.appId}`;
}
/* -------------------------------------------- */
/**
* Return the active application element, if it currently exists in the DOM
* @type {jQuery}
*/
get element() {
if ( this._element ) return this._element;
let selector = `#${this.id}`;
return $(selector);
}
/* -------------------------------------------- */
/**
* The path to the HTML template file which should be used to render the inner content of the app
* @type {string}
*/
get template() {
return this.options.template;
}
/* -------------------------------------------- */
/**
* Control the rendering style of the application. If popOut is true, the application is rendered in its own
* wrapper window, otherwise only the inner app content is rendered
* @type {boolean}
*/
get popOut() {
return this.options.popOut ?? true;
}
/* -------------------------------------------- */
/**
* Return a flag for whether the Application instance is currently rendered
* @type {boolean}
*/
get rendered() {
return this._state === Application.RENDER_STATES.RENDERED;
}
/* -------------------------------------------- */
/**
* Whether the Application is currently closing.
* @type {boolean}
*/
get closing() {
return this._state === Application.RENDER_STATES.CLOSING;
}
/* -------------------------------------------- */
/**
* An Application window should define its own title definition logic which may be dynamic depending on its data
* @type {string}
*/
get title() {
return game.i18n.localize(this.options.title);
}
/* -------------------------------------------- */
/* Application rendering
/* -------------------------------------------- */
/**
* An application should define the data object used to render its template.
* This function may either return an Object directly, or a Promise which resolves to an Object
* If undefined, the default implementation will return an empty object allowing only for rendering of static HTML
* @param {object} options
* @returns {object|Promise<object>}
*/
getData(options={}) {
return {};
}
/* -------------------------------------------- */
/**
* Render the Application by evaluating it's HTML template against the object of data provided by the getData method
* If the Application is rendered as a pop-out window, wrap the contained HTML in an outer frame with window controls
*
* @param {boolean} force Add the rendered application to the DOM if it is not already present. If false, the
* Application will only be re-rendered if it is already present.
* @param {object} options Additional rendering options which are applied to customize the way that the Application
* is rendered in the DOM.
*
* @param {number} [options.left] The left positioning attribute
* @param {number} [options.top] The top positioning attribute
* @param {number} [options.width] The rendered width
* @param {number} [options.height] The rendered height
* @param {number} [options.scale] The rendered transformation scale
* @param {boolean} [options.focus=false] Apply focus to the application, maximizing it and bringing it to the top
* of the vertical stack.
* @param {string} [options.renderContext] A context-providing string which suggests what event triggered the render
* @param {object} [options.renderData] The data change which motivated the render request
*
* @returns {Application} The rendered Application instance
*
*/
render(force=false, options={}) {
this._render(force, options).catch(err => {
this._state = Application.RENDER_STATES.ERROR;
Hooks.onError("Application#render", err, {
msg: `An error occurred while rendering ${this.constructor.name} ${this.appId}`,
log: "error",
...options
});
});
return this;
}
/* -------------------------------------------- */
/**
* An asynchronous inner function which handles the rendering of the Application
* @fires renderApplication
* @param {boolean} force Render and display the application even if it is not currently displayed.
* @param {object} options Additional options which update the current values of the Application#options object
* @returns {Promise<void>} A Promise that resolves to the Application once rendering is complete
* @protected
*/
async _render(force=false, options={}) {
// Do not render under certain conditions
const states = Application.RENDER_STATES;
this._priorState = this._state;
if ( [states.CLOSING, states.RENDERING].includes(this._state) ) return;
// Applications which are not currently rendered must be forced
if ( !force && (this._state <= states.NONE) ) return;
// Begin rendering the application
if ( [states.NONE, states.CLOSED, states.ERROR].includes(this._state) ) {
console.log(`${vtt} | Rendering ${this.constructor.name}`);
}
this._state = states.RENDERING;
// Merge provided options with those supported by the Application class
foundry.utils.mergeObject(this.options, options, { insertKeys: false });
options.focus ??= force;
// Get the existing HTML element and application data used for rendering
const element = this.element;
this.appId = element.data("appid") ?? ++_appId;
if ( this.popOut ) ui.windows[this.appId] = this;
const data = await this.getData(this.options);
// Store scroll positions
if ( element.length && this.options.scrollY ) this._saveScrollPositions(element);
// Render the inner content
const inner = await this._renderInner(data);
let html = inner;
// If the application already exists in the DOM, replace the inner content
if ( element.length ) this._replaceHTML(element, html);
// Otherwise render a new app
else {
// Wrap a popOut application in an outer frame
if ( this.popOut ) {
html = await this._renderOuter();
html.find(".window-content").append(inner);
}
// Add the HTML to the DOM and record the element
this._injectHTML(html);
}
if ( !this.popOut && this.options.resizable ) new Draggable(this, html, false, this.options.resizable);
// Activate event listeners on the inner HTML
this._activateCoreListeners(inner);
this.activateListeners(inner);
// Set the application position (if it's not currently minimized)
if ( !this._minimized ) {
foundry.utils.mergeObject(this.position, options, {insertKeys: false});
this.setPosition(this.position);
}
// Apply focus to the application, maximizing it and bringing it to the top
if ( this.popOut && (options.focus === true) ) this.maximize().then(() => this.bringToTop());
// Dispatch Hooks for rendering the base and subclass applications
this._callHooks("render", html, data);
// Restore prior scroll positions
if ( this.options.scrollY ) this._restoreScrollPositions(html);
this._state = states.RENDERED;
}
/* -------------------------------------------- */
/**
* Return the inheritance chain for this Application class up to (and including) it's base Application class.
* @returns {Function[]}
* @private
*/
static _getInheritanceChain() {
const parents = foundry.utils.getParentClasses(this);
const base = this.defaultOptions.baseApplication;
const chain = [this];
for ( let cls of parents ) {
chain.push(cls);
if ( cls.name === base ) break;
}
return chain;
}
/* -------------------------------------------- */
/**
* Call all hooks for all applications in the inheritance chain.
* @param {string | (className: string) => string} hookName The hook being triggered, which formatted
* with the Application class name
* @param {...*} hookArgs The arguments passed to the hook calls
* @protected
* @internal
*/
_callHooks(hookName, ...hookArgs) {
const formatHook = typeof hookName === "string" ? className => `${hookName}${className}` : hookName;
for ( const cls of this.constructor._getInheritanceChain() ) {
if ( !cls.name ) continue;
Hooks.callAll(formatHook(cls.name), this, ...hookArgs);
}
}
/* -------------------------------------------- */
/**
* Persist the scroll positions of containers within the app before re-rendering the content
* @param {jQuery} html The HTML object being traversed
* @protected
*/
_saveScrollPositions(html) {
const selectors = this.options.scrollY || [];
this._scrollPositions = selectors.reduce((pos, sel) => {
const el = html.find(sel);
pos[sel] = Array.from(el).map(el => el.scrollTop);
return pos;
}, {});
}
/* -------------------------------------------- */
/**
* Restore the scroll positions of containers within the app after re-rendering the content
* @param {jQuery} html The HTML object being traversed
* @protected
*/
_restoreScrollPositions(html) {
const selectors = this.options.scrollY || [];
const positions = this._scrollPositions || {};
for ( let sel of selectors ) {
const el = html.find(sel);
el.each((i, el) => el.scrollTop = positions[sel]?.[i] || 0);
}
}
/* -------------------------------------------- */
/**
* Render the outer application wrapper
* @returns {Promise<jQuery>} A promise resolving to the constructed jQuery object
* @protected
*/
async _renderOuter() {
// Gather basic application data
const classes = this.options.classes;
const windowData = {
id: this.id,
classes: classes.join(" "),
appId: this.appId,
title: this.title,
headerButtons: this._getHeaderButtons()
};
// Render the template and return the promise
let html = await renderTemplate("templates/app-window.html", windowData);
html = $(html);
// Activate header button click listeners after a slight timeout to prevent immediate interaction
setTimeout(() => {
html.find(".header-button").click(event => {
event.preventDefault();
const button = windowData.headerButtons.find(b => event.currentTarget.classList.contains(b.class));
button.onclick(event);
});
}, 500);
// Make the outer window draggable
const header = html.find("header")[0];
new Draggable(this, html, header, this.options.resizable);
// Make the outer window minimizable
if ( this.options.minimizable ) {
header.addEventListener("dblclick", this._onToggleMinimize.bind(this));
}
// Set the outer frame z-index
this.position.zIndex = Math.min(++_maxZ, 99999);
html[0].style.zIndex = this.position.zIndex;
ui.activeWindow = this;
// Return the outer frame
return html;
}
/* -------------------------------------------- */
/**
* Render the inner application content
* @param {object} data The data used to render the inner template
* @returns {Promise<jQuery>} A promise resolving to the constructed jQuery object
* @private
*/
async _renderInner(data) {
let html = await renderTemplate(this.template, data);
if ( html === "" ) throw new Error(`No data was returned from template ${this.template}`);
return $(html);
}
/* -------------------------------------------- */
/**
* Customize how inner HTML is replaced when the application is refreshed
* @param {jQuery} element The original HTML processed as a jQuery object
* @param {jQuery} html New updated HTML as a jQuery object
* @private
*/
_replaceHTML(element, html) {
if ( !element.length ) return;
// For pop-out windows update the inner content and the window title
if ( this.popOut ) {
element.find(".window-content").html(html);
let t = element.find(".window-title")[0];
if ( t.hasChildNodes() ) t = t.childNodes[0];
t.textContent = this.title;
}
// For regular applications, replace the whole thing
else {
element.replaceWith(html);
this._element = html;
}
}
/* -------------------------------------------- */
/**
* Customize how a new HTML Application is added and first appears in the DOM
* @param {jQuery} html The HTML element which is ready to be added to the DOM
* @private
*/
_injectHTML(html) {
$("body").append(html);
this._element = html;
html.hide().fadeIn(200);
}
/* -------------------------------------------- */
/**
* Specify the set of config buttons which should appear in the Application header.
* Buttons should be returned as an Array of objects.
* The header buttons which are added to the application can be modified by the getApplicationHeaderButtons hook.
* @fires getApplicationHeaderButtons
* @returns {ApplicationHeaderButton[]}
* @protected
*/
_getHeaderButtons() {
const buttons = [
{
label: "Close",
class: "close",
icon: "fas fa-times",
onclick: () => this.close()
}
];
this._callHooks(className => `get${className}HeaderButtons`, buttons);
return buttons;
}
/* -------------------------------------------- */
/**
* Create a {@link ContextMenu} for this Application.
* @param {jQuery} html The Application's HTML.
* @private
*/
_contextMenu(html) {}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/**
* Activate required listeners which must be enabled on every Application.
* These are internal interactions which should not be overridden by downstream subclasses.
* @param {jQuery} html
* @protected
*/
_activateCoreListeners(html) {
const content = this.popOut ? html[0].parentElement : html[0];
this._tabs.forEach(t => t.bind(content));
this._dragDrop.forEach(d => d.bind(content));
this._searchFilters.forEach(f => f.bind(content));
}
/* -------------------------------------------- */
/**
* After rendering, activate event listeners which provide interactivity for the Application.
* This is where user-defined Application subclasses should attach their event-handling logic.
* @param {JQuery} html
*/
activateListeners(html) {}
/* -------------------------------------------- */
/**
* Change the currently active tab
* @param {string} tabName The target tab name to switch to
* @param {object} options Options which configure changing the tab
* @param {string} options.group A specific named tab group, useful if multiple sets of tabs are present
* @param {boolean} options.triggerCallback Whether to trigger tab-change callback functions
*/
activateTab(tabName, {group, triggerCallback=true}={}) {
if ( !this._tabs.length ) throw new Error(`${this.constructor.name} does not define any tabs`);
const tabs = group ? this._tabs.find(t => t.group === group) : this._tabs[0];
if ( !tabs ) throw new Error(`Tab group "${group}" not found in ${this.constructor.name}`);
tabs.activate(tabName, {triggerCallback});
}
/* -------------------------------------------- */
/**
* Handle changes to the active tab in a configured Tabs controller
* @param {MouseEvent|null} event A left click event
* @param {Tabs} tabs The Tabs controller
* @param {string} active The new active tab name
* @protected
*/
_onChangeTab(event, tabs, active) {
this.setPosition();
}
/* -------------------------------------------- */
/**
* Handle changes to search filtering controllers which are bound to the Application
* @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
* @protected
*/
_onSearchFilter(event, query, rgx, html) {}
/* -------------------------------------------- */
/**
* Define whether a user is able to begin a dragstart workflow for a given drag selector
* @param {string} selector The candidate HTML selector for dragging
* @returns {boolean} Can the current user drag this selector?
* @protected
*/
_canDragStart(selector) {
return game.user.isGM;
}
/* -------------------------------------------- */
/**
* Define whether a user is able to conclude a drag-and-drop workflow for a given drop selector
* @param {string} selector The candidate HTML selector for the drop target
* @returns {boolean} Can the current user drop on this selector?
* @protected
*/
_canDragDrop(selector) {
return game.user.isGM;
}
/* -------------------------------------------- */
/**
* Callback actions which occur at the beginning of a drag start workflow.
* @param {DragEvent} event The originating DragEvent
* @protected
*/
_onDragStart(event) {}
/* -------------------------------------------- */
/**
* Callback actions which occur when a dragged element is over a drop target.
* @param {DragEvent} event The originating DragEvent
* @protected
*/
_onDragOver(event) {}
/* -------------------------------------------- */
/**
* Callback actions which occur when a dragged element is dropped on a target.
* @param {DragEvent} event The originating DragEvent
* @protected
*/
_onDrop(event) {}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Bring the application to the top of the rendering stack
*/
bringToTop() {
if ( ui.activeWindow === this ) return;
const element = this.element[0];
const z = document.defaultView.getComputedStyle(element).zIndex;
if ( z < _maxZ ) {
this.position.zIndex = Math.min(++_maxZ, 99999);
element.style.zIndex = this.position.zIndex;
ui.activeWindow = this;
}
}
/* -------------------------------------------- */
/**
* Close the application and un-register references to it within UI mappings
* This function returns a Promise which resolves once the window closing animation concludes
* @fires closeApplication
* @param {object} [options={}] Options which affect how the Application is closed
* @returns {Promise<void>} A Promise which resolves once the application is closed
*/
async close(options={}) {
const states = Application.RENDER_STATES;
if ( !options.force && ![states.RENDERED, states.ERROR].includes(this._state) ) return;
this._state = states.CLOSING;
// Get the element
let el = this.element;
if ( !el ) return this._state = states.CLOSED;
el.css({minHeight: 0});
// Dispatch Hooks for closing the base and subclass applications
this._callHooks("close", el);
// Animate closing the element
return new Promise(resolve => {
el.slideUp(200, () => {
el.remove();
// Clean up data
this._element = null;
delete ui.windows[this.appId];
this._minimized = false;
this._scrollPositions = null;
this._state = states.CLOSED;
resolve();
});
});
}
/* -------------------------------------------- */
/**
* Minimize the pop-out window, collapsing it to a small tab
* Take no action for applications which are not of the pop-out variety or apps which are already minimized
* @returns {Promise<void>} A Promise which resolves once the minimization action has completed
*/
async minimize() {
if ( !this.rendered || !this.popOut || [true, null].includes(this._minimized) ) return;
this._minimized = null;
// Get content
const window = this.element;
const header = window.find(".window-header");
const content = window.find(".window-content");
this._saveScrollPositions(window);
// Remove minimum width and height styling rules
window.css({minWidth: 100, minHeight: 30});
// Slide-up content
content.slideUp(100);
// Slide up window height
return new Promise(resolve => {
window.animate({height: `${header[0].offsetHeight+1}px`}, 100, () => {
window.animate({width: MIN_WINDOW_WIDTH}, 100, () => {
window.addClass("minimized");
this._minimized = true;
resolve();
});
});
});
}
/* -------------------------------------------- */
/**
* Maximize the pop-out window, expanding it to its original size
* Take no action for applications which are not of the pop-out variety or are already maximized
* @returns {Promise<void>} A Promise which resolves once the maximization action has completed
*/
async maximize() {
if ( !this.popOut || [false, null].includes(this._minimized) ) return;
this._minimized = null;
// Get content
let window = this.element;
let content = window.find(".window-content");
// Expand window
return new Promise(resolve => {
window.animate({width: this.position.width, height: this.position.height}, 100, () => {
content.slideDown(100, () => {
window.removeClass("minimized");
this._minimized = false;
window.css({minWidth: "", minHeight: ""}); // Remove explicit dimensions
content.css({display: ""}); // Remove explicit "block" display
this.setPosition(this.position);
this._restoreScrollPositions(window);
resolve();
});
});
});
}
/* -------------------------------------------- */
/**
* Set the application position and store its new location.
* Returns the updated position object for the application containing the new values.
* @param {object} position Positional data
* @param {number|null} position.left The left offset position in pixels
* @param {number|null} position.top The top offset position in pixels
* @param {number|null} position.width The application width in pixels
* @param {number|string|null} position.height The application height in pixels
* @param {number|null} position.scale The application scale as a numeric factor where 1.0 is default
* @returns {{left: number, top: number, width: number, height: number, scale:number}|void}
*/
setPosition({left, top, width, height, scale}={}) {
if ( !this.popOut && !this.options.resizable ) return; // Only configure position for popout or resizable apps.
const el = this.element[0];
const currentPosition = this.position;
const pop = this.popOut;
const styles = window.getComputedStyle(el);
if ( scale === null ) scale = 1;
scale = scale ?? currentPosition.scale ?? 1;
// If Height is "auto" unset current preference
if ( (height === "auto") || (this.options.height === "auto") ) {
el.style.height = "";
height = null;
}
// Update width if an explicit value is passed, or if no width value is set on the element
if ( !el.style.width || width ) {
const tarW = width || el.offsetWidth;
const minW = parseInt(styles.minWidth) || (pop ? MIN_WINDOW_WIDTH : 0);
const maxW = el.style.maxWidth || (window.innerWidth / scale);
currentPosition.width = width = Math.clamp(tarW, minW, maxW);
el.style.width = `${width}px`;
if ( ((width * scale) + currentPosition.left) > window.innerWidth ) left = currentPosition.left;
}
width = el.offsetWidth;
// Update height if an explicit value is passed, or if no height value is set on the element
if ( !el.style.height || height ) {
const tarH = height || (el.offsetHeight + 1);
const minH = parseInt(styles.minHeight) || (pop ? MIN_WINDOW_HEIGHT : 0);
const maxH = el.style.maxHeight || (window.innerHeight / scale);
currentPosition.height = height = Math.clamp(tarH, minH, maxH);
el.style.height = `${height}px`;
if ( ((height * scale) + currentPosition.top) > window.innerHeight + 1 ) top = currentPosition.top - 1;
}
height = el.offsetHeight;
// Update Left
if ( (pop && !el.style.left) || Number.isFinite(left) ) {
const scaledWidth = width * scale;
const tarL = Number.isFinite(left) ? left : (window.innerWidth - scaledWidth) / 2;
const maxL = Math.max(window.innerWidth - scaledWidth, 0);
currentPosition.left = left = Math.clamp(tarL, 0, maxL);
el.style.left = `${left}px`;
}
// Update Top
if ( (pop && !el.style.top) || Number.isFinite(top) ) {
const scaledHeight = height * scale;
const tarT = Number.isFinite(top) ? top : (window.innerHeight - scaledHeight) / 2;
const maxT = Math.max(window.innerHeight - scaledHeight, 0);
currentPosition.top = Math.clamp(tarT, 0, maxT);
el.style.top = `${currentPosition.top}px`;
}
// Update Scale
if ( scale ) {
currentPosition.scale = Math.max(scale, 0);
if ( scale === 1 ) el.style.transform = "";
else el.style.transform = `scale(${scale})`;
}
// Return the updated position object
return currentPosition;
}
/* -------------------------------------------- */
/**
* Handle application minimization behavior - collapsing content and reducing the size of the header
* @param {Event} ev
* @private
*/
_onToggleMinimize(ev) {
ev.preventDefault();
if ( this._minimized ) this.maximize(ev);
else this.minimize(ev);
}
/* -------------------------------------------- */
/**
* Additional actions to take when the application window is resized
* @param {Event} event
* @private
*/
_onResize(event) {}
/* -------------------------------------------- */
/**
* Wait for any images present in the Application to load.
* @returns {Promise<void>} A Promise that resolves when all images have loaded.
* @protected
*/
_waitForImages() {
return new Promise(resolve => {
let loaded = 0;
const images = Array.from(this.element.find("img")).filter(img => !img.complete);
if ( !images.length ) resolve();
for ( const img of images ) {
img.onload = img.onerror = () => {
loaded++;
img.onload = img.onerror = null;
if ( loaded >= images.length ) resolve();
};
}
});
}
}