Files

891 lines
30 KiB
JavaScript
Raw Permalink Normal View History

2025-01-04 00:34:03 +01:00
/**
* @typedef {ApplicationOptions} FormApplicationOptions
* @property {boolean} [closeOnSubmit=true] Whether to automatically close the application when it's contained
* form is submitted.
* @property {boolean} [submitOnChange=false] Whether to automatically submit the contained HTML form when an input
* or select element is changed.
* @property {boolean} [submitOnClose=false] Whether to automatically submit the contained HTML form when the
* application window is manually closed.
* @property {boolean} [editable=true] Whether the application form is editable - if true, it's fields will
* be unlocked and the form can be submitted. If false, all form fields
* will be disabled and the form cannot be submitted.
* @property {boolean} [sheetConfig=false] Support configuration of the sheet type used for this application.
*/
/**
* An abstract pattern for defining an Application responsible for updating some object using an HTML form
*
* A few critical assumptions:
* 1) This application is used to only edit one object at a time
* 2) The template used contains one (and only one) HTML form as it's outer-most element
* 3) This abstract layer has no knowledge of what is being updated, so the implementation must define _updateObject
*
* @extends {Application}
* @abstract
* @interface
*
* @param {object} object Some object which is the target data structure to be updated by the form.
* @param {FormApplicationOptions} [options] Additional options which modify the rendering of the sheet.
*/
class FormApplication extends Application {
constructor(object={}, options={}) {
super(options);
/**
* The object target which we are using this form to modify
* @type {*}
*/
this.object = object;
/**
* A convenience reference to the form HTMLElement
* @type {HTMLElement}
*/
this.form = null;
/**
* Keep track of any mce editors which may be active as part of this form
* The values of this object are inner-objects with references to the MCE editor and other metadata
* @type {Record<string, object>}
*/
this.editors = {};
}
/**
* An array of custom element tag names that should be listened to for changes.
* @type {string[]}
* @protected
*/
static _customElements = Object.values(foundry.applications.elements).reduce((arr, el) => {
if ( el.tagName ) arr.push(el.tagName);
return arr;
}, []);
/* -------------------------------------------- */
/**
* Assign the default options which are supported by the document edit sheet.
* In addition to the default options object supported by the parent Application class, the Form Application
* supports the following additional keys and values:
*
* @returns {FormApplicationOptions} The default options for this FormApplication class
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["form"],
closeOnSubmit: true,
editable: true,
sheetConfig: false,
submitOnChange: false,
submitOnClose: false
});
}
/* -------------------------------------------- */
/**
* Is the Form Application currently editable?
* @type {boolean}
*/
get isEditable() {
return this.options.editable;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* @inheritdoc
* @returns {object|Promise<object>}
*/
getData(options={}) {
return {
object: this.object,
options: this.options,
title: this.title
};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
// Identify the focused element
let focus = this.element.find(":focus");
focus = focus.length ? focus[0] : null;
// Render the application and restore focus
await super._render(force, options);
if ( focus && focus.name ) {
const input = this.form?.[focus.name];
if ( input && (input.focus instanceof Function) ) input.focus();
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
const html = await super._renderInner(...args);
this.form = html.filter((i, el) => el instanceof HTMLFormElement)[0];
if ( !this.form ) this.form = html.find("form")[0];
return html;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_activateCoreListeners(html) {
super._activateCoreListeners(html);
if ( !this.form ) return;
if ( !this.isEditable ) {
return this._disableFields(this.form);
}
this.form.onsubmit = this._onSubmit.bind(this);
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
if ( !this.isEditable ) return;
const changeElements = ["input", "select", "textarea"].concat(this.constructor._customElements);
html.on("change", changeElements.join(","), this._onChangeInput.bind(this));
html.find(".editor-content[data-edit]").each((i, div) => this._activateEditor(div));
html.find("button.file-picker").click(this._activateFilePicker.bind(this));
if ( this._priorState <= this.constructor.RENDER_STATES.NONE ) html.find("[autofocus]")[0]?.focus();
}
/* -------------------------------------------- */
/**
* If the form is not editable, disable its input fields
* @param {HTMLElement} form The form HTML
* @protected
*/
_disableFields(form) {
const inputs = ["INPUT", "SELECT", "TEXTAREA", "BUTTON"];
for ( let i of inputs ) {
for ( let el of form.getElementsByTagName(i) ) {
if ( i === "TEXTAREA" ) el.readOnly = true;
else el.disabled = true;
}
}
}
/* -------------------------------------------- */
/**
* Handle standard form submission steps
* @param {Event} event The submit event which triggered this handler
* @param {object | null} [updateData] Additional specific data keys/values which override or extend the contents of
* the parsed form. This can be used to update other flags or data fields at the
* same time as processing a form submission to avoid multiple database operations.
* @param {boolean} [preventClose] Override the standard behavior of whether to close the form on submit
* @param {boolean} [preventRender] Prevent the application from re-rendering as a result of form submission
* @returns {Promise} A promise which resolves to the validated update data
* @protected
*/
async _onSubmit(event, {updateData=null, preventClose=false, preventRender=false}={}) {
event.preventDefault();
// Prevent double submission
const states = this.constructor.RENDER_STATES;
if ( (this._state === states.NONE) || !this.isEditable || this._submitting ) return false;
this._submitting = true;
// Process the form data
const formData = this._getSubmitData(updateData);
// Handle the form state prior to submission
let closeForm = this.options.closeOnSubmit && !preventClose;
const priorState = this._state;
if ( preventRender ) this._state = states.RENDERING;
if ( closeForm ) this._state = states.CLOSING;
// Trigger the object update
try {
await this._updateObject(event, formData);
}
catch(err) {
console.error(err);
closeForm = false;
this._state = priorState;
}
// Restore flags and optionally close the form
this._submitting = false;
if ( preventRender ) this._state = priorState;
if ( closeForm ) await this.close({submit: false, force: true});
return formData;
}
/* -------------------------------------------- */
/**
* Get an object of update data used to update the form's target object
* @param {object} updateData Additional data that should be merged with the form data
* @returns {object} The prepared update data
* @protected
*/
_getSubmitData(updateData={}) {
if ( !this.form ) throw new Error("The FormApplication subclass has no registered form element");
const fd = new FormDataExtended(this.form, {editors: this.editors});
let data = fd.object;
if ( updateData ) data = foundry.utils.flattenObject(foundry.utils.mergeObject(data, updateData));
return data;
}
/* -------------------------------------------- */
/**
* Handle changes to an input element, submitting the form if options.submitOnChange is true.
* Do not preventDefault in this handler as other interactions on the form may also be occurring.
* @param {Event} event The initial change event
* @protected
*/
async _onChangeInput(event) {
// Saving a <prose-mirror> element
if ( event.currentTarget.matches("prose-mirror") ) return this._onSubmit(event);
// Ignore inputs inside an editor environment
if ( event.currentTarget.closest(".editor") ) return;
// Handle changes to specific input types
const el = event.target;
if ( (el.type === "color") && el.dataset.edit ) this._onChangeColorPicker(event);
else if ( el.type === "range" ) this._onChangeRange(event);
// Maybe submit the form
if ( this.options.submitOnChange ) {
return this._onSubmit(event);
}
}
/* -------------------------------------------- */
/**
* Handle the change of a color picker input which enters it's chosen value into a related input field
* @param {Event} event The color picker change event
* @protected
*/
_onChangeColorPicker(event) {
const input = event.target;
input.form[input.dataset.edit].value = input.value;
}
/* -------------------------------------------- */
/**
* Handle changes to a range type input by propagating those changes to the sibling range-value element
* @param {Event} event The initial change event
* @protected
*/
_onChangeRange(event) {
const field = event.target.parentElement.querySelector(".range-value");
if ( field ) {
if ( field.tagName === "INPUT" ) field.value = event.target.value;
else field.innerHTML = event.target.value;
}
}
/* -------------------------------------------- */
/**
* This method is called upon form submission after form data is validated
* @param {Event} event The initial triggering submission event
* @param {object} formData The object of validated form data with which to update the object
* @returns {Promise} A Promise which resolves once the update operation has completed
* @abstract
*/
async _updateObject(event, formData) {
throw new Error("A subclass of the FormApplication must implement the _updateObject method.");
}
/* -------------------------------------------- */
/* TinyMCE Editor */
/* -------------------------------------------- */
/**
* Activate a named TinyMCE text editor
* @param {string} name The named data field which the editor modifies.
* @param {object} options Editor initialization options passed to {@link TextEditor.create}.
* @param {string} initialContent Initial text content for the editor area.
* @returns {Promise<TinyMCE.Editor|ProseMirror.EditorView>}
*/
async activateEditor(name, options={}, initialContent="") {
const editor = this.editors[name];
if ( !editor ) throw new Error(`${name} is not a registered editor name!`);
options = foundry.utils.mergeObject(editor.options, options);
if ( !options.fitToSize ) options.height = options.target.offsetHeight;
if ( editor.hasButton ) editor.button.style.display = "none";
const instance = editor.instance = editor.mce = await TextEditor.create(options, initialContent || editor.initial);
options.target.closest(".editor")?.classList.add(options.engine ?? "tinymce");
editor.changed = false;
editor.active = true;
// Legacy behavior to support TinyMCE.
// We could remove this in the future if we drop official support for TinyMCE.
if ( options.engine !== "prosemirror" ) {
instance.focus();
instance.on("change", () => editor.changed = true);
}
return instance;
}
/* -------------------------------------------- */
/**
* Handle saving the content of a specific editor by name
* @param {string} name The named editor to save
* @param {object} [options]
* @param {boolean} [options.remove] Remove the editor after saving its content
* @param {boolean} [options.preventRender] Prevent normal re-rendering of the sheet after saving.
* @returns {Promise<void>}
*/
async saveEditor(name, {remove=true, preventRender}={}) {
const editor = this.editors[name];
if ( !editor || !editor.instance ) throw new Error(`${name} is not an active editor name!`);
editor.active = false;
const instance = editor.instance;
await this._onSubmit(new Event("submit"), { preventRender });
// Remove the editor
if ( remove ) {
instance.destroy();
editor.instance = editor.mce = null;
if ( editor.hasButton ) editor.button.style.display = "block";
this.render();
}
editor.changed = false;
}
/* -------------------------------------------- */
/**
* Activate an editor instance present within the form
* @param {HTMLElement} div The element which contains the editor
* @protected
*/
_activateEditor(div) {
// Get the editor content div
const name = div.dataset.edit;
const engine = div.dataset.engine || "tinymce";
const collaborate = div.dataset.collaborate === "true";
const button = div.previousElementSibling;
const hasButton = button && button.classList.contains("editor-edit");
const wrap = div.parentElement.parentElement;
const wc = div.closest(".window-content");
// Determine the preferred editor height
const heights = [wrap.offsetHeight, wc ? wc.offsetHeight : null];
if ( div.offsetHeight > 0 ) heights.push(div.offsetHeight);
const height = Math.min(...heights.filter(h => Number.isFinite(h)));
// Get initial content
const options = {
target: div,
fieldName: name,
save_onsavecallback: () => this.saveEditor(name),
height, engine, collaborate
};
if ( engine === "prosemirror" ) options.plugins = this._configureProseMirrorPlugins(name, {remove: hasButton});
// Define the editor configuration
const initial = foundry.utils.getProperty(this.object, name);
const editor = this.editors[name] = {
options,
target: name,
button: button,
hasButton: hasButton,
mce: null,
instance: null,
active: !hasButton,
changed: false,
initial
};
// Activate the editor immediately, or upon button click
const activate = () => {
editor.initial = foundry.utils.getProperty(this.object, name);
this.activateEditor(name, {}, editor.initial);
};
if ( hasButton ) button.onclick = activate;
else activate();
}
/* -------------------------------------------- */
/**
* Configure ProseMirror plugins for this sheet.
* @param {string} name The name of the editor.
* @param {object} [options] Additional options to configure the plugins.
* @param {boolean} [options.remove=true] Whether the editor should destroy itself on save.
* @returns {object}
* @protected
*/
_configureProseMirrorPlugins(name, {remove=true}={}) {
return {
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
destroyOnSave: remove,
onSave: () => this.saveEditor(name, {remove})
}),
keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema, {
onSave: () => this.saveEditor(name, {remove})
})
};
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
const states = Application.RENDER_STATES;
if ( !options.force && ![states.RENDERED, states.ERROR].includes(this._state) ) return;
// Trigger saving of the form
const submit = options.submit ?? this.options.submitOnClose;
if ( submit ) await this.submit({preventClose: true, preventRender: true});
// Close any open FilePicker instances
for ( let fp of (this.#filepickers) ) fp.close();
this.#filepickers.length = 0;
for ( const fp of this.element[0].querySelectorAll("file-picker") ) fp.picker?.close();
// Close any open MCE editors
for ( let ed of Object.values(this.editors) ) {
if ( ed.mce ) ed.mce.destroy();
}
this.editors = {};
// Close the application itself
return super.close(options);
}
/* -------------------------------------------- */
/**
* Submit the contents of a Form Application, processing its content as defined by the Application
* @param {object} [options] Options passed to the _onSubmit event handler
* @returns {Promise<FormApplication>} Return a self-reference for convenient method chaining
*/
async submit(options={}) {
if ( this._submitting ) return this;
const submitEvent = new Event("submit");
await this._onSubmit(submitEvent, options);
return this;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get filepickers() {
foundry.utils.logCompatibilityWarning("FormApplication#filepickers is deprecated and replaced by the <file-picker>"
+ "HTML element", {since: 12, until: 14, once: true});
return this.#filepickers;
}
#filepickers = [];
/**
* @deprecated since v12
* @ignore
*/
_activateFilePicker(event) {
foundry.utils.logCompatibilityWarning("FormApplication#_activateFilePicker is deprecated without replacement",
{since: 12, until: 14, once: true});
event.preventDefault();
const options = this._getFilePickerOptions(event);
const fp = new FilePicker(options);
this.#filepickers.push(fp);
return fp.browse();
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
_getFilePickerOptions(event) {
foundry.utils.logCompatibilityWarning("FormApplication#_getFilePickerOptions is deprecated without replacement",
{since: 12, until: 14, once: true});
const button = event.currentTarget;
const target = button.dataset.target;
const field = button.form[target] || null;
return {
field: field,
type: button.dataset.type,
current: field?.value ?? "",
button: button,
callback: this._onSelectFile.bind(this)
};
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
_onSelectFile(selection, filePicker) {}
}
/* -------------------------------------------- */
/**
* @typedef {FormApplicationOptions} DocumentSheetOptions
* @property {number} viewPermission The default permissions required to view this Document sheet.
* @property {HTMLSecretConfiguration[]} [secrets] An array of {@link HTMLSecret} configuration objects.
*/
/**
* Extend the FormApplication pattern to incorporate specific logic for viewing or editing Document instances.
* See the FormApplication documentation for more complete description of this interface.
*
* @extends {FormApplication}
* @abstract
* @interface
*/
class DocumentSheet extends FormApplication {
/**
* @param {Document} object A Document instance which should be managed by this form.
* @param {DocumentSheetOptions} [options={}] Optional configuration parameters for how the form behaves.
*/
constructor(object, options={}) {
super(object, options);
this._secrets = this._createSecretHandlers();
}
/* -------------------------------------------- */
/**
* The list of handlers for secret block functionality.
* @type {HTMLSecret[]}
* @protected
*/
_secrets = [];
/* -------------------------------------------- */
/**
* @override
* @returns {DocumentSheetOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet"],
template: `templates/sheets/${this.name.toLowerCase()}.html`,
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED,
sheetConfig: true,
secrets: []
});
}
/* -------------------------------------------- */
/**
* A semantic convenience reference to the Document instance which is the target object for this form.
* @type {ClientDocument}
*/
get document() {
return this.object;
}
/* -------------------------------------------- */
/** @inheritdoc */
get id() {
return `${this.constructor.name}-${this.document.uuid.replace(/\./g, "-")}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get isEditable() {
let editable = this.options.editable && this.document.isOwner;
if ( this.document.pack ) {
const pack = game.packs.get(this.document.pack);
if ( pack.locked ) editable = false;
}
return editable;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
const reference = this.document.name ? `: ${this.document.name}` : "";
return `${game.i18n.localize(this.document.constructor.metadata.label)}${reference}`;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
await super.close(options);
delete this.object.apps?.[this.appId];
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const data = this.document.toObject(false);
const isEditable = this.isEditable;
return {
cssClass: isEditable ? "editable" : "locked",
editable: isEditable,
document: this.document,
data: data,
limited: this.document.limited,
options: this.options,
owner: this.document.isOwner,
title: this.title
};
}
/* -------------------------------------------- */
/** @inheritdoc */
_activateCoreListeners(html) {
super._activateCoreListeners(html);
if ( this.isEditable ) html.find("img[data-edit]").on("click", this._onEditImage.bind(this));
if ( !this.document.isOwner ) return;
this._secrets.forEach(secret => secret.bind(html[0]));
}
/* -------------------------------------------- */
/** @inheritdoc */
async activateEditor(name, options={}, initialContent="") {
const editor = this.editors[name];
options.document = this.document;
if ( editor?.options.engine === "prosemirror" ) {
options.plugins = foundry.utils.mergeObject({
highlightDocumentMatches: ProseMirror.ProseMirrorHighlightMatchesPlugin.build(ProseMirror.defaultSchema)
}, options.plugins);
}
return super.activateEditor(name, options, initialContent);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _render(force, options={}) {
// Verify user permission to view and edit
if ( !this._canUserView(game.user) ) {
if ( !force ) return;
const err = game.i18n.format("SHEETS.DocumentSheetPrivate", {
type: game.i18n.localize(this.object.constructor.metadata.label)
});
ui.notifications.warn(err);
return;
}
options.editable = options.editable ?? this.object.isOwner;
// Parent class rendering workflow
await super._render(force, options);
// Register the active Application with the referenced Documents
this.object.apps[this.appId] = this;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _renderOuter() {
const html = await super._renderOuter();
this._createDocumentIdLink(html);
return html;
}
/* -------------------------------------------- */
/**
* Create an ID link button in the document sheet header which displays the document ID and copies to clipboard
* @param {jQuery} html
* @protected
*/
_createDocumentIdLink(html) {
if ( !(this.object instanceof foundry.abstract.Document) || !this.object.id ) return;
const title = html.find(".window-title");
const label = game.i18n.localize(this.object.constructor.metadata.label);
const idLink = document.createElement("a");
idLink.classList.add("document-id-link");
idLink.ariaLabel = game.i18n.localize("SHEETS.CopyUuid");
idLink.dataset.tooltip = `SHEETS.CopyUuid`;
idLink.dataset.tooltipDirection = "UP";
idLink.innerHTML = '<i class="fa-solid fa-passport"></i>';
idLink.addEventListener("click", event => {
event.preventDefault();
game.clipboard.copyPlainText(this.object.uuid);
ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "uuid", id: this.object.uuid}));
});
idLink.addEventListener("contextmenu", event => {
event.preventDefault();
game.clipboard.copyPlainText(this.object.id);
ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "id", id: this.object.id}));
});
title.append(idLink);
}
/* -------------------------------------------- */
/**
* Test whether a certain User has permission to view this Document Sheet.
* @param {User} user The user requesting to render the sheet
* @returns {boolean} Does the User have permission to view this sheet?
* @protected
*/
_canUserView(user) {
return this.object.testUserPermission(user, this.options.viewPermission);
}
/* -------------------------------------------- */
/**
* Create objects for managing the functionality of secret blocks within this Document's content.
* @returns {HTMLSecret[]}
* @protected
*/
_createSecretHandlers() {
if ( !this.document.isOwner || this.document.compendium?.locked ) return [];
return this.options.secrets.map(config => {
config.callbacks = {
content: this._getSecretContent.bind(this),
update: this._updateSecret.bind(this)
};
return new HTMLSecret(config);
});
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_getHeaderButtons() {
let buttons = super._getHeaderButtons();
// Compendium Import
if ( (this.document.constructor.name !== "Folder") && !this.document.isEmbedded &&
this.document.compendium && this.document.constructor.canUserCreate(game.user) ) {
buttons.unshift({
label: "Import",
class: "import",
icon: "fas fa-download",
onclick: async () => {
await this.close();
return this.document.collection.importFromCompendium(this.document.compendium, this.document.id);
}
});
}
// Sheet Configuration
if ( this.options.sheetConfig && this.isEditable && (this.document.getFlag("core", "sheetLock") !== true) ) {
buttons.unshift({
label: "Sheet",
class: "configure-sheet",
icon: "fas fa-cog",
onclick: ev => this._onConfigureSheet(ev)
});
}
return buttons;
}
/* -------------------------------------------- */
/**
* Get the HTML content that a given secret block is embedded in.
* @param {HTMLElement} secret The secret block.
* @returns {string}
* @protected
*/
_getSecretContent(secret) {
const edit = secret.closest("[data-edit]")?.dataset.edit;
if ( edit ) return foundry.utils.getProperty(this.document, edit);
}
/* -------------------------------------------- */
/**
* Update the HTML content that a given secret block is embedded in.
* @param {HTMLElement} secret The secret block.
* @param {string} content The new content.
* @returns {Promise<ClientDocument>} The updated Document.
* @protected
*/
_updateSecret(secret, content) {
const edit = secret.closest("[data-edit]")?.dataset.edit;
if ( edit ) return this.document.update({[edit]: content});
}
/* -------------------------------------------- */
/**
* Handle requests to configure the default sheet used by this Document
* @param event
* @private
*/
_onConfigureSheet(event) {
event.preventDefault();
new DocumentSheetConfig(this.document, {
top: this.position.top + 40,
left: this.position.left + ((this.position.width - DocumentSheet.defaultOptions.width) / 2)
}).render(true);
}
/* -------------------------------------------- */
/**
* Handle changing a Document's image.
* @param {MouseEvent} event The click event.
* @returns {Promise}
* @protected
*/
_onEditImage(event) {
const attr = event.currentTarget.dataset.edit;
const current = foundry.utils.getProperty(this.object, attr);
const { img } = this.document.constructor.getDefaultArtwork?.(this.document.toObject()) ?? {};
const fp = new FilePicker({
current,
type: "image",
redirectToRoot: img ? [img] : [],
callback: path => {
event.currentTarget.src = path;
if ( this.options.submitOnChange ) return this._onSubmit(event);
},
top: this.position.top + 40,
left: this.position.left + 10
});
return fp.browse();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
if ( !this.object.id ) return;
return this.object.update(formData);
}
}