/** * 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} */ 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} 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} 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} 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} 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} 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} 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} 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(); }; } }); } }