This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,269 @@
/**
* An extension of the native FormData implementation.
*
* This class functions the same way that the default FormData does, but it is more opinionated about how
* input fields of certain types should be evaluated and handled.
*
* It also adds support for certain Foundry VTT specific concepts including:
* Support for defined data types and type conversion
* Support for TinyMCE editors
* Support for editable HTML elements
*
* @extends {FormData}
*
* @param {HTMLFormElement} form The form being processed
* @param {object} options Options which configure form processing
* @param {Record<string, object>} [options.editors] A record of TinyMCE editor metadata objects, indexed by their update key
* @param {Record<string, string>} [options.dtypes] A mapping of data types for form fields
* @param {boolean} [options.disabled=false] Include disabled fields?
* @param {boolean} [options.readonly=false] Include readonly fields?
*/
class FormDataExtended extends FormData {
constructor(form, {dtypes={}, editors={}, disabled=false, readonly=true}={}) {
super();
/**
* A mapping of data types requested for each form field.
* @type {{string, string}}
*/
this.dtypes = dtypes;
/**
* A record of TinyMCE editors which are linked to this form.
* @type {Record<string, object>}
*/
this.editors = editors;
/**
* The object representation of the form data, available once processed.
* @type {object}
*/
Object.defineProperty(this, "object", {value: {}, writable: false, enumerable: false});
// Process the provided form
this.process(form, {disabled, readonly});
}
/* -------------------------------------------- */
/**
* Process the HTML form element to populate the FormData instance.
* @param {HTMLFormElement} form The HTML form being processed
* @param {object} options Options forwarded from the constructor
*/
process(form, options) {
this.#processFormFields(form, options);
this.#processEditableHTML(form, options);
this.#processEditors();
// Emit the formdata event for compatibility with the parent FormData class
form.dispatchEvent(new FormDataEvent("formdata", {formData: this}));
}
/* -------------------------------------------- */
/**
* Assign a value to the FormData instance which always contains JSON strings.
* Also assign the cast value in its preferred data type to the parsed object representation of the form data.
* @param {string} name The field name
* @param {any} value The raw extracted value from the field
* @inheritDoc
*/
set(name, value) {
this.object[name] = value;
if ( value instanceof Array ) value = JSON.stringify(value);
super.set(name, value);
}
/* -------------------------------------------- */
/**
* Append values to the form data, adding them to an array.
* @param {string} name The field name to append to the form
* @param {any} value The value to append to the form data
* @inheritDoc
*/
append(name, value) {
if ( name in this.object ) {
if ( !Array.isArray(this.object[name]) ) this.object[name] = [this.object[name]];
}
else this.object[name] = [];
this.object[name].push(value);
super.append(name, value);
}
/* -------------------------------------------- */
/**
* Process all standard HTML form field elements from the form.
* @param {HTMLFormElement} form The form being processed
* @param {object} options Options forwarded from the constructor
* @param {boolean} [options.disabled] Process disabled fields?
* @param {boolean} [options.readonly] Process readonly fields?
* @private
*/
#processFormFields(form, {disabled, readonly}={}) {
if ( !disabled && form.hasAttribute("disabled") ) return;
const mceEditorIds = Object.values(this.editors).map(e => e.mce?.id);
for ( const element of form.elements ) {
const name = element.name;
// Skip fields which are unnamed or already handled
if ( !name || this.has(name) ) continue;
// Skip buttons and editors
if ( (element.tagName === "BUTTON") || mceEditorIds.includes(name) ) continue;
// Skip disabled or read-only fields
if ( !disabled && (element.disabled || element.closest("fieldset")?.disabled) ) continue;
if ( !readonly && element.readOnly ) continue;
// Extract and process the value of the field
const field = form.elements[name];
const value = this.#getFieldValue(name, field);
this.set(name, value);
}
}
/* -------------------------------------------- */
/**
* Process editable HTML elements (ones with a [data-edit] attribute).
* @param {HTMLFormElement} form The form being processed
* @param {object} options Options forwarded from the constructor
* @param {boolean} [options.disabled] Process disabled fields?
* @param {boolean} [options.readonly] Process readonly fields?
* @private
*/
#processEditableHTML(form, {disabled, readonly}={}) {
const editableElements = form.querySelectorAll("[data-edit]");
for ( const element of editableElements ) {
const name = element.dataset.edit;
if ( this.has(name) || (name in this.editors) ) continue;
if ( (!disabled && element.disabled) || (!readonly && element.readOnly) ) continue;
let value;
if (element.tagName === "IMG") value = element.getAttribute("src");
else value = element.innerHTML.trim();
this.set(name, value);
}
}
/* -------------------------------------------- */
/**
* Process TinyMCE editor instances which are present in the form.
* @private
*/
#processEditors() {
for ( const [name, editor] of Object.entries(this.editors) ) {
if ( !editor.instance ) continue;
if ( editor.options.engine === "tinymce" ) {
const content = editor.instance.getContent();
this.delete(editor.mce.id); // Delete hidden MCE inputs
this.set(name, content);
} else if ( editor.options.engine === "prosemirror" ) {
this.set(name, ProseMirror.dom.serializeString(editor.instance.view.state.doc.content));
}
}
}
/* -------------------------------------------- */
/**
* Obtain the parsed value of a field conditional on its element type and requested data type.
* @param {string} name The field name being processed
* @param {HTMLElement|RadioNodeList} field The HTML field or a RadioNodeList of multiple fields
* @returns {*} The processed field value
* @private
*/
#getFieldValue(name, field) {
// Multiple elements with the same name
if ( field instanceof RadioNodeList ) {
const fields = Array.from(field);
if ( fields.every(f => f.type === "radio") ) {
const chosen = fields.find(f => f.checked);
return chosen ? this.#getFieldValue(name, chosen) : undefined;
}
return Array.from(field).map(f => this.#getFieldValue(name, f));
}
// Record requested data type
const dataType = field.dataset.dtype || this.dtypes[name];
// Checkbox
if ( field.type === "checkbox" ) {
// Non-boolean checkboxes with an explicit value attribute yield that value or null
if ( field.hasAttribute("value") && (dataType !== "Boolean") ) {
return this.#castType(field.checked ? field.value : null, dataType);
}
// Otherwise, true or false based on the checkbox checked state
return this.#castType(field.checked, dataType);
}
// Number and Range
if ( ["number", "range"].includes(field.type) ) {
if ( field.value === "" ) return null;
else return this.#castType(field.value, dataType || "Number");
}
// Multi-Select
if ( field.type === "select-multiple" ) {
return Array.from(field.options).reduce((chosen, opt) => {
if ( opt.selected ) chosen.push(this.#castType(opt.value, dataType));
return chosen;
}, []);
}
// Radio Select
if ( field.type === "radio" ) {
return field.checked ? this.#castType(field.value, dataType) : null;
}
// Other field types
return this.#castType(field.value, dataType);
}
/* -------------------------------------------- */
/**
* Cast a processed value to a desired data type.
* @param {any} value The raw field value
* @param {string} dataType The desired data type
* @returns {any} The resulting data type
* @private
*/
#castType(value, dataType) {
if ( value instanceof Array ) return value.map(v => this.#castType(v, dataType));
if ( [undefined, null].includes(value) || (dataType === "String") ) return value;
// Boolean
if ( dataType === "Boolean" ) {
if ( value === "false" ) return false;
return Boolean(value);
}
// Number
else if ( dataType === "Number" ) {
if ( (value === "") || (value === "null") ) return null;
return Number(value);
}
// Serialized JSON
else if ( dataType === "JSON" ) {
return JSON.parse(value);
}
// Other data types
if ( window[dataType] instanceof Function ) {
try {
return window[dataType](value);
} catch(err) {
console.warn(`The form field value "${value}" was not able to be cast to the requested data type ${dataType}`);
}
}
return value;
}
}