Initial
This commit is contained in:
4
resources/app/client-esm/applications/api/_module.mjs
Normal file
4
resources/app/client-esm/applications/api/_module.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
export {default as ApplicationV2} from "./application.mjs";
|
||||
export {default as DialogV2} from "./dialog.mjs";
|
||||
export {default as DocumentSheetV2} from "./document-sheet.mjs";
|
||||
export {default as HandlebarsApplicationMixin} from "./handlebars-application.mjs";
|
||||
1414
resources/app/client-esm/applications/api/application.mjs
Normal file
1414
resources/app/client-esm/applications/api/application.mjs
Normal file
File diff suppressed because it is too large
Load Diff
342
resources/app/client-esm/applications/api/dialog.mjs
Normal file
342
resources/app/client-esm/applications/api/dialog.mjs
Normal file
@@ -0,0 +1,342 @@
|
||||
import ApplicationV2 from "./application.mjs";
|
||||
import {mergeObject} from "../../../common/utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* @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<any>}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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<void>}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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<ApplicationConfiguration & DialogV2Configuration>}
|
||||
*
|
||||
* @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: '<input name="guess" type="number" min="1" max="10" step="1" autofocus>',
|
||||
* 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: `
|
||||
* <label><input type="radio" name="choice" value="one" checked> Option 1</label>
|
||||
* <label><input type="radio" name="choice" value="two"> Option 2</label>
|
||||
* <label><input type="radio" name="choice" value="three"> Options 3</label>
|
||||
* `,
|
||||
* 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 });
|
||||
* ```
|
||||
*/
|
||||
export default 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 ? `<div class="dialog-content standard-form">${this.options.content}</div>` : ""}
|
||||
<footer class="form-footer">${this._renderButtons()}</footer>
|
||||
`;
|
||||
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 `
|
||||
<button type="${isDefault ? "submit" : "button"}" data-action="${action}" class="${cls}"
|
||||
${isDefault ? "autofocus" : ""}>
|
||||
${icon ? `<i class="${icon}"></i>` : ""}
|
||||
<span>${game.i18n.localize(label)}</span>
|
||||
</button>
|
||||
`;
|
||||
}).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<DialogV2>}
|
||||
* @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<ApplicationConfiguration & DialogV2Configuration & DialogV2WaitOptions>} [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<any>} 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<ApplicationConfiguration & DialogV2Configuration & DialogV2WaitOptions>} [options]
|
||||
* @param {Partial<DialogV2Button>} [options.ok] Options to overwrite the default confirmation button configuration.
|
||||
* @returns {Promise<any>} 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<ApplicationConfiguration & DialogV2Configuration>} [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<any>} 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
296
resources/app/client-esm/applications/api/document-sheet.mjs
Normal file
296
resources/app/client-esm/applications/api/document-sheet.mjs
Normal file
@@ -0,0 +1,296 @@
|
||||
import ApplicationV2 from "./application.mjs";
|
||||
import {DOCUMENT_OWNERSHIP_LEVELS} from "../../../common/constants.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration
|
||||
* @typedef {import("../_types.mjs").ApplicationRenderOptions} ApplicationRenderOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DocumentSheetConfiguration
|
||||
* @property {Document} document The Document instance associated with this sheet
|
||||
* @property {number} viewPermission A permission level in CONST.DOCUMENT_OWNERSHIP_LEVELS
|
||||
* @property {number} editPermission A permission level in CONST.DOCUMENT_OWNERSHIP_LEVELS
|
||||
* @property {boolean} sheetConfig Allow sheet configuration as a header button
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DocumentSheetRenderOptions
|
||||
* @property {string} renderContext A string with the format "{operation}{documentName}" providing context
|
||||
* @property {object} renderData Data describing the document modification that occurred
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Application class is responsible for rendering an HTMLElement into the Foundry Virtual Tabletop user interface.
|
||||
* @extends {ApplicationV2<
|
||||
* ApplicationConfiguration & DocumentSheetConfiguration,
|
||||
* ApplicationRenderOptions & DocumentSheetRenderOptions
|
||||
* >}
|
||||
* @alias DocumentSheetV2
|
||||
*/
|
||||
export default class DocumentSheetV2 extends ApplicationV2 {
|
||||
constructor(options={}) {
|
||||
super(options);
|
||||
this.#document = options.document;
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: "{id}",
|
||||
classes: ["sheet"],
|
||||
tag: "form", // Document sheets are forms by default
|
||||
document: null,
|
||||
viewPermission: DOCUMENT_OWNERSHIP_LEVELS.LIMITED,
|
||||
editPermission: DOCUMENT_OWNERSHIP_LEVELS.OWNER,
|
||||
sheetConfig: true,
|
||||
actions: {
|
||||
configureSheet: DocumentSheetV2.#onConfigureSheet,
|
||||
copyUuid: {handler: DocumentSheetV2.#onCopyUuid, buttons: [0, 2]}
|
||||
},
|
||||
form: {
|
||||
handler: this.#onSubmitDocumentForm,
|
||||
submitOnChange: false,
|
||||
closeOnSubmit: false
|
||||
}
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Document instance associated with the application
|
||||
* @type {ClientDocument}
|
||||
*/
|
||||
get document() {
|
||||
return this.#document;
|
||||
}
|
||||
|
||||
#document;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get title() {
|
||||
const {constructor: cls, id, name, type} = this.document;
|
||||
const prefix = cls.hasTypeData ? CONFIG[cls.documentName].typeLabels[type] : cls.metadata.label
|
||||
return `${game.i18n.localize(prefix)}: ${name ?? id}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this Document sheet visible to the current User?
|
||||
* This is governed by the viewPermission threshold configured for the class.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isVisible() {
|
||||
return this.document.testUserPermission(game.user, this.options.viewPermission);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this Document sheet editable by the current User?
|
||||
* This is governed by the editPermission threshold configured for the class.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isEditable() {
|
||||
if ( this.document.pack ) {
|
||||
const pack = game.packs.get(this.document.pack);
|
||||
if ( pack.locked ) return false;
|
||||
}
|
||||
return this.document.testUserPermission(game.user, this.options.editPermission);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_initializeApplicationOptions(options) {
|
||||
options = super._initializeApplicationOptions(options);
|
||||
options.uniqueId = `${this.constructor.name}-${options.document.uuid}`;
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
*_headerControlButtons() {
|
||||
for ( const control of this._getHeaderControls() ) {
|
||||
if ( control.visible === false ) continue;
|
||||
if ( ("ownership" in control) && !this.document.testUserPermission(game.user, control.ownership) ) continue;
|
||||
yield control;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _renderFrame(options) {
|
||||
const frame = await super._renderFrame(options);
|
||||
|
||||
// Add form options
|
||||
if ( this.options.tag === "form" ) frame.autocomplete = "off";
|
||||
|
||||
// Add document ID copy
|
||||
const copyLabel = game.i18n.localize("SHEETS.CopyUuid");
|
||||
const copyId = `<button type="button" class="header-control fa-solid fa-passport" data-action="copyUuid"
|
||||
data-tooltip="${copyLabel}" aria-label="${copyLabel}"></button>`;
|
||||
this.window.close.insertAdjacentHTML("beforebegin", copyId);
|
||||
|
||||
// Add sheet configuration button
|
||||
if ( this.options.sheetConfig && this.isEditable && !this.document.getFlag("core", "sheetLock") ) {
|
||||
const label = game.i18n.localize("SHEETS.ConfigureSheet");
|
||||
const sheetConfig = `<button type="button" class="header-control fa-solid fa-cog" data-action="configureSheet"
|
||||
data-tooltip="${label}" aria-label="${label}"></button>`;
|
||||
this.window.close.insertAdjacentHTML("beforebegin", sheetConfig);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Application Life-Cycle Events */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_canRender(_options) {
|
||||
if ( !this.isVisible ) throw new Error(game.i18n.format("SHEETS.DocumentSheetPrivate", {
|
||||
type: game.i18n.localize(this.document.constructor.metadata.label)
|
||||
}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onFirstRender(context, options) {
|
||||
super._onFirstRender(context, options);
|
||||
this.document.apps[this.id] = this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onClose(_options) {
|
||||
delete this.document.apps[this.id];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events to configure the sheet used for this document.
|
||||
* @param {PointerEvent} event
|
||||
* @this {DocumentSheetV2}
|
||||
*/
|
||||
static #onConfigureSheet(event) {
|
||||
event.stopPropagation(); // Don't trigger other events
|
||||
if ( event.detail > 1 ) return; // Ignore repeated clicks
|
||||
new DocumentSheetConfig(this.document, {
|
||||
top: this.position.top + 40,
|
||||
left: this.position.left + ((this.position.width - DocumentSheet.defaultOptions.width) / 2)
|
||||
}).render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events to copy the UUID of this document to clipboard.
|
||||
* @param {PointerEvent} event
|
||||
* @this {DocumentSheetV2}
|
||||
*/
|
||||
static #onCopyUuid(event) {
|
||||
event.preventDefault(); // Don't open context menu
|
||||
event.stopPropagation(); // Don't trigger other events
|
||||
if ( event.detail > 1 ) return; // Ignore repeated clicks
|
||||
const id = event.button === 2 ? this.document.id : this.document.uuid;
|
||||
const type = event.button === 2 ? "id" : "uuid";
|
||||
const label = game.i18n.localize(this.document.constructor.metadata.label);
|
||||
game.clipboard.copyPlainText(id);
|
||||
ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type, id}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Form Submission */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Process form submission for the sheet
|
||||
* @this {DocumentSheetV2} The handler is called with the application as its bound scope
|
||||
* @param {SubmitEvent} event The originating form submission event
|
||||
* @param {HTMLFormElement} form The form element that was submitted
|
||||
* @param {FormDataExtended} formData Processed data for the submitted form
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async #onSubmitDocumentForm(event, form, formData) {
|
||||
const submitData = this._prepareSubmitData(event, form, formData);
|
||||
await this._processSubmitData(event, form, submitData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare data used to update the Item upon form submission.
|
||||
* This data is cleaned and validated before being returned for further processing.
|
||||
* @param {SubmitEvent} event The originating form submission event
|
||||
* @param {HTMLFormElement} form The form element that was submitted
|
||||
* @param {FormDataExtended} formData Processed data for the submitted form
|
||||
* @returns {object} Prepared submission data as an object
|
||||
* @throws {Error} Subclasses may throw validation errors here to prevent form submission
|
||||
* @protected
|
||||
*/
|
||||
_prepareSubmitData(event, form, formData) {
|
||||
const submitData = this._processFormData(event, form, formData);
|
||||
const addType = this.document.constructor.hasTypeData && !("type" in submitData);
|
||||
if ( addType ) submitData.type = this.document.type;
|
||||
this.document.validate({changes: submitData, clean: true, fallback: false});
|
||||
if ( addType ) delete submitData.type;
|
||||
return submitData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Customize how form data is extracted into an expanded object.
|
||||
* @param {SubmitEvent} event The originating form submission event
|
||||
* @param {HTMLFormElement} form The form element that was submitted
|
||||
* @param {FormDataExtended} formData Processed data for the submitted form
|
||||
* @returns {object} An expanded object of processed form data
|
||||
* @throws {Error} Subclasses may throw validation errors here to prevent form submission
|
||||
*/
|
||||
_processFormData(event, form, formData) {
|
||||
return foundry.utils.expandObject(formData.object);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Submit a document update based on the processed form data.
|
||||
* @param {SubmitEvent} event The originating form submission event
|
||||
* @param {HTMLFormElement} form The form element that was submitted
|
||||
* @param {object} submitData Processed and validated form data to be used for a document update
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _processSubmitData(event, form, submitData) {
|
||||
await this.document.update(submitData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Programmatically submit a DocumentSheetV2 instance, providing additional data to be merged with form data.
|
||||
* @param {object} options
|
||||
* @param {object} [options.updateData] Additional data merged with processed form data
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async submit({updateData}={}) {
|
||||
const formConfig = this.options.form;
|
||||
if ( !formConfig?.handler ) throw new Error(`The ${this.constructor.name} DocumentSheetV2 does not support a`
|
||||
+ ` single top-level form element.`);
|
||||
const form = this.element;
|
||||
const event = new Event("submit");
|
||||
const formData = new FormDataExtended(form);
|
||||
const submitData = this._prepareSubmitData(event, form, formData);
|
||||
foundry.utils.mergeObject(submitData, updateData, {inplace: true});
|
||||
await this._processSubmitData(event, form, submitData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @typedef {import("../types.mjs").Constructor} Constructor
|
||||
* @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration
|
||||
* @typedef {import("./types.mjs").ApplicationFormSubmission} ApplicationFormSubmission
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} HandlebarsRenderOptions
|
||||
* @property {string[]} parts An array of named template parts to render
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} HandlebarsTemplatePart
|
||||
* @property {string} template The template entry-point for the part
|
||||
* @property {string} [id] A CSS id to assign to the top-level element of the rendered part.
|
||||
* This id string is automatically prefixed by the application id.
|
||||
* @property {string[]} [classes] An array of CSS classes to apply to the top-level element of the
|
||||
* rendered part.
|
||||
* @property {string[]} [templates] An array of templates that are required to render the part.
|
||||
* If omitted, only the entry-point is inferred as required.
|
||||
* @property {string[]} [scrollable] An array of selectors within this part whose scroll positions should
|
||||
* be persisted during a re-render operation. A blank string is used
|
||||
* to denote that the root level of the part is scrollable.
|
||||
* @property {Record<string, ApplicationFormConfiguration>} [forms] A registry of forms selectors and submission handlers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Augment an Application class with [Handlebars](https://handlebarsjs.com) template rendering behavior.
|
||||
* @param {Constructor} BaseApplication
|
||||
*/
|
||||
export default function HandlebarsApplicationMixin(BaseApplication) {
|
||||
/**
|
||||
* The mixed application class augmented with [Handlebars](https://handlebarsjs.com) template rendering behavior.
|
||||
* @extends {ApplicationV2<ApplicationConfiguration, HandlebarsRenderOptions>}
|
||||
*/
|
||||
class HandlebarsApplication extends BaseApplication {
|
||||
|
||||
/**
|
||||
* Configure a registry of template parts which are supported for this application for partial rendering.
|
||||
* @type {Record<string, HandlebarsTemplatePart>}
|
||||
*/
|
||||
static PARTS = {}
|
||||
|
||||
/**
|
||||
* A record of all rendered template parts.
|
||||
* @returns {Record<string, HTMLElement>}
|
||||
*/
|
||||
get parts() {
|
||||
return this.#parts;
|
||||
}
|
||||
#parts = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_configureRenderOptions(options) {
|
||||
super._configureRenderOptions(options);
|
||||
options.parts ??= Object.keys(this.constructor.PARTS);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preFirstRender(context, options) {
|
||||
await super._preFirstRender(context, options);
|
||||
const allTemplates = new Set();
|
||||
for ( const part of Object.values(this.constructor.PARTS) ) {
|
||||
const partTemplates = part.templates ?? [part.template];
|
||||
for ( const template of partTemplates ) allTemplates.add(template);
|
||||
}
|
||||
await loadTemplates(Array.from(allTemplates));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render each configured application part using Handlebars templates.
|
||||
* @param {ApplicationRenderContext} context Context data for the render operation
|
||||
* @param {HandlebarsRenderOptions} options Options which configure application rendering behavior
|
||||
* @returns {Promise<Record<string, HTMLElement>>} A single rendered HTMLElement for each requested part
|
||||
* @protected
|
||||
* @override
|
||||
*/
|
||||
async _renderHTML(context, options) {
|
||||
const rendered = {}
|
||||
for ( const partId of options.parts ) {
|
||||
const part = this.constructor.PARTS[partId];
|
||||
if ( !part ) {
|
||||
ui.notifications.warn(`Part "${partId}" is not a supported template part for ${this.constructor.name}`);
|
||||
continue;
|
||||
}
|
||||
const partContext = await this._preparePartContext(partId, context, options);
|
||||
try {
|
||||
const htmlString = await renderTemplate(part.template, partContext);
|
||||
rendered[partId] = this.#parsePartHTML(partId, part, htmlString);
|
||||
} catch(err) {
|
||||
throw new Error(`Failed to render template part "${partId}":\n${err.message}`, {cause: err});
|
||||
}
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare context that is specific to only a single rendered part.
|
||||
*
|
||||
* It is recommended to augment or mutate the shared context so that downstream methods like _onRender have
|
||||
* visibility into the data that was used for rendering. It is acceptable to return a different context object
|
||||
* rather than mutating the shared context at the expense of this transparency.
|
||||
*
|
||||
* @param {string} partId The part being rendered
|
||||
* @param {ApplicationRenderContext} context Shared context provided by _prepareContext
|
||||
* @param {HandlebarsRenderOptions} options Options which configure application rendering behavior
|
||||
* @returns {Promise<ApplicationRenderContext>} Context data for a specific part
|
||||
* @protected
|
||||
*/
|
||||
async _preparePartContext(partId, context, options) {
|
||||
context.partId = `${this.id}-${partId}`;
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Parse the returned HTML string from template rendering into a uniquely identified HTMLElement for insertion.
|
||||
* @param {string} partId The id of the part being rendered
|
||||
* @param {HandlebarsTemplatePart} part Configuration of the part being parsed
|
||||
* @param {string} htmlString The string rendered for the part
|
||||
* @returns {HTMLElement} The parsed HTMLElement for the part
|
||||
*/
|
||||
#parsePartHTML(partId, part, htmlString) {
|
||||
const t = document.createElement("template");
|
||||
t.innerHTML = htmlString;
|
||||
if ( (t.content.children.length !== 1) ) {
|
||||
throw new Error(`Template part "${partId}" must render a single HTML element.`);
|
||||
}
|
||||
const e = t.content.firstElementChild;
|
||||
e.dataset.applicationPart = partId;
|
||||
if ( part.id ) e.setAttribute("id", `${this.id}-${part.id}`);
|
||||
if ( part.classes ) e.classList.add(...part.classes);
|
||||
return e;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Replace the HTML of the application with the result provided by Handlebars rendering.
|
||||
* @param {Record<string, HTMLElement>} result The result from Handlebars template rendering
|
||||
* @param {HTMLElement} content The content element into which the rendered result must be inserted
|
||||
* @param {HandlebarsRenderOptions} options Options which configure application rendering behavior
|
||||
* @protected
|
||||
* @override
|
||||
*/
|
||||
_replaceHTML(result, content, options) {
|
||||
for ( const [partId, htmlElement] of Object.entries(result) ) {
|
||||
const priorElement = content.querySelector(`[data-application-part="${partId}"]`);
|
||||
const state = {};
|
||||
if ( priorElement ) {
|
||||
this._preSyncPartState(partId, htmlElement, priorElement, state);
|
||||
priorElement.replaceWith(htmlElement);
|
||||
this._syncPartState(partId, htmlElement, priorElement, state);
|
||||
}
|
||||
else content.appendChild(htmlElement);
|
||||
this._attachPartListeners(partId, htmlElement, options);
|
||||
this.#parts[partId] = htmlElement;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare data used to synchronize the state of a template part.
|
||||
* @param {string} partId The id of the part being rendered
|
||||
* @param {HTMLElement} newElement The new rendered HTML element for the part
|
||||
* @param {HTMLElement} priorElement The prior rendered HTML element for the part
|
||||
* @param {object} state A state object which is used to synchronize after replacement
|
||||
* @protected
|
||||
*/
|
||||
_preSyncPartState(partId, newElement, priorElement, state) {
|
||||
const part = this.constructor.PARTS[partId];
|
||||
|
||||
// Focused element or field
|
||||
const focus = priorElement.querySelector(":focus");
|
||||
if ( focus?.id ) state.focus = `#${focus.id}`;
|
||||
else if ( focus?.name ) state.focus = `${focus.tagName}[name="${focus.name}"]`;
|
||||
else state.focus = undefined;
|
||||
|
||||
// Scroll positions
|
||||
state.scrollPositions = [];
|
||||
for ( const selector of (part.scrollable || []) ) {
|
||||
const el0 = selector === "" ? priorElement : priorElement.querySelector(selector);
|
||||
if ( el0 ) {
|
||||
const el1 = selector === "" ? newElement : newElement.querySelector(selector);
|
||||
if ( el1 ) state.scrollPositions.push([el1, el0.scrollTop, el0.scrollLeft]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Synchronize the state of a template part after it has been rendered and replaced in the DOM.
|
||||
* @param {string} partId The id of the part being rendered
|
||||
* @param {HTMLElement} newElement The new rendered HTML element for the part
|
||||
* @param {HTMLElement} priorElement The prior rendered HTML element for the part
|
||||
* @param {object} state A state object which is used to synchronize after replacement
|
||||
* @protected
|
||||
*/
|
||||
_syncPartState(partId, newElement, priorElement, state) {
|
||||
if ( state.focus ) {
|
||||
const newFocus = newElement.querySelector(state.focus);
|
||||
if ( newFocus ) newFocus.focus();
|
||||
}
|
||||
for ( const [el, scrollTop, scrollLeft] of state.scrollPositions ) Object.assign(el, {scrollTop, scrollLeft});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Attach event listeners to rendered template parts.
|
||||
* @param {string} partId The id of the part being rendered
|
||||
* @param {HTMLElement} htmlElement The rendered HTML element for the part
|
||||
* @param {ApplicationRenderOptions} options Rendering options passed to the render method
|
||||
* @protected
|
||||
*/
|
||||
_attachPartListeners(partId, htmlElement, options) {
|
||||
const part = this.constructor.PARTS[partId];
|
||||
|
||||
// Attach form submission handlers
|
||||
if ( part.forms ) {
|
||||
for ( const [selector, formConfig] of Object.entries(part.forms) ) {
|
||||
const form = htmlElement.matches(selector) ? htmlElement : htmlElement.querySelector(selector);
|
||||
form.addEventListener("submit", this._onSubmitForm.bind(this, formConfig));
|
||||
form.addEventListener("change", this._onChangeForm.bind(this, formConfig));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return HandlebarsApplication;
|
||||
}
|
||||
Reference in New Issue
Block a user