`
});
if ( !confirm ) {
form.disabled = false;
return;
}
}
// Submit the request
const postData = Object.assign(formData.object, {action: "shutdown"});
return this.#post(form, postData);
}
/* -------------------------------------------- */
/**
* @this {JoinGameForm}
* @param {SubmitEvent} event
* @param {HTMLFormElement} form
* @param {FormDataExtended} formData
*/
static async #onSubmitLoginForm(event, form, formData) {
event.preventDefault();
if ( !formData.get("userid") ) return ui.notifications.error("JOIN.ErrorMustSelectUser", {localize: true});
const postData = Object.assign(formData.object, {action: "join"});
return this.#post(form, postData);
}
/* -------------------------------------------- */
/**
* Submit join view POST requests to the server for handling.
* @param {HTMLFormElement} form The form being submitted
* @param {object} postData The processed form data
* @returns {Promise}
*/
async #post(form, postData) {
form.disabled = true;
const joinURL = foundry.utils.getRoute("join");
const user = game.users.get(postData.userid)?.name || postData.userid;
let response;
// Submit the request
try {
response = await foundry.utils.fetchJsonWithTimeout(joinURL, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(postData)
});
}
catch(e) {
if (e instanceof foundry.utils.HttpError) {
const error = game.i18n.format(e.displayMessage, {user});
ui.notifications.error(error);
}
else {
ui.notifications.error(e);
}
form.disabled = false;
return;
}
// Redirect on success
ui.notifications.info(game.i18n.format(response.message, {user}));
setTimeout(() => window.location.href = response.redirect, 500 );
}
}
/**
* A form application for managing core server configuration options.
* @see config.ApplicationConfiguration
*/
class SetupApplicationConfiguration extends FormApplication {
/**
* An ApplicationConfiguration instance which is used for validation and processing of form changes.
* @type {config.ApplicationConfiguration}
*/
config = new foundry.config.ApplicationConfiguration(this.object);
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "setup-configuration",
template: "templates/setup/app-configuration.hbs",
title: "SETUP.ConfigTitle",
popOut: true,
width: 720
});
}
/**
* Which CSS theme is currently being previewed
* @type {string}
*/
#previewTheme = this.config.cssTheme;
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const worlds = Array.from(game.worlds.values());
worlds.sort((a, b) => a.title.localeCompare(b.title, game.i18n.lang));
return {
noAdminPW: !game.data.options.adminPassword,
config: this.config.toObject(),
cssThemes: CONST.CSS_THEMES,
languages: this.#getLanguages(),
fields: this.config.schema.fields,
worlds: worlds,
};
}
/* -------------------------------------------- */
/**
* Get the set of languages which are choices for selection.
* @returns {FormSelectOption[]}
*/
#getLanguages() {
const options = [];
for ( const l of game.data.languages ) {
for ( const m of l.modules ) {
options.push({group: l.label, value: `${l.id}.${m.id}`, label: `${l.label} - ${m.label}`});
}
}
return options;
}
/* -------------------------------------------- */
/** @inheritDoc */
async close(options) {
this.#applyThemeChange(this.config.cssTheme);
return super.close(options);
}
/* -------------------------------------------- */
/** @override */
async _onChangeInput(event) {
this.#applyThemeChange(this.form.cssTheme.value);
}
/* -------------------------------------------- */
/** @override */
async _onSubmit(event, options={}) {
event.preventDefault();
const original = this.config.toObject();
// Validate the proposed changes
const formData = this._getSubmitData();
let changes;
try {
changes = this.config.updateSource(formData);
} catch(err) {
return ui.notifications.error(err.message);
}
if ( foundry.utils.isEmpty(changes) ) return this.close();
// Confirm that a server restart is okay
const confirm = await Dialog.confirm({
title: game.i18n.localize("SETUP.ConfigSave"),
content: `
${game.i18n.localize("SETUP.ConfigSaveWarning")}
`,
defaultYes: false,
options: {width: 480}
});
// Submit the form
if ( confirm ) {
const response = await Setup.post({action: "adminConfigure", config: changes});
if ( response.restart ) ui.notifications.info("SETUP.ConfigSaveRestart", {localize: true, permanent: true});
return this.close();
}
// Reset the form
this.config.updateSource(original);
return this.render();
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {}
/* -------------------------------------------- */
/**
* Update the body class with the previewed CSS theme.
* @param {string} themeId The theme ID to preview
*/
#applyThemeChange(themeId) {
document.body.classList.replace(`theme-${this.#previewTheme}`, `theme-${themeId}`);
this.#previewTheme = themeId;
}
/* -------------------------------------------- */
/**
* Prompt the user with a request to share telemetry data if they have not yet chosen an option.
* @returns {Promise}
*/
static async telemetryRequestDialog() {
if ( game.data.options.telemetry !== undefined ) return;
const response = await Dialog.wait({
title: game.i18n.localize("SETUP.TelemetryRequestTitle"),
content: `
${game.i18n.localize("SETUP.TelemetryRequest1")}
`
+ `
${game.i18n.localize("SETUP.TelemetryHint")}
`
+ `
${game.i18n.localize("SETUP.TelemetryRequest2")}
`,
focus: true,
close: () => null,
buttons: {
yes: {
icon: '',
label: game.i18n.localize("SETUP.TelemetryAllow"),
callback: () => true
},
no: {
icon: '',
label: game.i18n.localize("SETUP.TelemetryDecline"),
callback: () => false
}
}
}, {width: 480});
if ( response !== null ) {
const { changes } = await Setup.post({action: "adminConfigure", config: {telemetry: response}});
foundry.utils.mergeObject(game.data.options, changes);
}
}
}
const _app$4 = foundry.applications.api;
/**
* The Setup Authentication Form.
* Prompt the user to provide a server administrator password if one has been configured.
* @extends ApplicationV2
* @mixes HandlebarsApplication
*/
class SetupAuthenticationForm extends _app$4.HandlebarsApplicationMixin(_app$4.ApplicationV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = foundry.utils.mergeObject(super.DEFAULT_OPTIONS, {
id: "setup-authentication",
classes: ["application", "framed"],
window: {
frame: false,
positioned: false
}
}, {inplace: false});
/** @override */
static PARTS = {
form: {
template: "templates/setup/setup-authentication.hbs"
}
};
}
const _app$3 = foundry.applications.api;
/**
* An application that renders the floating setup menu buttons.
* @extends ApplicationV2
* @mixes HandlebarsApplication
*/
class SetupWarnings extends _app$3.HandlebarsApplicationMixin(_app$3.ApplicationV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = foundry.utils.mergeObject(super.DEFAULT_OPTIONS, {
id: "setup-warnings",
window: {
title: "SETUP.WarningsTitle",
icon: "fa-solid fa-triangle-exclamation"
},
position: {
width: 680
},
actions: {
reinstallPackage: SetupWarnings.#reinstallPackage,
uninstallPackage: SetupWarnings.#uninstallPackage,
managePackage: SetupWarnings.#managePackage
}
}, {inplace: false});
/** @override */
static PARTS = {
packages: {
id: "packages",
template: "templates/setup/setup-warnings.hbs",
scrollable: [""]
}
}
/* -------------------------------------------- */
/** @inheritDoc */
get title() {
return `${super.title} (${game.issueCount.total})`;
}
/* -------------------------------------------- */
/** @override */
async _prepareContext(options) {
const categories = {
world: {label: "SETUP.Worlds", packages: {}},
system: {label: "SETUP.Systems", packages: {}},
module: {label: "SETUP.Modules", packages: {}}
};
// Organize warnings
for ( const pkg of Object.values(game.data.packageWarnings) ) {
const cls = PACKAGE_TYPES[pkg.type];
const p = game[cls.collection].get(pkg.id);
categories[pkg.type].packages[pkg.id] = {
id: pkg.id,
type: pkg.type,
name: p ? p.title : "",
errors: pkg.error.map(e => e.trim()).join("\n"),
warnings: pkg.warning.map(e => e.trim()).join("\n"),
reinstallable: pkg.reinstallable,
installed: p !== undefined
};
}
// Filter categories to ones which have issues
for ( const [k, v] of Object.entries(categories) ) {
if ( foundry.utils.isEmpty(v.packages) ) delete categories[k];
}
return {categories};
}
/* -------------------------------------------- */
/**
* Handle button clicks to manage the package in the main setup interface.
* @param {PointerEvent} event The initiating click event
* @param {HTMLButtonElement} target The clicked button
*/
static #managePackage(event, target) {
event.preventDefault();
const li = target.closest(".package");
const packageType = li.closest("section[data-package-type]").dataset.packageType;
ui.setupPackages.activateTab(`${packageType}s`);
// Filter to the target package
const packageId = li.dataset.packageId;
const filter = ui.setupPackages._searchFilters.find(f => f._inputSelector === `#${packageType}-filter`)._input;
filter.value = packageId;
filter.dispatchEvent(new Event("input", {bubbles: true}));
}
/* -------------------------------------------- */
/**
* Handle button clicks to reinstall a package.
* @param {PointerEvent} event The initiating click event
* @param {HTMLButtonElement} target The clicked button
*/
static async #reinstallPackage(event, target) {
event.preventDefault();
const pkg = target.closest("[data-package-id]");
const id = pkg.dataset.packageId;
const type = pkg.dataset.packageType;
target.querySelector("i").classList.add("fa-spin");
// Uninstall current
await Setup.uninstallPackage({id, type});
delete game.data.packageWarnings[id];
// Install package
await Setup.warmPackages({ type });
const warnInfo = game.data.packageWarnings[id];
if ( !pkg && !warnInfo?.manifest ) {
return ui.notifications.error("SETUP.ReinstallPackageNotFound", { localize: true, permanent: true });
}
await Setup.installPackage({ type, id, manifest: warnInfo?.manifest ?? pkg.manifest });
}
/* -------------------------------------------- */
/**
* Handle button clicks to uninstall a package
* @param {PointerEvent} event The initiating click event
* @param {HTMLButtonElement} target The clicked button
*/
static async #uninstallPackage(event, target) {
event.preventDefault();
const pkg = target.closest("[data-package-id]");
const id = pkg.dataset.packageId;
const type = pkg.dataset.packageType;
await Setup.uninstallPackage({id, type});
delete game.data.packageWarnings[id];
}
}
/**
* @typedef {FormApplicationOptions} CategoryFilterApplicationOptions
* @property {string} initialCategory The category that is initially selected when the Application first renders.
* @property {string[]} inputs A list of selectors for form inputs that should have their values preserved on
* re-render.
*/
/**
* @typedef {object} CategoryFilterCategoryContext
* @property {string} id The category identifier.
* @property {boolean} active Whether the category is currently selected.
* @property {string} label The localized category label.
* @property {number} count The number of entries in this category.
*/
/**
* An abstract class responsible for displaying a 2-pane Application that allows for entries to be grouped and filtered
* by category.
*/
class CategoryFilterApplication extends FormApplication {
/**
* The currently selected category.
* @type {string}
*/
#category = this.options.initialCategory;
/**
* The currently selected category.
* @type {string}
*/
get category() {
return this.#category;
}
/**
* Record the state of user inputs.
* @type {string[]}
* @protected
*/
_inputs = [];
/* -------------------------------------------- */
/** @returns {CategoryFilterApplicationOptions} */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["category-filter"],
width: 920,
height: 780,
scrollY: [".categories", ".entry-list"],
filters: [{ inputSelector: 'input[name="filter"]', contentSelector: ".entries" }]
});
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force=false, options={}) {
this._saveInputs();
await super._render(force, options);
this._restoreInputs();
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const { categories, entries } = this._prepareCategoryData();
categories.sort(this._sortCategories.bind(this));
entries.sort(this._sortEntries.bind(this));
return { categories, entries };
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html[0].children[0].onsubmit = ev => ev.preventDefault();
html.find(".entry-title h3").on("click", this._onClickEntryTitle.bind(this));
html.find(".categories .category").on("click", this._onClickCategoryFilter.bind(this));
}
/* -------------------------------------------- */
/**
* Category comparator.
* @param {CategoryFilterCategoryContext} a
* @param {CategoryFilterCategoryContext} b
* @returns {number}
* @protected
*/
_sortCategories(a, b) {
return 0;
}
/* -------------------------------------------- */
/**
* Entries comparator.
* @param {object} a
* @param {object} b
* @return {number}
* @protected
*/
_sortEntries(a, b) {
return 0;
}
/* -------------------------------------------- */
/**
* Handle click events to filter by a certain category.
* @param {PointerEvent} event The triggering event.
* @protected
*/
_onClickCategoryFilter(event) {
event.preventDefault();
this.#category = event.currentTarget.dataset.category;
this.render();
}
/* -------------------------------------------- */
/** @override */
_onSearchFilter(event, query, rgx, html) {
if ( html.classList.contains("loading") ) return;
for ( const entry of html.querySelectorAll(".entry") ) {
if ( !query ) {
entry.classList.remove("hidden");
continue;
}
let match = false;
this._getSearchFields(entry).forEach(field => match ||= rgx.test(SearchFilter.cleanQuery(field)));
entry.classList.toggle("hidden", !match);
}
}
/* -------------------------------------------- */
/**
* Retrieve any additional fields that the entries should be filtered on.
* @param {HTMLElement} entry The entry element.
* @returns {string[]}
* @protected
*/
_getSearchFields(entry) {
return [];
}
/* -------------------------------------------- */
/**
* Record the state of user inputs.
* @protected
*/
_saveInputs() {
if ( !this.element.length || !this.options.inputs?.length ) return;
this._inputs = this.options.inputs.map(selector => {
const input = this.element[0].querySelector(selector);
return input?.value ?? "";
});
}
/* -------------------------------------------- */
/**
* Restore the state of user inputs.
* @protected
*/
_restoreInputs() {
if ( !this.options.inputs?.length || !this.element.length ) return;
this.options.inputs.forEach((selector, i) => {
const value = this._inputs[i] ?? "";
const input = this.element[0].querySelector(selector);
if ( input ) input.value = value;
});
}
/* -------------------------------------------- */
/* Abstract Methods */
/* -------------------------------------------- */
/**
* Get category context data.
* @returns {{categories: CategoryFilterCategoryContext[], entries: object[]}}
* @abstract
*/
_prepareCategoryData() {
return { categories: [], entries: [] };
}
/* -------------------------------------------- */
/**
* Handle clicking on the entry title.
* @param {PointerEvent} event The triggering event.
* @abstract
*/
_onClickEntryTitle(event) {}
}
/**
* An application that manages backups for a single package.
*/
class BackupList extends FormApplication {
/**
* The list of available backups for this package.
* @type {BackupData[]}
*/
#backups = [];
/**
* The backup date formatter.
* @type {Intl.DateTimeFormat}
*/
#dateFormatter = new Intl.DateTimeFormat(game.i18n.lang, { dateStyle: "full", timeStyle: "short" });
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["backup-list", "category-filter"],
template: "templates/setup/backup-list.hbs",
width: 640,
height: 780
});
}
/* -------------------------------------------- */
/** @override */
get id() {
return `backup-list-${this.object.type}-${this.object.id}`;
}
/** @override */
get title() {
return game.i18n.format("SETUP.BACKUPS.ManagePackage", { package: this.object.title });
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force=false, options={}) {
await super._render(force, options);
if ( !Setup.backups && force ) Setup.listBackups().then(() => this.render());
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const context = {};
if ( Setup.backups ) this.#backups = Setup.backups[this.object.type]?.[this.object.id] ?? [];
else context.progress = { label: "SETUP.BACKUPS.Loading", icon: "fas fa-spinner fa-spin" };
context.entries = this.#prepareEntries();
return context;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("[data-action]").on("click", this.#onAction.bind(this));
html.find(".entry-title").on("click", this.#onClickEntryTitle.bind(this));
}
/* -------------------------------------------- */
/** @inheritDoc */
_getHeaderButtons() {
const buttons = super._getHeaderButtons();
buttons.unshift({
label: "SETUP.BACKUPS.TakeBackup",
class: "create-backup",
icon: "fas fa-floppy-disk",
onclick: this.#onCreateBackup.bind(this)
});
return buttons;
}
/* -------------------------------------------- */
/**
* Delete any selected backups.
*/
async #deleteSelected() {
const toDelete = [];
for ( const el of this.form.elements ) {
if ( el.checked && (el.name !== "select-all") ) toDelete.push(el.name);
}
await Setup.deleteBackups(this.object, toDelete, { dialog: true });
this.render();
}
/* -------------------------------------------- */
/**
* Prepare template context data for backup entries.
* @returns {BackupEntryUIDescriptor[]}
*/
#prepareEntries() {
return this.#backups.map(backupData => {
const { id, size, note, createdAt, snapshotId } = backupData;
const tags = [
{ label: foundry.utils.formatFileSize(size, { decimalPlaces: 0 }) },
this.constructor.getVersionTag(backupData)
];
if ( snapshotId ) tags.unshift({ label: game.i18n.localize("SETUP.BACKUPS.Snapshot") });
return {
id, tags,
description: note,
inSnapshot: !!snapshotId,
noRestore: !this.constructor.canRestoreBackup(backupData),
title: this.#dateFormatter.format(createdAt),
};
});
}
/* -------------------------------------------- */
/**
* Determine the version tag for a given backup.
* @param {BackupData} backupData The backup.
* @returns {BackupEntryTagDescriptor}
*/
static getVersionTag(backupData) {
const cls = PACKAGE_TYPES[backupData.type];
const availability = cls.testAvailability(backupData);
return cls.getVersionBadge(availability, backupData);
}
/* -------------------------------------------- */
/**
* Determine if a given backup is allowed to be restored.
* @param {BackupData} backupData The backup.
* @returns {boolean}
*/
static canRestoreBackup(backupData) {
const { packageId, type } = backupData;
const cls = PACKAGE_TYPES[type];
const pkg = game[cls.collection].get(packageId);
// If there is no currently-installed version of the package, it can always be restored.
if ( !pkg ) return true;
const codes = CONST.PACKAGE_AVAILABILITY_CODES;
const usable = code => (code >= codes.VERIFIED) && (code <= codes.UNVERIFIED_GENERATION);
// If the installed package is already unusable, there is no harm in restoring a backup, it can't make things worse.
if ( !usable(pkg.availability) ) return true;
// Otherwise check if restoring the backup would make the package unusable.
return usable(cls.testAvailability(backupData));
}
/* -------------------------------------------- */
/**
* Handle clicking on an action button.
* @param {PointerEvent} event The triggering event.
*/
#onAction(event) {
const { action } = event.currentTarget.dataset;
switch ( action ) {
case "delete":
this.#deleteSelected();
break;
case "restore":
this.#onRestore(event);
break;
case "select-all":
this.#toggleSelectAll(event.currentTarget.checked);
break;
}
}
/* -------------------------------------------- */
/**
* Handle clicking the backup title in order to toggle its checkbox.
* @param {PointerEvent} event The triggering event.
*/
#onClickEntryTitle(event) {
const row = event.currentTarget.closest(".checkbox-row");
const checkbox = row.querySelector("input");
if ( !checkbox.disabled ) checkbox.checked = !checkbox.checked;
}
/* -------------------------------------------- */
/**
* Handle creating a new backup.
*/
async #onCreateBackup() {
await Setup.createBackup(this.object, { dialog: true });
this.render();
}
/* -------------------------------------------- */
/**
* Handle restoring a specific backup.
* @param {PointerEvent} event The triggering event.
*/
async #onRestore(event) {
const { backupId } = event.currentTarget.closest("[data-backup-id]").dataset;
const backupData = this.#backups.find(entry => entry.id === backupId);
const pkg = game[`${this.object.type}s`].get(this.object.id);
await Setup.restoreBackup(backupData, { dialog: !!pkg });
this.render();
}
/* -------------------------------------------- */
/**
* Handle selecting or deselecting all backups.
* @param {boolean} select Whether to select or deselect.
*/
#toggleSelectAll(select) {
for ( const el of this.form.elements ) {
if ( !el.disabled && (el.type === "checkbox") && (el.name !== "select-all") ) el.checked = select;
}
}
/* -------------------------------------------- */
/**
* Toggle the locked state of the interface.
* @param {boolean} locked Is the interface locked?
*/
toggleLock(locked) {
const element = this.element[0];
if ( !element ) return;
element.querySelectorAll("a.button, .create-backup").forEach(el => el.classList.toggle("disabled", locked));
element.querySelectorAll("button").forEach(el => el.disabled = locked);
}
}
/**
* @typedef {object} BackupEntryTagDescriptor
* @property {"unsafe"|"warning"|"neutral"|"safe"} [type] The tag type.
* @property {string} [icon] An icon class.
* @property {string} label The tag text.
* @property {string} [tooltip] Tooltip text.
*/
/**
* @typedef {object} BackupEntryUIDescriptor
* @property {string} [packageId] The ID of the package this backup represents, if applicable.
* @property {string} [backupId] The ID of the package backup, if applicable.
* @property {string} [snapshotId] The ID of the snapshot, if applicable.
* @property {number} [createdAt] The snapshot's creation timestamp.
* @property {string} title The title of the entry. Either a formatted date for snapshots, or the title of the
* package for package backups.
* @property {string} [restoreLabel] The label for the restore button.
* @property {string} description The description for the entry. Either the user's note for snapshots, or the
* package description for package backups.
* @property {boolean} [inSnapshot] For package backups, this indicates that it is part of a snapshot.
* @property {boolean} [noRestore] Is the backup allowed to be restored.
* @property {BackupEntryTagDescriptor[]} tags Tag descriptors for the backup or snapshot.
*/
/**
* An Application that manages user backups and snapshots.
*/
class BackupManager extends CategoryFilterApplication {
/**
* The snapshot date formatter.
* @type {Intl.DateTimeFormat}
*/
#dateFormatter = new Intl.DateTimeFormat(game.i18n.lang, { dateStyle: "full", timeStyle: "short" });
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "backup-manager",
template: "templates/setup/backup-manager.hbs",
title: "SETUP.BACKUPS.ManageBackups",
inputs: ['[name="filter"]'],
initialCategory: "world"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force=false, options={}) {
await super._render(force, options);
if ( !Setup.backups && force ) Setup.listBackups().then(() => this.render(false));
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
// Loading progress.
if ( Setup.backups ) {
const totalSize = Object.entries(Setup.backups).reduce((acc, [k, v]) => {
if ( k === "snapshots" ) return acc;
return acc + Object.values(v).reduce((acc, arr) => acc + arr.reduce((acc, d) => acc + d.size, 0), 0);
}, 0);
context.totalSize = foundry.utils.formatFileSize(totalSize, { decimalPlaces: 0 });
}
else context.progress = { label: "SETUP.BACKUPS.Loading", icon: "fas fa-spinner fa-spin" };
context.hasBulkActions = this.category === "snapshots";
return context;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("[data-action]").on("click", this.#onAction.bind(this));
}
/* -------------------------------------------- */
/** @override */
_prepareCategoryData() {
const categories = ["snapshots", "world", "module", "system"].map(id => {
let count;
if ( id === "snapshots" ) count = Object.keys(Setup.backups?.[id] ?? {}).length;
else count = Object.values(Setup.backups?.[id] ?? {}).filter(backups => backups.length).length;
return {
id, count,
active: this.category === id,
label: game.i18n.localize(`SETUP.BACKUPS.TYPE.${id}`)
};
});
let entries;
if ( this.category === "snapshots" ) entries = this.#getSnapshotsContext();
else entries = this.#getPackagesContext(this.category);
return { categories, entries };
}
/* -------------------------------------------- */
/** @override */
_sortEntries(a, b) {
if ( this.category === "snapshots" ) return b.createdAt - a.createdAt;
return a.title.localeCompare(b.title, game.i18n.lang);
}
/* -------------------------------------------- */
/** @override */
_sortCategories(a, b) {
const order = ["snapshots", "world", "module", "system"];
return order.indexOf(a.id) - order.indexOf(b.id);
}
/* -------------------------------------------- */
/**
* Get snapshot context data.
* @returns {BackupEntryUIDescriptor[]}
*/
#getSnapshotsContext() {
return Object.values(Setup.backups?.snapshots ?? {}).map(snapshotData => {
const { createdAt } = snapshotData;
const versionTag = this.#getSnapshotVersionTag(snapshotData);
return {
createdAt,
snapshotId: snapshotData.id,
title: this.#dateFormatter.format(createdAt),
restoreLabel: "SETUP.BACKUPS.Restore",
description: snapshotData.note,
noRestore: versionTag.noRestore,
tags: [
versionTag,
{ label: foundry.utils.formatFileSize(snapshotData.size, { decimalPlaces: 0 }) }
]
};
});
}
/* -------------------------------------------- */
/**
* Determine the version tag for a given snapshot.
* @param {SnapshotData} snapshotData The snapshot.
* @returns {BackupEntryTagDescriptor}
*/
#getSnapshotVersionTag({ generation, build }) {
const label = game.i18n.format("SETUP.BACKUPS.VersionFormat", { version: `${generation}.${build}` });
// Safe to restore a snapshot taken in the current generation.
if ( generation === game.release.generation ) return { label, type: "safe", icon: "fas fa-code-branch" };
// Potentially safe to restore a snapshot from an older generation into a newer generation software version.
if ( generation < game.release.generation ) return { label, type: "warning", icon: "fas fa-exclamation-triangle" };
// Impossible to restore a snapshot from a newer generation than the current software version.
if ( generation > game.release.generation ) return {
label,
type: "error",
icon: "fa fa-file-slash",
noRestore: true
};
}
/* -------------------------------------------- */
/**
* Get package backup context data.
* @param {"module"|"system"|"world"} type The package type.
* @returns {BackupEntryUIDescriptor[]}
*/
#getPackagesContext(type) {
const entries = [];
for ( const backups of Object.values(Setup.backups?.[type] ?? {}) ) {
if ( !backups.length ) continue;
const newest = backups[0];
const size = backups.reduce((acc, backupData) => acc + backupData.size, 0);
const { packageId, title, description } = newest;
const pkg = game[PACKAGE_TYPES[type].collection].get(packageId);
const tags = [
{ label: game.i18n.format(`SETUP.BACKUPS.Num${backups.length === 1 ? "" : "Pl"}`, { number: backups.length }) },
{ label: foundry.utils.formatFileSize(size, { decimalPlaces: 0 }) },
BackupList.getVersionTag(newest)
];
entries.push({
packageId, title, tags,
packageType: type,
backupId: newest.id,
restoreLabel: "SETUP.BACKUPS.RestoreLatest",
noRestore: !BackupList.canRestoreBackup(newest),
packageExists: !!pkg,
description: TextEditor.previewHTML(description, 150)
});
}
return entries;
}
/* -------------------------------------------- */
/** @override */
_onClickEntryTitle(event) {
const { packageId, packageType, packageTitle } = event.currentTarget.closest(".entry").dataset;
return new BackupList({ id: packageId, type: packageType, title: packageTitle }).render(true);
}
/* -------------------------------------------- */
/**
* Handle clicking on an action button.
* @param {PointerEvent} event The triggering event.
*/
#onAction(event) {
const { action } = event.currentTarget.dataset;
switch ( action ) {
case "create":
this.#onCreateBackup(event);
break;
case "delete":
this.#deleteSelected();
break;
case "manage":
this._onClickEntryTitle(event);
break;
case "restore":
this.#onRestore(event);
break;
case "select-all":
this.#toggleSelectAll(event.currentTarget.checked);
break;
}
}
/* -------------------------------------------- */
/**
* Handle selecting or deleting all snapshots.
* @param {boolean} select Whether to select or deselect.
*/
#toggleSelectAll(select) {
for ( const el of this.form.elements ) {
if ( !el.disabled && (el.type === "checkbox") && (el.name !== "select-all") ) el.checked = select;
}
}
/* -------------------------------------------- */
/**
* Handle creating a new package backup.
* @param {PointerEvent} event The triggering event.
* @returns {Promise}
*/
async #onCreateBackup(event) {
const { packageId, packageType } = event.currentTarget.closest(".entry").dataset;
const pkg = game[PACKAGE_TYPES[packageType].collection].get(packageId);
if ( !pkg ) return;
await Setup.createBackup(pkg, { dialog: true });
this.render();
}
/* -------------------------------------------- */
/**
* Handle restoring a snapshot or the latest backup.
* @param {PointerEvent} event The triggering event.
*/
async #onRestore(event) {
const { packageId, packageType, snapshotId } = event.currentTarget.closest(".entry").dataset;
if ( snapshotId ) return Setup.restoreSnapshot(Setup.backups.snapshots[snapshotId], { dialog: true });
const pkg = game[PACKAGE_TYPES[packageType].collection].get(packageId);
await Setup.restoreLatestBackup({ id: packageId, type: packageType }, { dialog: !!pkg });
this.render();
}
/* -------------------------------------------- */
/**
* Handle creating a snapshot.
*/
async #onCreateSnapshot() {
await Setup.createSnapshot({ dialog: true });
this.render(true);
}
/* -------------------------------------------- */
/**
* Delete any selected snapshots.
*/
async #deleteSelected() {
const toDelete = [];
for ( const el of this.form.elements ) {
if ( el.checked && (el.name !== "select-all") ) toDelete.push(el.name);
}
await Setup.deleteSnapshots(toDelete, { dialog: true });
this.render(true);
}
/* -------------------------------------------- */
/** @override */
_getSearchFields(entry) {
return [entry.dataset.packageId ?? "", entry.querySelector(".entry-title h3")?.textContent ?? ""];
}
/* -------------------------------------------- */
/** @inheritdoc */
_getHeaderButtons() {
const buttons = super._getHeaderButtons();
const packages = game.worlds.size + game.systems.size + game.modules.size;
if ( packages ) {
buttons.unshift({
label: "SETUP.BACKUPS.CreateSnapshot",
class: "create-snapshot",
icon: "fas fa-camera-retro",
onclick: this.#onCreateSnapshot.bind(this)
});
}
return buttons;
}
/* -------------------------------------------- */
/**
* Toggle the locked state of the interface.
* @param {boolean} locked Is the interface locked?
*/
toggleLock(locked) {
const element = this.element[0];
if ( !element ) return;
element.querySelectorAll("a.control.category, .create-snapshot, a.button, .entry-title h3").forEach(el => {
el.classList.toggle("disabled", locked);
});
element.querySelectorAll("button").forEach(el => el.disabled = locked);
}
}
const _app$2 = foundry.applications.api;
/**
* An application that renders the floating setup menu buttons.
* @extends ApplicationV2
* @mixes HandlebarsApplication
*/
class SetupMenu extends _app$2.HandlebarsApplicationMixin(_app$2.ApplicationV2) {
/** @inheritDoc */
static DEFAULT_OPTIONS = foundry.utils.mergeObject(super.DEFAULT_OPTIONS, {
id: "setup-menu",
tag: "nav",
window: {
frame: false,
positioned: false
},
actions: {
adminLogout: SetupMenu.#onClickAdminLogout,
backups: SetupMenu.#onClickBackupManager,
configure: SetupMenu.#onClickConfiguration,
update: SetupMenu.#onClickUpdate,
viewWarnings: SetupMenu.#onClickViewWarnings
}
}, {inplace: false});
/** @override */
static PARTS = {
buttons: {
id: "buttons",
template: "templates/setup/parts/setup-menu.hbs",
}
}
/* -------------------------------------------- */
/** @override */
async _prepareContext(options) {
let { hasUpdate, channel } = game.data.coreUpdate;
hasUpdate &&= ["testing", "stable"].includes(channel);
const buttons = [
{
action: "viewWarnings",
tooltip: game.i18n.localize("Warnings"),
icon: "fa-solid fa-triangle-exclamation",
pip: game.issueCount.total ? {
type: game.issueCount.error > 0 ? "error" : "warning",
label: game.issueCount.total
} : null
},
{
action: "configure",
tooltip: "Configure",
icon: "fa-solid fa-cogs",
pip: !game.data.options.adminPassword ? {
type: "warning",
label: "!"
} : null
},
{
action: "update",
tooltip: "Update",
icon: "fa-solid fa-download",
pip: hasUpdate ? {
type: "warning",
label: "!"
} : null
}
];
// Backup
const canBackup = !game.data.options.noBackups;
if ( canBackup ) buttons.push({
action: "backups",
tooltip: game.i18n.localize("SETUP.BACKUPS.ManageBackups"),
icon: "fa-solid fa-floppy-disks"
});
// Log Out
const canLogOut = !!game.data.options.adminPassword;
if ( canLogOut ) buttons.push({
action: "adminLogout",
tooltip: "Log Out",
icon: "fa-solid fa-door-open"
});
return {buttons};
}
/* -------------------------------------------- */
/**
* Toggle the locked state of the interface.
* @param {boolean} locked Is the interface locked?
*/
toggleLock(locked) {
if ( !this.rendered ) return;
this.element.querySelectorAll("button").forEach(el => el.disabled = locked);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
static #onClickAdminLogout() {
Setup.post({action: "adminLogout"});
}
static #onClickBackupManager() {
new BackupManager().render(true);
}
static #onClickConfiguration() {
new SetupApplicationConfiguration(game.data.options).render(true);
}
static #onClickUpdate() {
window.location.href = foundry.utils.getRoute("update");
}
static #onClickViewWarnings(event, target) {
const warnings = new SetupWarnings();
const {bottom, right} = target.parentElement.getBoundingClientRect();
warnings.render(true, {left: right - warnings.options.width, top: bottom + 20});
}
}
/**
* A FormApplication which facilitates the creation of a new Module.
*/
class ModuleConfigurationForm extends FormApplication {
constructor(moduleData, options) {
super(undefined, options);
this.#module = new Module(moduleData || {
id: "my-new-module",
title: "My New Module",
version: "1.0.0",
compatibility: {
minimum: game.release.generation,
verified: game.release.generation
}
});
this.#source = moduleData ? game.modules.get(this.#module.id) : undefined;
}
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "module-create",
template: "templates/setup/module-configuration.hbs",
width: 760,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "basics"}]
});
}
/** @override */
get title() {
if ( !this.#source ) return game.i18n.localize("PACKAGE.ModuleCreate");
return `${game.i18n.localize("PACKAGE.ModuleEdit")}: ${this.#module.title}`;
}
/**
* A Module instance used as the source data for the form and to validate changes.
* @type {Module}
*/
#module;
/**
* If editing an existing package, track a reference to its persisted data
* @type {Module}
*/
#source;
/**
* Display a pending relationship which has not yet been confirmed to appear at the bottom of the list?
* @type {boolean}
*/
#pendingRelationship = false;
/* -------------------------------------------- */
/** @inheritDoc */
async getData(options={}) {
const compendiumTypes = CONST.COMPENDIUM_DOCUMENT_TYPES.map(documentName => {
return { value: documentName, label: game.i18n.localize(getDocumentClass(documentName).metadata.label) };
});
game.i18n.sortObjects(compendiumTypes, "label");
return {
compendiumTypes,
isCreation: !this.#source,
module: this.#module,
moduleId: this.#source?.id || "",
packs: this.#getPacks(),
relatedPackages: {
systems: Object.fromEntries(Array.from(game.systems.values()).map(s => [s.id, s.title])),
modules: Object.fromEntries(Array.from(game.modules.values()).map(m => [m.id, m.title]))
},
relationships: this.#getFlattenedRelationships(),
relationshipCategories: {
requires: "PACKAGE.Relationships.Requires",
recommends: "PACKAGE.Relationships.Recommends",
conflicts: "PACKAGE.Relationships.Conflicts"
},
submitLabel: this.#source ? "PACKAGE.ModuleEdit" : "PACKAGE.ModuleCreate"
}
}
/* -------------------------------------------- */
#getPacks() {
return this.#module.packs.map(pack => {
return {
name: pack.name,
label: pack.label,
type: pack.type,
system: pack.system,
creating: pack.flags?._placeholder,
existing: this.#source?.packs.find(p => p.name === pack.name)
}
});
}
/* -------------------------------------------- */
/**
* Flatten the relationships object into an array which is more convenient for rendering.
* @returns {Array<{id: string, type: string, category: string}>}
*/
#getFlattenedRelationships() {
const relationships = [];
for ( const [category, rs] of Object.entries(this.#module.relationships) ) {
if ( !["systems", "requires", "recommends", "conflicts"].includes(category) ) continue;
for ( let [i, r] of Object.entries(Array.from(rs)) ) {
r = foundry.utils.deepClone(r);
r.category = category;
r.index = i;
relationships.push(r);
}
}
if ( this.#pendingRelationship ) relationships.push({id: "", category: "", index: -1});
return relationships;
}
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
html.on("click", "[data-action]", this.#onAction.bind(this));
html.on("input", "input[data-slugify]", this.#onSlugify.bind(this));
}
/* -------------------------------------------- */
/**
* Handle click events on action buttons within the form.
* @param {Event} event The originating click event
*/
#onAction(event) {
event.preventDefault();
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "authorAdd":
return this.#authorAdd();
case "authorDelete":
return this.#authorDelete(Number(button.dataset.index));
case "packAdd":
return this.#packAdd();
case "packDelete":
return this.#packDelete(Number(button.dataset.index));
case "relationshipAdd":
return this.#relationshipAdd();
case "relationshipDelete":
return this.#relationshipDelete(button.dataset.category, Number(button.dataset.index));
}
}
/* -------------------------------------------- */
/**
* Add a new entry to the authors array.
*/
#authorAdd() {
const data = this._getSubmitData();
data.authors.push({name: `Author ${data.authors.length + 1}`});
this.#tryUpdate(data);
}
/* -------------------------------------------- */
/**
* Delete an entry from the authors array.
* @param {number} index The array index to delete
*/
#authorDelete(index) {
const data = this._getSubmitData();
data.authors.splice(index, 1);
this.#tryUpdate(data);
}
/* -------------------------------------------- */
/**
* Add a new entry to the packs array.
*/
#packAdd() {
const data = this._getSubmitData();
let i = data.packs.length;
let nextName;
while ( true ) {
i++;
nextName = `pack-${i}`;
if ( !data.packs.find(p => p.name === nextName ) && !this.#source?.packs.find(p => p.name === nextName) ) break;
}
data.packs.push({
name: nextName,
label: `Pack ${i}`,
path: `packs/${nextName}`,
type: "JournalEntry",
ownership: {PLAYER: "OBSERVER", ASSISTANT: "OWNER"},
flags: {
_placeholder: true
}
});
this.#tryUpdate(data);
}
/* -------------------------------------------- */
/**
* Delete an entry from the packs array.
* @param {number} index The array index to delete
*/
#packDelete(index) {
const data = this._getSubmitData();
data.packs.splice(index, 1);
this.#tryUpdate(data);
}
/* -------------------------------------------- */
/**
* Add a pending relationship entry to the relationships object.
*/
#relationshipAdd() {
this.#pendingRelationship = true;
const data = this._getSubmitData();
this.#tryUpdate(data);
}
/* -------------------------------------------- */
/**
* Remove a relationship, or remove the pending relationship from the relationships object.
* @param {string} category The relationship category being removed
* @param {number} index The array index to delete
*/
#relationshipDelete(category, index) {
const data = this._getSubmitData();
for ( const c of ["systems", "requires", "recommends", "conflicts"] ) {
if ( !data.relationships[c] ) continue;
for ( const [i, r] of Object.entries(data.relationships[c]) ) {
if ( (r._category === category) && (r._index === index) ) {
data.relationships[c].splice(i, 1);
break;
}
}
}
this.#pendingRelationship = false;
this.#tryUpdate(data);
}
/* -------------------------------------------- */
/** @override */
async _onChangeInput(event) {
await super._onChangeInput(event);
// If the .relationship select changes, update the category select
if ( event.target.classList.contains("relationship") ) {
this.#updateRelationshipOptions(event.currentTarget);
}
}
/* -------------------------------------------- */
/** @override */
async _render(force, options) {
await super._render(force, options);
this.element[0].querySelectorAll("select.relationship")
.forEach(select => this.#updateRelationshipOptions(select));
}
/* -------------------------------------------- */
/**
* Swaps what options are available based on Package type
* @param {HTMLSelectElement} select The select element
*/
#updateRelationshipOptions(select) {
// If this is a system relationship, the only valid category is "system"
const selectedOption = select.options[select.selectedIndex];
const isSystem = selectedOption.parentNode.dataset.category === "system";
const categorySelect = select.closest("fieldset").querySelector("select[name$='.category']");
// Remove the system option, if it exists
categorySelect.querySelector("option[value='systems']")?.remove();
categorySelect.disabled = isSystem;
if ( isSystem ) {
// Create a selected option
const option = document.createElement("option");
option.value = "systems";
option.text = game.i18n.localize("PACKAGE.Relationships.Systems");
option.selected = true;
// Prepend the selected option
categorySelect.prepend(option);
}
}
/* -------------------------------------------- */
/**
* Automatically slugify a related input field as text is typed.
* @param {Event} event The field input event
*/
#onSlugify(event) {
const input = event.currentTarget;
const target = this.form[input.dataset.slugify];
if ( target.disabled ) return;
target.placeholder = input.value.slugify({strict: true});
}
/* -------------------------------------------- */
/** @override */
_getSubmitData(updateData = {}) {
const fd = new FormDataExtended(this.form, {disabled: true});
const formData = foundry.utils.expandObject(fd.object);
const moduleData = this.#module.toObject();
// Module ID
if ( this.#source ) formData.id = this.#source.id;
else if ( !formData.id ) formData.id = formData.title.slugify({strict: true});
// Authors
formData.authors = Object.values(formData.authors || {}).map((author, i) => {
const moduleAuthor = moduleData.authors[i];
author = foundry.utils.mergeObject(moduleAuthor, author, {inplace: false});
if ( foundry.utils.isEmpty(author.flags) ) delete author.flags;
return author;
});
// Packs
formData.packs = Object.values(formData.packs || {}).map((pack, i) => {
const modulePack = moduleData.packs[i];
if ( !pack.name ) pack.name = pack.label.slugify({strict: true});
const sourcePath = this.#source?.packs.find(p => p.name === pack.name)?.path;
pack.path = sourcePath?.replace(`modules/${this.#source.id}/`, "") ?? `packs/${pack.name}`;
pack = foundry.utils.mergeObject(modulePack, pack, {inplace: false});
if ( pack.flags?._placeholder ) delete pack.flags._placeholder;
if ( foundry.utils.isEmpty(pack.flags) ) delete pack.flags;
return pack;
});
// Relationships
const relationships = {};
for ( let r of Object.values(formData.relationships || {}) ) {
if ( !(r.category && r.id) ) continue;
const c = r.category;
delete r.category;
if ( r._category ) {
const moduleRelationship = moduleData.relationships[r._category][r._index];
r = foundry.utils.mergeObject(moduleRelationship, r, {inplace: false});
}
if ( foundry.utils.isEmpty(r.compatibility) ) delete r.compatibility;
relationships[c] ||= [];
r.type = game.systems.has(r.id) ? "system" : "module";
relationships[c].push(r);
}
formData.relationships = relationships;
return formData;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
// Assert that the final data is valid
this.form.disabled = true;
this.#tryUpdate(formData, {render: false});
// Prepare request data
let requestData;
if ( this.#source ) {
requestData = this.#source.updateSource(formData, {dryRun: true});
requestData.id = this.#source.id;
}
else {
requestData = this.#module.toObject();
if ( game.modules.has(requestData.id) ) {
const msg = game.i18n.format("PACKAGE.ModuleCreateErrorAlreadyExists", {id: this.#module.id});
ui.notifications.error(msg, {console: false});
throw new Error(msg);
}
}
requestData.action = "manageModule";
// Submit the module management request
await Setup.post(requestData);
const msg = this.#source ? "PACKAGE.ModuleEditSuccess" : "PACKAGE.ModuleCreateSuccess";
ui.notifications.info(game.i18n.format(msg, {id: this.#module.id}));
return Setup.reload();
}
/* -------------------------------------------- */
/**
* Attempt to update the working Module instance, displaying error messages for any validation failures.
* @param {object} changes Proposed changes to the Module source
* @param {object} [options] Additional options
* @param {boolean} [options.render] Re-render the app?
*/
#tryUpdate(changes, {render=true}={}) {
try {
this.#module.updateSource(changes);
} catch(err) {
ui.notifications.error(err.message);
this.form.disabled = false;
throw err;
}
if ( render ) this.render();
}
}
/**
* A class responsible for managing a server-side operation's progress lifecycle.
*/
class ProgressReceiver extends foundry.utils.EventEmitterMixin(Object) {
/**
* @typedef {object} ProgressReceiverPacket
* @property {string} action The progress action.
* @property {string} id The operation identifier.
* @property {number} pct The progress percentage.
* @property {string} step The individual step in the action.
* @property {string} [message] A text status message.
* @property {string} [title] The title of the entry. If not provided, the ID is used instead.
*/
/**
* @typedef {object} ProgressReceiverOptions
* @property {boolean} [notify=true] Spawn UI notifications during the lifecycle events.
* @property {string} [title] A human-readable title for the operation.
* @property {string} [successMessage] A message to display on operation success.
* @property {string} [failureMessage] A message to display on operation failure.
*/
/**
* @callback ProgressReceiverProgress
* @param {ProgressReceiverPacket} data The progress packet.
*/
/**
* @callback ProgressReceiverComplete
* @param {ProgressReceiverPacket} data Completion event data.
* @returns {void}
*/
/**
* @param {string} operationId A unique identifier for the operation.
* @param {string} action The operation action.
* @param {object} [context] Additional context to send with the request.
* @param {ProgressReceiverOptions} [options]
*/
constructor(operationId, action, context={}, options={}) {
super();
this.#operationId = operationId;
this.#action = action;
this.#context = context;
this.#options = { notify: true, ...options };
}
static emittedEvents = ["progress", "error", "complete"];
/**
* The operation action.
* @type {string}
*/
#action;
/**
* Additional context to send with the request.
* @type {object}
*/
#context;
/**
* Additional options to configure behavior.
* @type {ProgressReceiverOptions}
*/
#options;
/**
* A unique identifier for the operation.
* @type {string}
*/
get operationId() {
return this.#operationId;
}
#operationId;
/**
* The progress listener.
* @type {function}
*/
#progressListener = this._onProgress.bind(this);
/**
* A callback to invoke on operation success.
* @type {function}
*/
#resolve;
/* -------------------------------------------- */
/**
* Handle operation completion.
* @param {ProgressReceiverPacket} data Completion event data.
* @protected
*/
_onComplete(data) {
const { notify, successMessage } = this.#options;
if ( notify && successMessage ) ui.notifications.info(successMessage);
Setup._removeProgressListener(this.#progressListener);
const event = new Event("complete");
event.data = data;
this.dispatchEvent(event);
this.#resolve(data);
}
/* -------------------------------------------- */
/**
* Handle an error during the operation.
* @param {object} data Error event data.
* @param {string} data.error The error message.
* @param {string} data.stack The error stack.
* @protected
*/
_onError(data) {
const { error, stack, ...context } = data;
const { notify, failureMessage } = this.#options;
const err = new Error(error);
err.stack = stack;
err.context = context;
if ( notify && failureMessage ) ui.notifications.error(failureMessage, { console: false, permanent: true });
console.error(err);
ui.setupPackages?.removeProgressBar(this.#operationId);
Setup._removeProgressListener(this.#progressListener);
const event = new Event("error");
event.data = data;
this.dispatchEvent(event);
this.#resolve(err);
}
/* -------------------------------------------- */
/**
* Handle progress ticks.
* @param {ProgressReceiverPacket} data Progress event data.
* @protected
*/
_onProgress(data) {
const { STEPS } = CONST.SETUP_PACKAGE_PROGRESS;
const { action, step, id } = data;
if ( (action !== this.#action) || (id !== this.operationId) ) return;
if ( (this.#options.title !== undefined) && !("title" in data) ) data.title = this.#options.title;
ui.setupPackages?.onProgress(data);
const event = new Event("progress");
event.data = data;
this.dispatchEvent(event);
if ( step === STEPS.ERROR ) return this._onError(data);
if ( step === STEPS.COMPLETE ) return this._onComplete(data);
}
/* -------------------------------------------- */
/**
* Handle a warning during the operation.
* @param {object} data Warning event data.
* @param {string} data.warning The warning message.
* @protected
*/
_onWarning({ warning }) {
if ( this.#options.notify ) ui.notifications.warn(warning);
}
/* -------------------------------------------- */
/**
* Fire the request and begin listening for progress events.
* @returns {Promise}
*/
listen() {
return new Promise(async (resolve, reject) => {
this.#resolve = resolve;
Setup._addProgressListener(this.#progressListener);
let response;
try {
response = await Setup.post({ ...this.#context, action: this.#action });
} catch(err) {
Setup._removeProgressListener(this.#progressListener);
return reject(err);
}
if ( response.error ) this._onError(response);
if ( response.warning ) this._onWarning(response);
});
}
}
/**
* @typedef {import("../types.mjs").Constructor} Constructor
*/
/**
* @callback EmittedEventListener
* @param {Event} event The emitted event
* @returns {any}
*/
/**
* Augment a base class with EventEmitter behavior.
* @template {Constructor} BaseClass
* @param {BaseClass} BaseClass Some base class augmented with event emitter functionality
*/
function EventEmitterMixin(BaseClass) {
/**
* A mixin class which implements the behavior of EventTarget.
* This is useful in cases where a class wants EventTarget-like behavior but needs to extend some other class.
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget
*/
class EventEmitter extends BaseClass {
/**
* An array of event types which are valid for this class.
* @type {string[]}
*/
static emittedEvents = [];
/**
* A mapping of registered events.
* @type {Record>}
*/
#events = {};
/* -------------------------------------------- */
/**
* Add a new event listener for a certain type of event.
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
* @param {string} type The type of event being registered for
* @param {EmittedEventListener} listener The listener function called when the event occurs
* @param {object} [options={}] Options which configure the event listener
* @param {boolean} [options.once=false] Should the event only be responded to once and then removed
*/
addEventListener(type, listener, {once = false} = {}) {
if ( !this.constructor.emittedEvents.includes(type) ) {
throw new Error(`"${type}" is not a supported event of the ${this.constructor.name} class`);
}
this.#events[type] ||= new Map();
this.#events[type].set(listener, {fn: listener, once});
}
/* -------------------------------------------- */
/**
* Remove an event listener for a certain type of event.
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
* @param {string} type The type of event being removed
* @param {EmittedEventListener} listener The listener function being removed
*/
removeEventListener(type, listener) {
this.#events[type]?.delete(listener);
}
/* -------------------------------------------- */
/**
* Dispatch an event on this target.
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent
* @param {Event} event The Event to dispatch
* @returns {boolean} Was default behavior for the event prevented?
*/
dispatchEvent(event) {
if ( !(event instanceof Event) ) {
throw new Error("EventEmitter#dispatchEvent must be provided an Event instance");
}
if ( !this.constructor.emittedEvents.includes(event?.type) ) {
throw new Error(`"${event.type}" is not a supported event of the ${this.constructor.name} class`);
}
const listeners = this.#events[event.type];
if ( !listeners ) return true;
// Extend and configure the Event
Object.defineProperties(event, {
target: {value: this},
stopPropagation: {value: function() {
event.propagationStopped = true;
Event.prototype.stopPropagation.call(this);
}},
stopImmediatePropagation: {value: function() {
event.propagationStopped = true;
Event.prototype.stopImmediatePropagation.call(this);
}}
});
// Call registered listeners
for ( const listener of listeners.values() ) {
listener.fn(event);
if ( listener.once ) this.removeEventListener(event.type, listener.fn);
if ( event.propagationStopped ) break;
}
return event.defaultPrevented;
}
}
return EventEmitter;
}
/**
* A simple Semaphore implementation which provides a limited queue for ensuring proper concurrency.
* @param {number} [max=1] The maximum number of tasks which are allowed concurrently.
*
* @example Using a Semaphore
* ```js
* // Some async function that takes time to execute
* function fn(x) {
* return new Promise(resolve => {
* setTimeout(() => {
* console.log(x);
* resolve(x);
* }, 1000));
* }
* };
*
* // Create a Semaphore and add many concurrent tasks
* const semaphore = new Semaphore(1);
* for ( let i of Array.fromRange(100) ) {
* semaphore.add(fn, i);
* }
* ```
*/
class Semaphore {
constructor(max=1) {
/**
* The maximum number of tasks which can be simultaneously attempted.
* @type {number}
*/
this.max = max;
/**
* A queue of pending function signatures
* @type {Array>}
* @private
*/
this._queue = [];
/**
* The number of tasks which are currently underway
* @type {number}
* @private
*/
this._active = 0;
}
/**
* The number of pending tasks remaining in the queue
* @type {number}
*/
get remaining() {
return this._queue.length;
}
/**
* The number of actively executing tasks
* @type {number}
*/
get active() {
return this._active;
}
/**
* Add a new tasks to the managed queue
* @param {Function} fn A callable function
* @param {...*} [args] Function arguments
* @returns {Promise} A promise that resolves once the added function is executed
*/
add(fn, ...args) {
return new Promise((resolve, reject) => {
this._queue.push([fn, args, resolve, reject]);
return this._try();
});
}
/**
* Abandon any tasks which have not yet concluded
*/
clear() {
this._queue = [];
}
/**
* Attempt to perform a task from the queue.
* If all workers are busy, do nothing.
* If successful, try again.
* @private
*/
async _try() {
if ( (this.active === this.max) || !this.remaining ) return false;
// Obtain the next task from the queue
const next = this._queue.shift();
if ( !next ) return;
this._active += 1;
// Try and execute it, resolving its promise
const [fn, args, resolve, reject] = next;
try {
const r = await fn(...args);
resolve(r);
}
catch(err) {
reject(err);
}
// Try the next function in the queue
this._active -= 1;
return this._try();
}
}
/**
* @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
*/
class ApplicationV2 extends EventEmitterMixin(Object) {
/**
* Applications are constructed by providing an object of configuration options.
* @param {Partial} [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}
*/
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}
*/
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} 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} 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} 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} 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}
* @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} 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}
* @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 = `
<${this.options.window.contentTag} class="${contentClasses}">
${this.options.window.resizable ? `` : ""}`;
// 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 = ``;
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} 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} 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} [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}
*/
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}
*/
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} 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}
* @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}
* @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}
* @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}
* @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}
* @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();
}
}
/**
* @typedef {import("../types.mjs").ColorSource} ColorSource
*/
/**
* A representation of a color in hexadecimal format.
* This class provides methods for transformations and manipulations of colors.
*/
class Color extends Number {
/**
* Is this a valid color?
* @type {boolean}
*/
get valid() {
const v = this.valueOf();
return Number.isInteger(v) && v >= 0 && v <= 0xFFFFFF;
}
/* ------------------------------------------ */
/**
* A CSS-compatible color string.
* If this color is not valid, the empty string is returned.
* An alias for Color#toString.
* @type {string}
*/
get css() {
return this.toString(16);
}
/* ------------------------------------------ */
/**
* The color represented as an RGB array.
* @type {[number, number, number]}
*/
get rgb() {
return [((this >> 16) & 0xFF) / 255, ((this >> 8) & 0xFF) / 255, (this & 0xFF) / 255];
}
/* ------------------------------------------ */
/**
* The numeric value of the red channel between [0, 1].
* @type {number}
*/
get r() {
return ((this >> 16) & 0xFF) / 255;
}
/* ------------------------------------------ */
/**
* The numeric value of the green channel between [0, 1].
* @type {number}
*/
get g() {
return ((this >> 8) & 0xFF) / 255;
}
/* ------------------------------------------ */
/**
* The numeric value of the blue channel between [0, 1].
* @type {number}
*/
get b() {
return (this & 0xFF) / 255;
}
/* ------------------------------------------ */
/**
* The maximum value of all channels.
* @type {number}
*/
get maximum() {
return Math.max(...this);
}
/* ------------------------------------------ */
/**
* The minimum value of all channels.
* @type {number}
*/
get minimum() {
return Math.min(...this);
}
/* ------------------------------------------ */
/**
* Get the value of this color in little endian format.
* @type {number}
*/
get littleEndian() {
return ((this >> 16) & 0xFF) + (this & 0x00FF00) + ((this & 0xFF) << 16);
}
/* ------------------------------------------ */
/**
* The color represented as an HSV array.
* Conversion formula adapted from http://en.wikipedia.org/wiki/HSV_color_space.
* Assumes r, g, and b are contained in the set [0, 1] and returns h, s, and v in the set [0, 1].
* @type {[number, number, number]}
*/
get hsv() {
const [r, g, b] = this.rgb;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h;
const s = max === 0 ? 0 : d / max;
const v = max;
// Achromatic colors
if (max === min) return [0, s, v];
// Normal colors
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
return [h, s, v];
}
/* ------------------------------------------ */
/**
* The color represented as an HSL array.
* Assumes r, g, and b are contained in the set [0, 1] and returns h, s, and l in the set [0, 1].
* @type {[number, number, number]}
*/
get hsl() {
const [r, g, b] = this.rgb;
// Compute luminosity, saturation and hue
const l = Math.max(r, g, b);
const s = l - Math.min(r, g, b);
let h = 0;
if ( s > 0 ) {
if ( l === r ) {
h = (g - b) / s;
} else if ( l === g ) {
h = 2 + (b - r) / s;
} else {
h = 4 + (r - g) / s;
}
}
const finalHue = (60 * h < 0 ? 60 * h + 360 : 60 * h) / 360;
const finalSaturation = s ? (l <= 0.5 ? s / (2 * l - s) : s / (2 - (2 * l - s))) : 0;
const finalLuminance = (2 * l - s) / 2;
return [finalHue, finalSaturation, finalLuminance];
}
/* ------------------------------------------ */
/**
* The color represented as a linear RGB array.
* Assumes r, g, and b are contained in the set [0, 1] and returns linear r, g, and b in the set [0, 1].
* @link https://en.wikipedia.org/wiki/SRGB#Transformation
* @type {Color}
*/
get linear() {
const toLinear = c => (c > 0.04045) ? Math.pow((c + 0.055) / 1.055, 2.4) : (c / 12.92);
return this.constructor.fromRGB([toLinear(this.r), toLinear(this.g), toLinear(this.b)]);
}
/* ------------------------------------------ */
/* Color Manipulation Methods */
/* ------------------------------------------ */
/** @override */
toString(radix) {
if ( !this.valid ) return "";
return `#${super.toString(16).padStart(6, "0")}`;
}
/* ------------------------------------------ */
/**
* Serialize the Color.
* @returns {string} The color as a CSS string
*/
toJSON() {
return this.css;
}
/* ------------------------------------------ */
/**
* Returns the color as a CSS string.
* @returns {string} The color as a CSS string
*/
toHTML() {
return this.css;
}
/* ------------------------------------------ */
/**
* Test whether this color equals some other color
* @param {Color|number} other Some other color or hex number
* @returns {boolean} Are the colors equal?
*/
equals(other) {
return this.valueOf() === other.valueOf();
}
/* ------------------------------------------ */
/**
* Get a CSS-compatible RGBA color string.
* @param {number} alpha The desired alpha in the range [0, 1]
* @returns {string} A CSS-compatible RGBA string
*/
toRGBA(alpha) {
const rgba = [(this >> 16) & 0xFF, (this >> 8) & 0xFF, this & 0xFF, alpha];
return `rgba(${rgba.join(", ")})`;
}
/* ------------------------------------------ */
/**
* Mix this Color with some other Color using a provided interpolation weight.
* @param {Color} other Some other Color to mix with
* @param {number} weight The mixing weight placed on this color where weight is placed on the other color
* @returns {Color} The resulting mixed Color
*/
mix(other, weight) {
return new Color(Color.mix(this, other, weight));
}
/* ------------------------------------------ */
/**
* Multiply this Color by another Color or a static scalar.
* @param {Color|number} other Some other Color or a static scalar.
* @returns {Color} The resulting Color.
*/
multiply(other) {
if ( other instanceof Color ) return new Color(Color.multiply(this, other));
return new Color(Color.multiplyScalar(this, other));
}
/* ------------------------------------------ */
/**
* Add this Color by another Color or a static scalar.
* @param {Color|number} other Some other Color or a static scalar.
* @returns {Color} The resulting Color.
*/
add(other) {
if ( other instanceof Color ) return new Color(Color.add(this, other));
return new Color(Color.addScalar(this, other));
}
/* ------------------------------------------ */
/**
* Subtract this Color by another Color or a static scalar.
* @param {Color|number} other Some other Color or a static scalar.
* @returns {Color} The resulting Color.
*/
subtract(other) {
if ( other instanceof Color ) return new Color(Color.subtract(this, other));
return new Color(Color.subtractScalar(this, other));
}
/* ------------------------------------------ */
/**
* Max this color by another Color or a static scalar.
* @param {Color|number} other Some other Color or a static scalar.
* @returns {Color} The resulting Color.
*/
maximize(other) {
if ( other instanceof Color ) return new Color(Color.maximize(this, other));
return new Color(Color.maximizeScalar(this, other));
}
/* ------------------------------------------ */
/**
* Min this color by another Color or a static scalar.
* @param {Color|number} other Some other Color or a static scalar.
* @returns {Color} The resulting Color.
*/
minimize(other) {
if ( other instanceof Color ) return new Color(Color.minimize(this, other));
return new Color(Color.minimizeScalar(this, other));
}
/* ------------------------------------------ */
/* Iterator */
/* ------------------------------------------ */
/**
* Iterating over a Color is equivalent to iterating over its [r,g,b] color channels.
* @returns {Generator}
*/
*[Symbol.iterator]() {
yield this.r;
yield this.g;
yield this.b;
}
/* ------------------------------------------------------------------------------------------- */
/* Real-time performance Methods and Properties */
/* Important Note: */
/* These methods are not a replacement, but a tool when real-time performance is needed. */
/* They do not have the flexibility of the "classic" methods and come with some limitations. */
/* Unless you have to deal with real-time performance, you should use the "classic" methods. */
/* ------------------------------------------------------------------------------------------- */
/**
* Set an rgb array with the rgb values contained in this Color class.
* @param {number[]} vec3 Receive the result. Must be an array with at least a length of 3.
*/
applyRGB(vec3) {
vec3[0] = ((this >> 16) & 0xFF) / 255;
vec3[1] = ((this >> 8) & 0xFF) / 255;
vec3[2] = (this & 0xFF) / 255;
}
/* ------------------------------------------ */
/**
* Apply a linear interpolation between two colors, according to the weight.
* @param {number} color1 The first color to mix.
* @param {number} color2 The second color to mix.
* @param {number} weight Weight of the linear interpolation.
* @returns {number} The resulting mixed color
*/
static mix(color1, color2, weight) {
return (((((color1 >> 16) & 0xFF) * (1 - weight) + ((color2 >> 16) & 0xFF) * weight) << 16) & 0xFF0000)
| (((((color1 >> 8) & 0xFF) * (1 - weight) + ((color2 >> 8) & 0xFF) * weight) << 8) & 0x00FF00)
| (((color1 & 0xFF) * (1 - weight) + (color2 & 0xFF) * weight) & 0x0000FF);
}
/* ------------------------------------------ */
/**
* Multiply two colors.
* @param {number} color1 The first color to multiply.
* @param {number} color2 The second color to multiply.
* @returns {number} The result.
*/
static multiply(color1, color2) {
return ((((color1 >> 16) & 0xFF) / 255 * ((color2 >> 16) & 0xFF) / 255) * 255 << 16)
| ((((color1 >> 8) & 0xFF) / 255 * ((color2 >> 8) & 0xFF) / 255) * 255 << 8)
| (((color1 & 0xFF) / 255 * ((color2 & 0xFF) / 255)) * 255);
}
/* ------------------------------------------ */
/**
* Multiply a color by a scalar
* @param {number} color The color to multiply.
* @param {number} scalar A static scalar to multiply with.
* @returns {number} The resulting color as a number.
*/
static multiplyScalar(color, scalar) {
return (Math.clamp(((color >> 16) & 0xFF) / 255 * scalar, 0, 1) * 255 << 16)
| (Math.clamp(((color >> 8) & 0xFF) / 255 * scalar, 0, 1) * 255 << 8)
| (Math.clamp((color & 0xFF) / 255 * scalar, 0, 1) * 255);
}
/* ------------------------------------------ */
/**
* Maximize two colors.
* @param {number} color1 The first color.
* @param {number} color2 The second color.
* @returns {number} The result.
*/
static maximize(color1, color2) {
return (Math.clamp(Math.max((color1 >> 16) & 0xFF, (color2 >> 16) & 0xFF), 0, 0xFF) << 16)
| (Math.clamp(Math.max((color1 >> 8) & 0xFF, (color2 >> 8) & 0xFF), 0, 0xFF) << 8)
| Math.clamp(Math.max(color1 & 0xFF, color2 & 0xFF), 0, 0xFF);
}
/* ------------------------------------------ */
/**
* Maximize a color by a static scalar.
* @param {number} color The color to maximize.
* @param {number} scalar Scalar to maximize with (normalized).
* @returns {number} The resulting color as a number.
*/
static maximizeScalar(color, scalar) {
return (Math.clamp(Math.max((color >> 16) & 0xFF, scalar * 255), 0, 0xFF) << 16)
| (Math.clamp(Math.max((color >> 8) & 0xFF, scalar * 255), 0, 0xFF) << 8)
| Math.clamp(Math.max(color & 0xFF, scalar * 255), 0, 0xFF);
}
/* ------------------------------------------ */
/**
* Add two colors.
* @param {number} color1 The first color.
* @param {number} color2 The second color.
* @returns {number} The resulting color as a number.
*/
static add(color1, color2) {
return (Math.clamp((((color1 >> 16) & 0xFF) + ((color2 >> 16) & 0xFF)), 0, 0xFF) << 16)
| (Math.clamp((((color1 >> 8) & 0xFF) + ((color2 >> 8) & 0xFF)), 0, 0xFF) << 8)
| Math.clamp(((color1 & 0xFF) + (color2 & 0xFF)), 0, 0xFF);
}
/* ------------------------------------------ */
/**
* Add a static scalar to a color.
* @param {number} color The color.
* @param {number} scalar Scalar to add with (normalized).
* @returns {number} The resulting color as a number.
*/
static addScalar(color, scalar) {
return (Math.clamp((((color >> 16) & 0xFF) + scalar * 255), 0, 0xFF) << 16)
| (Math.clamp((((color >> 8) & 0xFF) + scalar * 255), 0, 0xFF) << 8)
| Math.clamp(((color & 0xFF) + scalar * 255), 0, 0xFF);
}
/* ------------------------------------------ */
/**
* Subtract two colors.
* @param {number} color1 The first color.
* @param {number} color2 The second color.
*/
static subtract(color1, color2) {
return (Math.clamp((((color1 >> 16) & 0xFF) - ((color2 >> 16) & 0xFF)), 0, 0xFF) << 16)
| (Math.clamp((((color1 >> 8) & 0xFF) - ((color2 >> 8) & 0xFF)), 0, 0xFF) << 8)
| Math.clamp(((color1 & 0xFF) - (color2 & 0xFF)), 0, 0xFF);
}
/* ------------------------------------------ */
/**
* Subtract a color by a static scalar.
* @param {number} color The color.
* @param {number} scalar Scalar to subtract with (normalized).
* @returns {number} The resulting color as a number.
*/
static subtractScalar(color, scalar) {
return (Math.clamp((((color >> 16) & 0xFF) - scalar * 255), 0, 0xFF) << 16)
| (Math.clamp((((color >> 8) & 0xFF) - scalar * 255), 0, 0xFF) << 8)
| Math.clamp(((color & 0xFF) - scalar * 255), 0, 0xFF);
}
/* ------------------------------------------ */
/**
* Minimize two colors.
* @param {number} color1 The first color.
* @param {number} color2 The second color.
*/
static minimize(color1, color2) {
return (Math.clamp(Math.min((color1 >> 16) & 0xFF, (color2 >> 16) & 0xFF), 0, 0xFF) << 16)
| (Math.clamp(Math.min((color1 >> 8) & 0xFF, (color2 >> 8) & 0xFF), 0, 0xFF) << 8)
| Math.clamp(Math.min(color1 & 0xFF, color2 & 0xFF), 0, 0xFF);
}
/* ------------------------------------------ */
/**
* Minimize a color by a static scalar.
* @param {number} color The color.
* @param {number} scalar Scalar to minimize with (normalized).
*/
static minimizeScalar(color, scalar) {
return (Math.clamp(Math.min((color >> 16) & 0xFF, scalar * 255), 0, 0xFF) << 16)
| (Math.clamp(Math.min((color >> 8) & 0xFF, scalar * 255), 0, 0xFF) << 8)
| Math.clamp(Math.min(color & 0xFF, scalar * 255), 0, 0xFF);
}
/* ------------------------------------------ */
/**
* Convert a color to RGB and assign values to a passed array.
* @param {number} color The color to convert to RGB values.
* @param {number[]} vec3 Receive the result. Must be an array with at least a length of 3.
*/
static applyRGB(color, vec3) {
vec3[0] = ((color >> 16) & 0xFF) / 255;
vec3[1] = ((color >> 8) & 0xFF) / 255;
vec3[2] = (color & 0xFF) / 255;
}
/* ------------------------------------------ */
/* Factory Methods */
/* ------------------------------------------ */
/**
* Create a Color instance from an RGB array.
* @param {ColorSource} color A color input
* @returns {Color} The hex color instance or NaN
*/
static from(color) {
if ( (color === null) || (color === undefined) ) return new this(NaN);
if ( typeof color === "string" ) return this.fromString(color);
if ( typeof color === "number" ) return new this(color);
if ( (color instanceof Array) && (color.length === 3) ) return this.fromRGB(color);
if ( color instanceof Color ) return color;
return new this(color);
}
/* ------------------------------------------ */
/**
* Create a Color instance from a color string which either includes or does not include a leading #.
* @param {string} color A color string
* @returns {Color} The hex color instance
*/
static fromString(color) {
return new this(parseInt(color.startsWith("#") ? color.substring(1) : color, 16));
}
/* ------------------------------------------ */
/**
* Create a Color instance from an RGB array.
* @param {[number, number, number]} rgb An RGB tuple
* @returns {Color} The hex color instance
*/
static fromRGB(rgb) {
return new this(((rgb[0] * 255) << 16) + ((rgb[1] * 255) << 8) + (rgb[2] * 255 | 0));
}
/* ------------------------------------------ */
/**
* Create a Color instance from an RGB normalized values.
* @param {number} r The red value
* @param {number} g The green value
* @param {number} b The blue value
* @returns {Color} The hex color instance
*/
static fromRGBvalues(r, g, b) {
return new this(((r * 255) << 16) + ((g * 255) << 8) + (b * 255 | 0));
}
/* ------------------------------------------ */
/**
* Create a Color instance from an HSV array.
* Conversion formula adapted from http://en.wikipedia.org/wiki/HSV_color_space.
* Assumes h, s, and v are contained in the set [0, 1].
* @param {[number, number, number]} hsv An HSV tuple
* @returns {Color} The hex color instance
*/
static fromHSV(hsv) {
const [h, s, v] = hsv;
const i = Math.floor(h * 6);
const f = (h * 6) - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
let rgb;
switch (i % 6) {
case 0: rgb = [v, t, p]; break;
case 1: rgb = [q, v, p]; break;
case 2: rgb = [p, v, t]; break;
case 3: rgb = [p, q, v]; break;
case 4: rgb = [t, p, v]; break;
case 5: rgb = [v, p, q]; break;
}
return this.fromRGB(rgb);
}
/* ------------------------------------------ */
/**
* Create a Color instance from an HSL array.
* Assumes h, s, and l are contained in the set [0, 1].
* @param {[number, number, number]} hsl An HSL tuple
* @returns {Color} The hex color instance
*/
static fromHSL(hsl) {
const [h, s, l] = hsl;
// Calculate intermediate values for the RGB components
const chroma = (1 - Math.abs(2 * l - 1)) * s;
const hue = h * 6;
const x = chroma * (1 - Math.abs(hue % 2 - 1));
const m = l - chroma / 2;
let r, g, b;
switch (Math.floor(hue)) {
case 0: [r, g, b] = [chroma, x, 0]; break;
case 1: [r, g, b] = [x, chroma, 0]; break;
case 2: [r, g, b] = [0, chroma, x]; break;
case 3: [r, g, b] = [0, x, chroma]; break;
case 4: [r, g, b] = [x, 0, chroma]; break;
case 5:
case 6:[r, g, b] = [chroma, 0, x]; break;
default: [r, g, b] = [0, 0, 0]; break;
}
// Adjust for luminance
r += m;
g += m;
b += m;
return this.fromRGB([r, g, b]);
}
/* ------------------------------------------ */
/**
* Create a Color instance (sRGB) from a linear rgb array.
* Assumes r, g, and b are contained in the set [0, 1].
* @link https://en.wikipedia.org/wiki/SRGB#Transformation
* @param {[number, number, number]} linear The linear rgb array
* @returns {Color} The hex color instance
*/
static fromLinearRGB(linear) {
const [r, g, b] = linear;
const tosrgb = c => (c <= 0.0031308) ? (12.92 * c) : (1.055 * Math.pow(c, 1 / 2.4) - 0.055);
return this.fromRGB([tosrgb(r), tosrgb(g), tosrgb(b)]);
}
}
/** @module constants */
/**
* Valid Chat Message styles which affect how the message is presented in the chat log.
* @enum {number}
*/
const CHAT_MESSAGE_STYLES = {
/**
* An uncategorized chat message
*/
OTHER: 0,
/**
* The message is spoken out of character (OOC).
* OOC messages will be outlined by the player's color to make them more easily recognizable.
*/
OOC: 1,
/**
* The message is spoken by an associated character.
*/
IC: 2,
/**
* The message is an emote performed by the selected character.
* Entering "/emote waves his hand." while controlling a character named Simon will send the message, "Simon waves his hand."
*/
EMOTE: 3,
};
/**
* The primary Document types.
* @type {string[]}
*/
const PRIMARY_DOCUMENT_TYPES = [
"Actor",
"Adventure",
"Cards",
"ChatMessage",
"Combat",
"FogExploration",
"Folder",
"Item",
"JournalEntry",
"Macro",
"Playlist",
"RollTable",
"Scene",
"Setting",
"User"
];
/**
* The embedded Document types.
* @type {Readonly}
*/
const EMBEDDED_DOCUMENT_TYPES = [
"ActiveEffect",
"ActorDelta",
"AmbientLight",
"AmbientSound",
"Card",
"Combatant",
"Drawing",
"Item",
"JournalEntryPage",
"MeasuredTemplate",
"Note",
"PlaylistSound",
"Region",
"RegionBehavior",
"TableResult",
"Tile",
"Token",
"Wall"
];
/**
* A listing of all valid Document types, both primary and embedded.
* @type {Readonly}
*/
Array.from(new Set([
...PRIMARY_DOCUMENT_TYPES,
...EMBEDDED_DOCUMENT_TYPES
])).sort();
/**
* The allowed primary Document types which may exist within a World.
* @type {string[]}
*/
const WORLD_DOCUMENT_TYPES = [
"Actor",
"Cards",
"ChatMessage",
"Combat",
"FogExploration",
"Folder",
"Item",
"JournalEntry",
"Macro",
"Playlist",
"RollTable",
"Scene",
"Setting",
"User"
];
/**
* Define the allowed User permission levels.
* Each level is assigned a value in ascending order. Higher levels grant more permissions.
* @enum {number}
* @see https://foundryvtt.com/article/users/
*/
const USER_ROLES = {
/**
* The User is blocked from taking actions in Foundry Virtual Tabletop.
* You can use this role to temporarily or permanently ban a user from joining the game.
*/
NONE: 0,
/**
* The User is able to join the game with permissions available to a standard player.
* They cannot take some more advanced actions which require Trusted permissions, but they have the basic functionalities needed to operate in the virtual tabletop.
*/
PLAYER: 1,
/**
* Similar to the Player role, except a Trusted User has the ability to perform some more advanced actions like create drawings, measured templates, or even to (optionally) upload media files to the server.
*/
TRUSTED: 2,
/**
* A special User who has many of the same in-game controls as a Game Master User, but does not have the ability to perform administrative actions like changing User roles or modifying World-level settings.
*/
ASSISTANT: 3,
/**
* A special User who has administrative control over this specific World.
* Game Masters behave quite differently than Players in that they have the ability to see all Documents and Objects within the world as well as the capability to configure World settings.
*/
GAMEMASTER: 4
};
/**
* Invert the User Role mapping to recover role names from a role integer
* @enum {string}
* @see USER_ROLES
*/
Object.entries(USER_ROLES).reduce((obj, r) => {
obj[r[1]] = r[0];
return obj;
}, {});
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
new Proxy(CHAT_MESSAGE_STYLES, {
get(target, prop, receiver) {
const msg = "CONST.CHAT_MESSAGE_TYPES is deprecated in favor of CONST.CHAT_MESSAGE_STYLES because the " +
"ChatMessage#type field has been renamed to ChatMessage#style";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return Reflect.get(...arguments);
}
});
// Deprecated chat message styles
Object.defineProperties(CHAT_MESSAGE_STYLES, {
/**
* @deprecated since v12
* @ignore
*/
ROLL: {
get() {
foundry.utils.logCompatibilityWarning("CONST.CHAT_MESSAGE_STYLES.ROLL is deprecated in favor of defining " +
"rolls directly in ChatMessage#rolls", {since: 12, until: 14, once: true});
return 0;
}
},
/**
* @deprecated since v12
* @ignore
*/
WHISPER: {
get() {
foundry.utils.logCompatibilityWarning("CONST.CHAT_MESSAGE_STYLES.WHISPER is deprecated in favor of defining " +
"whisper recipients directly in ChatMessage#whisper", {since: 12, until: 14, once: true});
return 0;
}
}
});
/**
* @deprecated since v12
* @ignore
*/
const _DOCUMENT_TYPES = Object.freeze(WORLD_DOCUMENT_TYPES.filter(t => {
const excluded = ["FogExploration", "Setting"];
return !excluded.includes(t);
}));
/**
* @deprecated since v12
* @ignore
*/
new Proxy(_DOCUMENT_TYPES, {
get(target, prop, receiver) {
const msg = "CONST.DOCUMENT_TYPES is deprecated in favor of either CONST.WORLD_DOCUMENT_TYPES or "
+ "CONST.COMPENDIUM_DOCUMENT_TYPES.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return Reflect.get(...arguments);
}
});
/* -------------------------------------------- */
/**
* Quickly clone a simple piece of data, returning a copy which can be mutated safely.
* This method DOES support recursive data structures containing inner objects or arrays.
* This method DOES NOT support advanced object types like Set, Map, or other specialized classes.
* @param {*} original Some sort of data
* @param {object} [options] Options to configure the behaviour of deepClone
* @param {boolean} [options.strict=false] Throw an Error if deepClone is unable to clone something instead of
* returning the original
* @param {number} [options._d] An internal depth tracker
* @return {*} The clone of that data
*/
function deepClone(original, {strict=false, _d=0}={}) {
if ( _d > 100 ) {
throw new Error("Maximum depth exceeded. Be sure your object does not contain cyclical data structures.");
}
_d++;
// Simple types
if ( (typeof original !== "object") || (original === null) ) return original;
// Arrays
if ( original instanceof Array ) return original.map(o => deepClone(o, {strict, _d}));
// Dates
if ( original instanceof Date ) return new Date(original);
// Unsupported advanced objects
if ( original.constructor && (original.constructor !== Object) ) {
if ( strict ) throw new Error("deepClone cannot clone advanced objects");
return original;
}
// Other objects
const clone = {};
for ( let k of Object.keys(original) ) {
clone[k] = deepClone(original[k], {strict, _d});
}
return clone;
}
/* -------------------------------------------- */
/**
* Expand a flattened object to be a standard nested Object by converting all dot-notation keys to inner objects.
* Only simple objects will be expanded. Other Object types like class instances will be retained as-is.
* @param {object} obj The object to expand
* @return {object} An expanded object
*/
function expandObject(obj) {
function _expand(value, depth) {
if ( depth > 32 ) throw new Error("Maximum object expansion depth exceeded");
if ( !value ) return value;
if ( Array.isArray(value) ) return value.map(v => _expand(v, depth+1)); // Map arrays
if ( value.constructor?.name !== "Object" ) return value; // Return advanced objects directly
const expanded = {}; // Expand simple objects
for ( let [k, v] of Object.entries(value) ) {
setProperty(expanded, k, _expand(v, depth+1));
}
return expanded;
}
return _expand(obj, 0);
}
/* -------------------------------------------- */
/**
* Learn the underlying data type of some variable. Supported identifiable types include:
* undefined, null, number, string, boolean, function, Array, Set, Map, Promise, Error,
* HTMLElement (client side only), Object (catchall for other object types)
* @param {*} variable A provided variable
* @return {string} The named type of the token
*/
function getType(variable) {
// Primitive types, handled with simple typeof check
const typeOf = typeof variable;
if ( typeOf !== "object" ) return typeOf;
// Special cases of object
if ( variable === null ) return "null";
if ( !variable.constructor ) return "Object"; // Object with the null prototype.
if ( variable.constructor.name === "Object" ) return "Object"; // simple objects
// Match prototype instances
const prototypes = [
[Array, "Array"],
[Set, "Set"],
[Map, "Map"],
[Promise, "Promise"],
[Error, "Error"],
[Color, "number"]
];
if ( "HTMLElement" in globalThis ) prototypes.push([globalThis.HTMLElement, "HTMLElement"]);
for ( const [cls, type] of prototypes ) {
if ( variable instanceof cls ) return type;
}
// Unknown Object type
return "Object";
}
/* -------------------------------------------- */
/**
* A helper function which searches through an object to assign a value using a string key
* This string key supports the notation a.b.c which would target object[a][b][c]
* @param {object} object The object to update
* @param {string} key The string key
* @param {*} value The value to be assigned
* @return {boolean} Whether the value was changed from its previous value
*/
function setProperty(object, key, value) {
if ( !key ) return false;
// Convert the key to an object reference if it contains dot notation
let target = object;
if ( key.indexOf('.') !== -1 ) {
let parts = key.split('.');
key = parts.pop();
target = parts.reduce((o, i) => {
if ( !o.hasOwnProperty(i) ) o[i] = {};
return o[i];
}, object);
}
// Update the target
if ( !(key in target) || (target[key] !== value) ) {
target[key] = value;
return true;
}
return false;
}
/* -------------------------------------------- */
/**
* Test whether a value is empty-like; either undefined or a content-less object.
* @param {*} value The value to test
* @returns {boolean} Is the value empty-like?
*/
function isEmpty(value) {
const t = getType(value);
switch ( t ) {
case "undefined":
return true;
case "null":
return true;
case "Array":
return !value.length;
case "Object":
return !Object.keys(value).length;
case "Set":
case "Map":
return !value.size;
default:
return false;
}
}
/* -------------------------------------------- */
/**
* Update a source object by replacing its keys and values with those from a target object.
*
* @param {object} original The initial object which should be updated with values from the
* target
* @param {object} [other={}] A new object whose values should replace those in the source
* @param {object} [options={}] Additional options which configure the merge
* @param {boolean} [options.insertKeys=true] Control whether to insert new top-level objects into the resulting
* structure which do not previously exist in the original object.
* @param {boolean} [options.insertValues=true] Control whether to insert new nested values into child objects in
* the resulting structure which did not previously exist in the
* original object.
* @param {boolean} [options.overwrite=true] Control whether to replace existing values in the source, or only
* merge values which do not already exist in the original object.
* @param {boolean} [options.recursive=true] Control whether to merge inner-objects recursively (if true), or
* whether to simply replace inner objects with a provided new value.
* @param {boolean} [options.inplace=true] Control whether to apply updates to the original object in-place
* (if true), otherwise the original object is duplicated and the
* copy is merged.
* @param {boolean} [options.enforceTypes=false] Control whether strict type checking requires that the value of a
* key in the other object must match the data type in the original
* data to be merged.
* @param {boolean} [options.performDeletions=false] Control whether to perform deletions on the original object if
* deletion keys are present in the other object.
* @param {number} [_d=0] A privately used parameter to track recursion depth.
* @returns {object} The original source object including updated, inserted, or
* overwritten records.
*
* @example Control how new keys and values are added
* ```js
* mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: false}); // {k1: "v1"}
* mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: true}); // {k1: "v1", k2: "v2"}
* mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: false}); // {k1: {i1: "v1"}}
* mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: true}); // {k1: {i1: "v1", i2: "v2"}}
* ```
*
* @example Control how existing data is overwritten
* ```js
* mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: true}); // {k1: "v2"}
* mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: false}); // {k1: "v1"}
* ```
*
* @example Control whether merges are performed recursively
* ```js
* mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: false}); // {k1: {i2: "v2"}}
* mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: true}); // {k1: {i1: "v1", i2: "v2"}}
* ```
*
* @example Deleting an existing object key
* ```js
* mergeObject({k1: "v1", k2: "v2"}, {"-=k1": null}, {performDeletions: true}); // {k2: "v2"}
* ```
*/
function mergeObject(original, other={}, {
insertKeys=true, insertValues=true, overwrite=true, recursive=true, inplace=true, enforceTypes=false,
performDeletions=false
}={}, _d=0) {
other = other || {};
if (!(original instanceof Object) || !(other instanceof Object)) {
throw new Error("One of original or other are not Objects!");
}
const options = {insertKeys, insertValues, overwrite, recursive, inplace, enforceTypes, performDeletions};
// Special handling at depth 0
if ( _d === 0 ) {
if ( Object.keys(other).some(k => /\./.test(k)) ) other = expandObject(other);
if ( Object.keys(original).some(k => /\./.test(k)) ) {
const expanded = expandObject(original);
if ( inplace ) {
Object.keys(original).forEach(k => delete original[k]);
Object.assign(original, expanded);
}
else original = expanded;
}
else if ( !inplace ) original = deepClone(original);
}
// Iterate over the other object
for ( let k of Object.keys(other) ) {
const v = other[k];
if ( original.hasOwnProperty(k) ) _mergeUpdate(original, k, v, options, _d+1);
else _mergeInsert(original, k, v, options, _d+1);
}
return original;
}
/**
* A helper function for merging objects when the target key does not exist in the original
* @private
*/
function _mergeInsert(original, k, v, {insertKeys, insertValues, performDeletions}={}, _d) {
// Delete a key
if ( k.startsWith("-=") && performDeletions ) {
delete original[k.slice(2)];
return;
}
const canInsert = ((_d <= 1) && insertKeys) || ((_d > 1) && insertValues);
if ( !canInsert ) return;
// Recursively create simple objects
if ( v?.constructor === Object ) {
original[k] = mergeObject({}, v, {insertKeys: true, inplace: true, performDeletions});
return;
}
// Insert a key
original[k] = v;
}
/**
* A helper function for merging objects when the target key exists in the original
* @private
*/
function _mergeUpdate(original, k, v, {
insertKeys, insertValues, enforceTypes, overwrite, recursive, performDeletions
}={}, _d) {
const x = original[k];
const tv = getType(v);
const tx = getType(x);
// Recursively merge an inner object
if ( (tv === "Object") && (tx === "Object") && recursive) {
return mergeObject(x, v, {
insertKeys, insertValues, overwrite, enforceTypes, performDeletions,
inplace: true
}, _d);
}
// Overwrite an existing value
if ( overwrite ) {
if ( (tx !== "undefined") && (tv !== tx) && enforceTypes ) {
throw new Error(`Mismatched data types encountered during object merge.`);
}
original[k] = v;
}
}
/**
* @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration
*/
/**
* @typedef {Object} DialogV2Button
* @property {string} action The button action identifier.
* @property {string} label The button label. Will be localized.
* @property {string} [icon] FontAwesome icon classes.
* @property {string} [class] CSS classes to apply to the button.
* @property {boolean} [default] Whether this button represents the default action to take if the user
* submits the form without pressing a button, i.e. with an Enter
* keypress.
* @property {DialogV2ButtonCallback} [callback] A function to invoke when the button is clicked. The value returned
* from this function will be used as the dialog's submitted value.
* Otherwise, the button's identifier is used.
*/
/**
* @callback DialogV2ButtonCallback
* @param {PointerEvent|SubmitEvent} event The button click event, or a form submission event if the dialog was
* submitted via keyboard.
* @param {HTMLButtonElement} button If the form was submitted via keyboard, this will be the default
* button, otherwise the button that was clicked.
* @param {HTMLDialogElement} dialog The dialog element.
* @returns {Promise}
*/
/**
* @typedef {Object} DialogV2Configuration
* @property {boolean} [modal] Modal dialogs prevent interaction with the rest of the UI until they
* are dismissed or submitted.
* @property {DialogV2Button[]} buttons Button configuration.
* @property {string} [content] The dialog content.
* @property {DialogV2SubmitCallback} [submit] A function to invoke when the dialog is submitted. This will not be
* called if the dialog is dismissed.
*/
/**
* @callback DialogV2RenderCallback
* @param {Event} event The render event.
* @param {HTMLDialogElement} dialog The dialog element.
*/
/**
* @callback DialogV2CloseCallback
* @param {Event} event The close event.
* @param {DialogV2} dialog The dialog instance.
*/
/**
* @callback DialogV2SubmitCallback
* @param {any} result Either the identifier of the button that was clicked to submit the
* dialog, or the result returned by that button's callback.
* @returns {Promise}
*/
/**
* @typedef {object} DialogV2WaitOptions
* @property {DialogV2RenderCallback} [render] A synchronous function to invoke whenever the dialog is rendered.
* @property {DialogV2CloseCallback} [close] A synchronous function to invoke when the dialog is closed under any
* circumstances.
* @property {boolean} [rejectClose=true] Throw a Promise rejection if the dialog is dismissed.
*/
/**
* A lightweight Application that renders a dialog containing a form with arbitrary content, and some buttons.
* @extends {ApplicationV2}
*
* @example Prompt the user to confirm an action.
* ```js
* const proceed = await foundry.applications.api.DialogV2.confirm({
* content: "Are you sure?",
* rejectClose: false,
* modal: true
* });
* if ( proceed ) console.log("Proceed.");
* else console.log("Do not proceed.");
* ```
*
* @example Prompt the user for some input.
* ```js
* let guess;
* try {
* guess = await foundry.applications.api.DialogV2.prompt({
* window: { title: "Guess a number between 1 and 10" },
* content: '',
* ok: {
* label: "Submit Guess",
* callback: (event, button, dialog) => button.form.elements.guess.valueAsNumber
* }
* });
* } catch {
* console.log("User did not make a guess.");
* return;
* }
* const n = Math.ceil(CONFIG.Dice.randomUniform() * 10);
* if ( n === guess ) console.log("User guessed correctly.");
* else console.log("User guessed incorrectly.");
* ```
*
* @example A custom dialog.
* ```js
* new foundry.applications.api.DialogV2({
* window: { title: "Choose an option" },
* content: `
*
*
*
* `,
* buttons: [{
* action: "choice",
* label: "Make Choice",
* default: true,
* callback: (event, button, dialog) => button.form.elements.choice.value
* }, {
* action: "all",
* label: "Take All"
* }],
* submit: result => {
* if ( result === "all" ) console.log("User picked all options.");
* else console.log(`User picked option: ${result}`);
* }
* }).render({ force: true });
* ```
*/
class DialogV2 extends ApplicationV2 {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
id: "dialog-{id}",
classes: ["dialog"],
tag: "dialog",
form: {
closeOnSubmit: true
},
window: {
frame: true,
positioned: true,
minimizable: false
}
};
/* -------------------------------------------- */
/** @inheritDoc */
_initializeApplicationOptions(options) {
options = super._initializeApplicationOptions(options);
if ( !options.buttons?.length ) throw new Error("You must define at least one entry in options.buttons");
options.buttons = options.buttons.reduce((obj, button) => {
options.actions[button.action] = this.constructor._onClickButton;
obj[button.action] = button;
return obj;
}, {});
return options;
}
/* -------------------------------------------- */
/** @override */
async _renderHTML(_context, _options) {
const form = document.createElement("form");
form.className = "dialog-form standard-form";
form.autocomplete = "off";
form.innerHTML = `
${this.options.content ? `
${this.options.content}
` : ""}
`;
form.addEventListener("submit", event => this._onSubmit(event.submitter, event));
return form;
}
/* -------------------------------------------- */
/**
* Render configured buttons.
* @returns {string}
* @protected
*/
_renderButtons() {
return Object.values(this.options.buttons).map(button => {
const { action, label, icon, default: isDefault, class: cls="" } = button;
return `
`;
}).join("");
}
/* -------------------------------------------- */
/**
* Handle submitting the dialog.
* @param {HTMLButtonElement} target The button that was clicked or the default button.
* @param {PointerEvent|SubmitEvent} event The triggering event.
* @returns {Promise}
* @protected
*/
async _onSubmit(target, event) {
event.preventDefault();
const button = this.options.buttons[target?.dataset.action];
const result = (await button?.callback?.(event, target, this.element)) ?? button?.action;
await this.options.submit?.(result);
return this.options.form.closeOnSubmit ? this.close() : this;
}
/* -------------------------------------------- */
/** @override */
_onFirstRender(_context, _options) {
if ( this.options.modal ) this.element.showModal();
else this.element.show();
}
/* -------------------------------------------- */
/** @inheritDoc */
_attachFrameListeners() {
super._attachFrameListeners();
this.element.addEventListener("keydown", this._onKeyDown.bind(this));
}
/* -------------------------------------------- */
/** @override */
_replaceHTML(result, content, _options) {
content.replaceChildren(result);
}
/* -------------------------------------------- */
/**
* Handle keypresses within the dialog.
* @param {KeyboardEvent} event The triggering event.
* @protected
*/
_onKeyDown(event) {
// Capture Escape keypresses for dialogs to ensure that close is called properly.
if ( event.key === "Escape" ) {
event.preventDefault(); // Prevent default browser dialog dismiss behavior.
event.stopPropagation();
this.close();
}
}
/* -------------------------------------------- */
/**
* @this {DialogV2}
* @param {PointerEvent} event The originating click event.
* @param {HTMLButtonElement} target The button element that was clicked.
* @protected
*/
static _onClickButton(event, target) {
this._onSubmit(target, event);
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* A utility helper to generate a dialog with yes and no buttons.
* @param {Partial} [options]
* @param {DialogV2Button} [options.yes] Options to overwrite the default yes button configuration.
* @param {DialogV2Button} [options.no] Options to overwrite the default no button configuration.
* @returns {Promise} Resolves to true if the yes button was pressed, or false if the no button
* was pressed. If additional buttons were provided, the Promise resolves to
* the identifier of the one that was pressed, or the value returned by its
* callback. If the dialog was dismissed, and rejectClose is false, the
* Promise resolves to null.
*/
static async confirm({ yes={}, no={}, ...options }={}) {
options.buttons ??= [];
options.buttons.unshift(mergeObject({
action: "yes", label: "Yes", icon: "fas fa-check", callback: () => true
}, yes), mergeObject({
action: "no", label: "No", icon: "fas fa-xmark", default: true, callback: () => false
}, no));
return this.wait(options);
}
/* -------------------------------------------- */
/**
* A utility helper to generate a dialog with a single confirmation button.
* @param {Partial} [options]
* @param {Partial} [options.ok] Options to overwrite the default confirmation button configuration.
* @returns {Promise} Resolves to the identifier of the button used to submit the dialog,
* or the value returned by that button's callback. If the dialog was
* dismissed, and rejectClose is false, the Promise resolves to null.
*/
static async prompt({ ok={}, ...options }={}) {
options.buttons ??= [];
options.buttons.unshift(mergeObject({
action: "ok", label: "Confirm", icon: "fas fa-check", default: true
}, ok));
return this.wait(options);
}
/* -------------------------------------------- */
/**
* Spawn a dialog and wait for it to be dismissed or submitted.
* @param {Partial} [options]
* @param {DialogV2RenderCallback} [options.render] A function to invoke whenever the dialog is rendered.
* @param {DialogV2CloseCallback} [options.close] A function to invoke when the dialog is closed under any
* circumstances.
* @param {boolean} [options.rejectClose=true] Throw a Promise rejection if the dialog is dismissed.
* @returns {Promise} Resolves to the identifier of the button used to submit the
* dialog, or the value returned by that button's callback. If the
* dialog was dismissed, and rejectClose is false, the Promise
* resolves to null.
*/
static async wait({ rejectClose=true, close, render, ...options }={}) {
return new Promise((resolve, reject) => {
// Wrap submission handler with Promise resolution.
const originalSubmit = options.submit;
options.submit = async result => {
await originalSubmit?.(result);
resolve(result);
};
const dialog = new this(options);
dialog.addEventListener("close", event => {
if ( close instanceof Function ) close(event, dialog);
if ( rejectClose ) reject(new Error("Dialog was dismissed without pressing a button."));
else resolve(null);
}, { once: true });
if ( render instanceof Function ) {
dialog.addEventListener("render", event => render(event, dialog.element));
}
dialog.render({ force: true });
});
}
}
/**
* The primary application which renders packages on the Setup view.
*/
class SetupPackages extends Application {
constructor(...args) {
super(...args);
this.#viewModes = this.#initializeViewModes();
}
/**
* Initialize user-designated favorite packages.
*/
#initializePackageFavorites() {
const packageFavorites = game.settings.get("core", Setup.FAVORITE_PACKAGES_SETTING);
for ( const [collectionName, ids] of Object.entries(packageFavorites) ) {
const c = game[collectionName];
for ( const id of ids ) {
const pkg = c.get(id);
if ( pkg ) pkg.favorite = true;
}
}
}
/**
* Retrieve selected view modes from client storage.
* @returns {{worlds: string, systems: string, modules: string}}
*/
#initializeViewModes() {
const vm = game.settings.get("core", "setupViewModes");
if ( !(vm.worlds in SetupPackages.VIEW_MODES) ) vm.worlds = "GALLERY";
if ( !(vm.systems in SetupPackages.VIEW_MODES) ) vm.systems = "GALLERY";
if ( !(vm.modules in SetupPackages.VIEW_MODES) ) vm.modules = "TILES";
return vm;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "setup-packages",
template: "templates/setup/setup-packages.hbs",
popOut: false,
scrollY: ["#worlds-list", "#systems-list", "#modules-list"],
tabs: [{navSelector: ".tabs", contentSelector: "#setup-packages", initial: "worlds"}],
filters: [
{inputSelector: "#world-filter", contentSelector: "#worlds-list"},
{inputSelector: "#system-filter", contentSelector: "#systems-list"},
{inputSelector: "#module-filter", contentSelector: "#modules-list"}
]
});
}
/**
* The set of progress actions eligible for display in the package progress bar.
* @type {Set}
*/
static progressActions = new Set([
CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.INSTALL_PKG,
CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.LAUNCH_WORLD,
CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.CREATE_BACKUP,
CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.RESTORE_BACKUP,
CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.DELETE_BACKUP,
CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.CREATE_SNAPSHOT,
CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.RESTORE_SNAPSHOT
]);
/**
* A mapping of package IDs to progress bar elements
* @type {Map}
*/
progress = new Map();
/**
* The view modes applied to each package tab.
* @type {{worlds: string, systems: string, modules: string}}
*/
#viewModes;
/**
* Track whether an "Update All" workflow is currently in progress.
* @type {"world"|"system"|"module"|null}
*/
#updatingAll = null;
/**
* The allowed view modes which can be used for each package-type tab.
* @enum {Readonly<{id: string, label: string, template: string}>}
*/
static VIEW_MODES = Object.freeze({
GALLERY: {
id: "GALLERY",
icon: "fa-solid fa-image-landscape",
label: "PACKAGE.VIEW_MODES.GALLERY",
template: "templates/setup/parts/package-gallery.hbs"
},
TILES: {
id: "TILES",
icon: "fa-solid fa-grid-horizontal",
label: "PACKAGE.VIEW_MODES.TILES",
template: "templates/setup/parts/package-tiles.hbs"
},
DETAILS: {
id: "DETAILS",
icon: "fa-solid fa-list",
label: "PACKAGE.VIEW_MODES.DETAILS",
template: "templates/setup/parts/package-details.hbs"
}
});
/**
* The maximum number of progress bars that will be displayed simultaneously.
* @type {number}
*/
static MAX_PROGRESS_BARS = 5;
/* -------------------------------------------- */
/* Tabs and Filters */
/* -------------------------------------------- */
/**
* The name of the currently active packages tab.
* @type {string}
*/
get activeTab() {
return this._tabs[0].active;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onChangeTab(event, tabs, active) {
super._onChangeTab(event, tabs, active);
this._searchFilters.forEach(f => {
if ( f._input ) f._input.value = "";
f.filter(null, "");
});
this.element.find(".tab.active .filter > input").trigger("focus");
document.querySelector(".tab.active > header").insertAdjacentElement("afterend", document.getElementById("progress"));
}
/* -------------------------------------------- */
/** @override */
_onSearchFilter(event, query, rgx, html) {
if ( !html ) return;
let anyMatch = !query;
const noResults = html.closest("section").querySelector(".no-results");
for ( const li of html.children ) {
if ( !query ) {
li.classList.remove("hidden");
continue;
}
const id = li.dataset.packageId;
const title = li.querySelector(".package-title")?.textContent;
let match = rgx.test(id) || rgx.test(SearchFilter.cleanQuery(title));
li.classList.toggle("hidden", !match);
if ( match ) anyMatch = true;
}
const empty = !anyMatch || !html.children.length;
html.classList.toggle("empty", empty);
if ( !anyMatch ) {
const label = game.i18n.localize(`SETUP.${html.closest(".tab").id.titleCase()}`);
const search = game.i18n.localize("SETUP.PackagesNoResultsSearch", { name: query});
noResults.innerHTML = `