Initial
This commit is contained in:
30
resources/app/client-esm/applications/_module.mjs
Normal file
30
resources/app/client-esm/applications/_module.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
/** @module applications */
|
||||
|
||||
export * as types from "./_types.mjs";
|
||||
export * as api from "./api/_module.mjs";
|
||||
export * as dice from "./dice/_module.mjs";
|
||||
export * as elements from "./elements/_module.mjs";
|
||||
export * as fields from "./forms/fields.mjs";
|
||||
export * as apps from "./apps/_module.mjs";
|
||||
export * as sheets from "./sheets/_module.mjs";
|
||||
export * as ui from "./ui/_module.mjs";
|
||||
|
||||
/**
|
||||
* A registry of currently rendered ApplicationV2 instances.
|
||||
* @type {Map<number, ApplicationV2>}
|
||||
*/
|
||||
export const instances = new Map();
|
||||
|
||||
/**
|
||||
* Parse an HTML string, returning a processed HTMLElement or HTMLCollection.
|
||||
* A single HTMLElement is returned if the provided string contains only a single top-level element.
|
||||
* An HTMLCollection is returned if the provided string contains multiple top-level elements.
|
||||
* @param {string} htmlString
|
||||
* @returns {HTMLCollection|HTMLElement}
|
||||
*/
|
||||
export function parseHTML(htmlString) {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = htmlString;
|
||||
const children = div.children;
|
||||
return children.length > 1 ? children : children[0];
|
||||
}
|
||||
146
resources/app/client-esm/applications/_types.mjs
Normal file
146
resources/app/client-esm/applications/_types.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApplicationConfiguration
|
||||
* @property {string} id An HTML element identifier used for this Application instance
|
||||
* @property {string} uniqueId An string discriminator substituted for {id} in the default
|
||||
* HTML element identifier for the class
|
||||
* @property {string[]} classes An array of CSS classes to apply to the Application
|
||||
* @property {string} tag The HTMLElement tag type used for the outer Application frame
|
||||
* @property {ApplicationWindowConfiguration} window Configuration of the window behaviors for this Application
|
||||
* @property {Record<string, ApplicationClickAction|{handler: ApplicationClickAction, buttons: number[]}>} actions
|
||||
* Click actions supported by the Application and their event handler
|
||||
* functions. A handler function can be defined directly which only
|
||||
* responds to left-click events. Otherwise, an object can be declared
|
||||
* containing both a handler function and an array of buttons which are
|
||||
* matched against the PointerEvent#button property.
|
||||
* @property {ApplicationFormConfiguration} [form] Configuration used if the application top-level element is a form or
|
||||
* dialog
|
||||
* @property {Partial<ApplicationPosition>} position Default positioning data for the application
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApplicationPosition
|
||||
* @property {number} top Window offset pixels from top
|
||||
* @property {number} left Window offset pixels from left
|
||||
* @property {number|"auto"} width Un-scaled pixels in width or "auto"
|
||||
* @property {number|"auto"} height Un-scaled pixels in height or "auto"
|
||||
* @property {number} scale A numeric scaling factor applied to application dimensions
|
||||
* @property {number} zIndex A z-index of the application relative to siblings
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApplicationWindowConfiguration
|
||||
* @property {boolean} [frame=true] Is this Application rendered inside a window frame?
|
||||
* @property {boolean} [positioned=true] Can this Application be positioned via JavaScript or only by CSS
|
||||
* @property {string} [title] The window title. Displayed only if the application is framed
|
||||
* @property {string|false} [icon] An optional Font Awesome icon class displayed left of the window title
|
||||
* @property {ApplicationHeaderControlsEntry[]} [controls] An array of window control entries
|
||||
* @property {boolean} [minimizable=true] Can the window app be minimized by double-clicking on the title
|
||||
* @property {boolean} [resizable=false] Is this window resizable?
|
||||
* @property {string} [contentTag="section"] A specific tag name to use for the .window-content element
|
||||
* @property {string[]} [contentClasses] Additional CSS classes to apply to the .window-content element
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApplicationFormConfiguration
|
||||
* @property {ApplicationFormSubmission} handler
|
||||
* @property {boolean} submitOnChange
|
||||
* @property {boolean} closeOnSubmit
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApplicationHeaderControlsEntry
|
||||
* @property {string} icon A font-awesome icon class which denotes the control button
|
||||
* @property {string} label The text label for the control button. This label will be automatically
|
||||
* localized when the button is rendered
|
||||
* @property {string} action The action name triggered by clicking the control button
|
||||
* @property {boolean} [visible] Is the control button visible for the current client?
|
||||
* @property {string|number} [ownership] A key or value in CONST.DOCUMENT_OWNERSHIP_LEVELS that restricts
|
||||
* visibility of this option for the current user. This option only
|
||||
* applies to DocumentSheetV2 instances.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApplicationConstructorParams
|
||||
* @property {ApplicationPosition} position
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApplicationRenderOptions
|
||||
* @property {boolean} [force=false] Force application rendering. If true, an application which does not
|
||||
* yet exist in the DOM is added. If false, only applications which
|
||||
* already exist are rendered.
|
||||
* @property {ApplicationPosition} [position] A specific position at which to render the Application
|
||||
* @property {ApplicationWindowRenderOptions} [window] Updates to the Application window frame
|
||||
* @property {string[]} [parts] Some Application classes, for example the HandlebarsApplication,
|
||||
* support re-rendering a subset of application parts instead of the full
|
||||
* Application HTML.
|
||||
* @property {boolean} [isFirstRender] Is this render the first one for the application? This property is
|
||||
* populated automatically.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApplicationWindowRenderOptions
|
||||
* @property {string} title Update the window title with a new value?
|
||||
* @property {string|false} icon Update the window icon with a new value?
|
||||
* @property {boolean} controls Re-render the window controls menu?
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApplicationRenderContext Context data provided to the renderer
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApplicationClosingOptions
|
||||
* @property {boolean} animate Whether to animate the close, or perform it instantaneously
|
||||
* @property {boolean} closeKey Whether the application was closed via keypress.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback ApplicationClickAction An on-click action supported by the Application. Run in the context of
|
||||
* a {@link HandlebarsApplication}.
|
||||
* @param {PointerEvent} event The originating click event
|
||||
* @param {HTMLElement} target The capturing HTML element which defines the [data-action]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback ApplicationFormSubmission A form submission handler method. Run in the context of a
|
||||
* {@link HandlebarsApplication}.
|
||||
* @param {SubmitEvent|Event} event The originating form submission or input change event
|
||||
* @param {HTMLFormElement} form The form element that was submitted
|
||||
* @param {FormDataExtended} formData Processed data for the submitted form
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApplicationTab
|
||||
* @property {string} id
|
||||
* @property {string} group
|
||||
* @property {string} icon
|
||||
* @property {string} label
|
||||
* @property {boolean} active
|
||||
* @property {string} cssClass
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FormNode
|
||||
* @property {boolean} fieldset
|
||||
* @property {string} [legend]
|
||||
* @property {FormNode[]} [fields]
|
||||
* @property {DataField} [field]
|
||||
* @property {any} [value]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FormFooterButton
|
||||
* @property {string} type
|
||||
* @property {string} [name]
|
||||
* @property {string} [icon]
|
||||
* @property {string} [label]
|
||||
* @property {string} [action]
|
||||
* @property {string} [cssClass]
|
||||
* @property {boolean} [disabled=false]
|
||||
*/
|
||||
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;
|
||||
}
|
||||
2
resources/app/client-esm/applications/apps/_module.mjs
Normal file
2
resources/app/client-esm/applications/apps/_module.mjs
Normal file
@@ -0,0 +1,2 @@
|
||||
export {default as CompendiumArtConfig} from "./compendium-art-config.mjs";
|
||||
export {default as PermissionConfig} from "./permission-config.mjs";
|
||||
@@ -0,0 +1,105 @@
|
||||
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
|
||||
import ApplicationV2 from "../api/application.mjs";
|
||||
|
||||
/**
|
||||
* An application for configuring compendium art priorities.
|
||||
* @extends ApplicationV2
|
||||
* @mixes HandlebarsApplication
|
||||
* @alias CompendiumArtConfig
|
||||
*/
|
||||
export default class CompendiumArtConfig extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
/** @override */
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: "compendium-art-config",
|
||||
tag: "form",
|
||||
window: {
|
||||
contentClasses: ["standard-form"],
|
||||
icon: "fas fa-palette",
|
||||
title: "COMPENDIUM.ART.SETTING.Title"
|
||||
},
|
||||
position: {
|
||||
width: 600,
|
||||
height: "auto"
|
||||
},
|
||||
form: {
|
||||
closeOnSubmit: true,
|
||||
handler: CompendiumArtConfig.#onSubmit
|
||||
},
|
||||
actions: {
|
||||
priority: CompendiumArtConfig.#onAdjustPriority
|
||||
}
|
||||
};
|
||||
|
||||
/** @override */
|
||||
static PARTS = {
|
||||
priorities: {
|
||||
id: "priorities",
|
||||
template: "templates/apps/compendium-art-config.hbs"
|
||||
},
|
||||
footer: {
|
||||
template: "templates/generic/form-footer.hbs"
|
||||
}
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _prepareContext(_options={}) {
|
||||
return {
|
||||
config: game.compendiumArt.getPackages(),
|
||||
buttons: [{ type: "submit", icon: "fas fa-save", label: "SETUP.SaveConfiguration" }]
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Adjust the priority of a package.
|
||||
* @this {ApplicationV2}
|
||||
* @param {MouseEvent} _event The click event.
|
||||
* @param {HTMLButtonElement} target The button that was clicked.
|
||||
*/
|
||||
static async #onAdjustPriority(_event, target) {
|
||||
const row = target.closest("[data-package-id]");
|
||||
const { packageId } = row.dataset;
|
||||
const configs = [];
|
||||
for ( const element of this.element.elements ) {
|
||||
const [id, key] = element.name.split(".");
|
||||
if ( key === "priority" ) configs.push({ packageId: id, priority: Number(element.value) });
|
||||
}
|
||||
const idx = configs.findIndex(config => config.packageId === packageId);
|
||||
if ( idx < 0 ) return;
|
||||
const sortBefore = "increase" in target.dataset;
|
||||
if ( sortBefore && (idx === 0) ) return;
|
||||
if ( !sortBefore && (idx >= configs.length - 1) ) return;
|
||||
const config = configs[idx];
|
||||
const sortTarget = configs[sortBefore ? idx - 1 : idx + 1];
|
||||
configs.splice(idx, 1);
|
||||
const updates = SortingHelpers.performIntegerSort(config, {
|
||||
sortBefore, target: sortTarget, siblings: configs, sortKey: "priority"
|
||||
});
|
||||
updates.forEach(({ target, update }) => {
|
||||
this.element.elements[`${target.packageId}.priority`].value = update.priority;
|
||||
});
|
||||
if ( sortBefore ) row.previousElementSibling.insertAdjacentElement("beforebegin", row);
|
||||
else row.nextElementSibling.insertAdjacentElement("afterend", row);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Save the compendium art configuration.
|
||||
* @this {ApplicationV2}
|
||||
* @param {SubmitEvent} _event The form submission event.
|
||||
* @param {HTMLFormElement} _form The form element that was submitted.
|
||||
* @param {FormDataExtended} formData Processed data for the submitted form.
|
||||
*/
|
||||
static async #onSubmit(_event, _form, formData) {
|
||||
await game.settings.set("core", game.compendiumArt.SETTING, foundry.utils.expandObject(formData.object));
|
||||
return SettingsConfig.reloadConfirm({ world: true });
|
||||
}
|
||||
}
|
||||
152
resources/app/client-esm/applications/apps/permission-config.mjs
Normal file
152
resources/app/client-esm/applications/apps/permission-config.mjs
Normal file
@@ -0,0 +1,152 @@
|
||||
import ApplicationV2 from "../api/application.mjs";
|
||||
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
|
||||
|
||||
/**
|
||||
* An application for configuring the permissions which are available to each User role.
|
||||
* @extends ApplicationV2
|
||||
* @mixes HandlebarsApplication
|
||||
* @alias PermissionConfig
|
||||
*/
|
||||
export default class PermissionConfig extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
|
||||
/** @inheritDoc */
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: "permissions-config",
|
||||
tag: "form",
|
||||
window: {
|
||||
contentClasses: ["standard-form"],
|
||||
icon: "fa-solid fa-shield-keyhole",
|
||||
title: "PERMISSION.Title",
|
||||
},
|
||||
position: {
|
||||
width: 660,
|
||||
height: "auto"
|
||||
},
|
||||
form: {
|
||||
closeOnSubmit: true,
|
||||
handler: PermissionConfig.#onSubmit
|
||||
},
|
||||
actions: {
|
||||
reset: PermissionConfig.#onReset
|
||||
}
|
||||
};
|
||||
|
||||
/** @override */
|
||||
static PARTS = {
|
||||
permissions: {
|
||||
id: "permissions",
|
||||
template: "templates/apps/permission-config.hbs",
|
||||
scrollable: [".permissions-list"]
|
||||
},
|
||||
footer: {
|
||||
template: "templates/generic/form-footer.hbs"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _prepareContext(_options={}) {
|
||||
const current = await game.settings.get("core", "permissions");
|
||||
return {
|
||||
roles: Object.keys(CONST.USER_ROLES).reduce((obj, r) => {
|
||||
if ( r === "NONE" ) return obj;
|
||||
obj[r] = `USER.Role${r.titleCase()}`;
|
||||
return obj;
|
||||
}, {}),
|
||||
permissions: this.#preparePermissions(current),
|
||||
buttons: [
|
||||
{type: "reset", action: "reset", icon: "fa-solid fa-sync", label: "PERMISSION.Reset"},
|
||||
{type: "submit", icon: "fa-solid fa-save", label: "PERMISSION.Submit"}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare the permissions object used to render the configuration template
|
||||
* @param {object} current The current permission configuration
|
||||
* @returns {object[]} Permission data for sheet rendering
|
||||
*/
|
||||
#preparePermissions(current) {
|
||||
const r = CONST.USER_ROLES;
|
||||
const rgm = r.GAMEMASTER;
|
||||
|
||||
// Get permissions
|
||||
const perms = Object.entries(CONST.USER_PERMISSIONS).reduce((arr, e) => {
|
||||
const perm = foundry.utils.deepClone(e[1]);
|
||||
perm.id = e[0];
|
||||
perm.label = game.i18n.localize(perm.label);
|
||||
perm.hint = game.i18n.localize(perm.hint);
|
||||
arr.push(perm);
|
||||
return arr;
|
||||
}, []);
|
||||
perms.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
|
||||
|
||||
// Configure permission roles
|
||||
for ( let p of perms ) {
|
||||
const roles = current[p.id] || Array.fromRange(rgm + 1).slice(p.defaultRole);
|
||||
p.roles = Object.values(r).reduce((arr, role) => {
|
||||
if ( role === r.NONE ) return arr;
|
||||
arr.push({
|
||||
name: `${p.id}.${role}`,
|
||||
value: roles.includes(role),
|
||||
readonly: (role === rgm) && (!p.disableGM) ? "readonly" : ""
|
||||
});
|
||||
return arr;
|
||||
}, []);
|
||||
}
|
||||
return perms;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle submission
|
||||
* @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 #onSubmit(event, form, formData) {
|
||||
const permissions = foundry.utils.expandObject(formData.object);
|
||||
for ( let [k, v] of Object.entries(permissions) ) {
|
||||
if ( !(k in CONST.USER_PERMISSIONS ) ) {
|
||||
delete permissions[k];
|
||||
continue;
|
||||
}
|
||||
permissions[k] = Object.entries(v).reduce((arr, r) => {
|
||||
if ( r[1] === true ) arr.push(parseInt(r[0]));
|
||||
return arr;
|
||||
}, []);
|
||||
}
|
||||
await game.settings.set("core", "permissions", permissions);
|
||||
ui.notifications.info("SETTINGS.PermissionUpdate", {localize: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click actions to reset all permissions back to their initial state.
|
||||
* @this {PermissionConfig}
|
||||
* @param {PointerEvent} event
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async #onReset(event) {
|
||||
event.preventDefault();
|
||||
const defaults = Object.entries(CONST.USER_PERMISSIONS).reduce((obj, [id, perm]) => {
|
||||
obj[id] = Array.fromRange(CONST.USER_ROLES.GAMEMASTER + 1).slice(perm.defaultRole);
|
||||
return obj;
|
||||
}, {});
|
||||
await game.settings.set("core", "permissions", defaults);
|
||||
ui.notifications.info("SETTINGS.PermissionReset", {localize: true});
|
||||
await this.render();
|
||||
}
|
||||
}
|
||||
1
resources/app/client-esm/applications/dice/_module.mjs
Normal file
1
resources/app/client-esm/applications/dice/_module.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export {default as RollResolver} from "./roll-resolver.mjs";
|
||||
321
resources/app/client-esm/applications/dice/roll-resolver.mjs
Normal file
321
resources/app/client-esm/applications/dice/roll-resolver.mjs
Normal file
@@ -0,0 +1,321 @@
|
||||
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
|
||||
import ApplicationV2 from "../api/application.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {object} DiceTermFulfillmentDescriptor
|
||||
* @property {string} id A unique identifier for the term.
|
||||
* @property {DiceTerm} term The term.
|
||||
* @property {string} method The fulfillment method.
|
||||
* @property {boolean} [isNew] Was the term newly-added to this resolver?
|
||||
*/
|
||||
|
||||
/**
|
||||
* An application responsible for handling unfulfilled dice terms in a roll.
|
||||
* @extends {ApplicationV2<ApplicationConfiguration, ApplicationRenderOptions>}
|
||||
* @mixes HandlebarsApplication
|
||||
* @alias RollResolver
|
||||
*/
|
||||
export default class RollResolver extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
constructor(roll, options={}) {
|
||||
super(options);
|
||||
this.#roll = roll;
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: "roll-resolver-{id}",
|
||||
tag: "form",
|
||||
classes: ["roll-resolver"],
|
||||
window: {
|
||||
title: "DICE.RollResolution",
|
||||
},
|
||||
position: {
|
||||
width: 500,
|
||||
height: "auto"
|
||||
},
|
||||
form: {
|
||||
submitOnChange: false,
|
||||
closeOnSubmit: false,
|
||||
handler: this._fulfillRoll
|
||||
}
|
||||
};
|
||||
|
||||
/** @override */
|
||||
static PARTS = {
|
||||
form: {
|
||||
id: "form",
|
||||
template: "templates/dice/roll-resolver.hbs"
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A collection of fulfillable dice terms.
|
||||
* @type {Map<string, DiceTermFulfillmentDescriptor>}
|
||||
*/
|
||||
get fulfillable() {
|
||||
return this.#fulfillable;
|
||||
}
|
||||
|
||||
#fulfillable = new Map();
|
||||
|
||||
/**
|
||||
* A function to call when the first pass of fulfillment is complete.
|
||||
* @type {function}
|
||||
*/
|
||||
#resolve;
|
||||
|
||||
/**
|
||||
* The roll being resolved.
|
||||
* @type {Roll}
|
||||
*/
|
||||
get roll() {
|
||||
return this.#roll;
|
||||
}
|
||||
|
||||
#roll;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Identify any terms in this Roll that should be fulfilled externally, and prompt the user to do so.
|
||||
* @returns {Promise<void>} Returns a Promise that resolves when the first pass of fulfillment is complete.
|
||||
*/
|
||||
async awaitFulfillment() {
|
||||
const fulfillable = await this.#identifyFulfillableTerms(this.roll.terms);
|
||||
if ( !fulfillable.length ) return;
|
||||
Roll.defaultImplementation.RESOLVERS.set(this.roll, this);
|
||||
this.render(true);
|
||||
return new Promise(resolve => this.#resolve = resolve);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register a fulfilled die roll.
|
||||
* @param {string} method The method used for fulfillment.
|
||||
* @param {string} denomination The denomination of the fulfilled die.
|
||||
* @param {number} result The rolled number.
|
||||
* @returns {boolean} Whether the result was consumed.
|
||||
*/
|
||||
registerResult(method, denomination, result) {
|
||||
const query = `label[data-denomination="${denomination}"][data-method="${method}"] > input:not(:disabled)`;
|
||||
const term = Array.from(this.element.querySelectorAll(query)).find(input => input.value === "");
|
||||
if ( !term ) {
|
||||
ui.notifications.warn(`${denomination} roll was not needed by the resolver.`);
|
||||
return false;
|
||||
}
|
||||
term.value = `${result}`;
|
||||
const submitTerm = term.closest(".form-fields")?.querySelector("button");
|
||||
if ( submitTerm ) submitTerm.dispatchEvent(new MouseEvent("click"));
|
||||
else this._checkDone();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async close(options={}) {
|
||||
if ( this.rendered ) await this.constructor._fulfillRoll.call(this, null, null, new FormDataExtended(this.element));
|
||||
Roll.defaultImplementation.RESOLVERS.delete(this.roll);
|
||||
this.#resolve?.();
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _prepareContext(_options) {
|
||||
const context = {
|
||||
formula: this.roll.formula,
|
||||
groups: {}
|
||||
};
|
||||
for ( const fulfillable of this.fulfillable.values() ) {
|
||||
const { id, term, method, isNew } = fulfillable;
|
||||
fulfillable.isNew = false;
|
||||
const config = CONFIG.Dice.fulfillment.methods[method];
|
||||
const group = context.groups[id] = {
|
||||
results: [],
|
||||
label: term.expression,
|
||||
icon: config.icon ?? '<i class="fas fa-bluetooth"></i>',
|
||||
tooltip: game.i18n.localize(config.label)
|
||||
};
|
||||
const { denomination, faces } = term;
|
||||
const icon = CONFIG.Dice.fulfillment.dice[denomination]?.icon;
|
||||
for ( let i = 0; i < Math.max(term.number ?? 1, term.results.length); i++ ) {
|
||||
const result = term.results[i];
|
||||
const { result: value, exploded, rerolled } = result ?? {};
|
||||
group.results.push({
|
||||
denomination, faces, id, method, icon, exploded, rerolled, isNew,
|
||||
value: value ?? "",
|
||||
readonly: method !== "manual",
|
||||
disabled: !!result
|
||||
});
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _onSubmitForm(formConfig, event) {
|
||||
this._toggleSubmission(false);
|
||||
this.element.querySelectorAll("input").forEach(input => {
|
||||
if ( !isNaN(input.valueAsNumber) ) return;
|
||||
const { term } = this.fulfillable.get(input.name);
|
||||
input.value = `${term.randomFace()}`;
|
||||
});
|
||||
await super._onSubmitForm(formConfig, event);
|
||||
this.element?.querySelectorAll("input").forEach(input => input.disabled = true);
|
||||
this.#resolve();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle prompting for a single extra result from a term.
|
||||
* @param {DiceTerm} term The term.
|
||||
* @param {string} method The method used to obtain the result.
|
||||
* @param {object} [options]
|
||||
* @returns {Promise<number|void>}
|
||||
*/
|
||||
async resolveResult(term, method, { reroll=false, explode=false }={}) {
|
||||
const group = this.element.querySelector(`fieldset[data-term-id="${term._id}"]`);
|
||||
if ( !group ) {
|
||||
console.warn("Attempted to resolve a single result for an unregistered DiceTerm.");
|
||||
return;
|
||||
}
|
||||
const fields = document.createElement("div");
|
||||
fields.classList.add("form-fields");
|
||||
fields.innerHTML = `
|
||||
<label class="icon die-input new-addition" data-denomination="${term.denomination}" data-method="${method}">
|
||||
<input type="number" min="1" max="${term.faces}" step="1" name="${term._id}"
|
||||
${method === "manual" ? "" : "readonly"} placeholder="${game.i18n.localize(term.denomination)}">
|
||||
${reroll ? '<i class="fas fa-arrow-rotate-right"></i>' : ""}
|
||||
${explode ? '<i class="fas fa-burst"></i>' : ""}
|
||||
${CONFIG.Dice.fulfillment.dice[term.denomination]?.icon ?? ""}
|
||||
</label>
|
||||
<button type="button" class="submit-result" data-tooltip="DICE.SubmitRoll"
|
||||
aria-label="${game.i18n.localize("DICE.SubmitRoll")}">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
`;
|
||||
group.appendChild(fields);
|
||||
this.setPosition({ height: "auto" });
|
||||
return new Promise(resolve => {
|
||||
const button = fields.querySelector("button");
|
||||
const input = fields.querySelector("input");
|
||||
button.addEventListener("click", () => {
|
||||
if ( !input.validity.valid ) {
|
||||
input.form.reportValidity();
|
||||
return;
|
||||
}
|
||||
let value = input.valueAsNumber;
|
||||
if ( !value ) value = term.randomFace();
|
||||
input.value = `${value}`;
|
||||
input.disabled = true;
|
||||
button.remove();
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the Roll instance with the fulfilled results.
|
||||
* @this {RollResolver}
|
||||
* @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>}
|
||||
* @protected
|
||||
*/
|
||||
static async _fulfillRoll(event, form, formData) {
|
||||
// Update the DiceTerms with the fulfilled values.
|
||||
for ( let [id, results] of Object.entries(formData.object) ) {
|
||||
const { term } = this.fulfillable.get(id);
|
||||
if ( !Array.isArray(results) ) results = [results];
|
||||
for ( const result of results ) {
|
||||
const roll = { result: undefined, active: true };
|
||||
// A null value indicates the user wishes to skip external fulfillment and fall back to the digital roll.
|
||||
if ( result === null ) roll.result = term.randomFace();
|
||||
else roll.result = result;
|
||||
term.results.push(roll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Identify any of the given terms which should be fulfilled externally.
|
||||
* @param {RollTerm[]} terms The terms.
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.isNew=false] Whether this term is a new addition to the already-rendered RollResolver.
|
||||
* @returns {Promise<DiceTerm[]>}
|
||||
*/
|
||||
async #identifyFulfillableTerms(terms, { isNew=false }={}) {
|
||||
const config = game.settings.get("core", "diceConfiguration");
|
||||
const fulfillable = Roll.defaultImplementation.identifyFulfillableTerms(terms);
|
||||
fulfillable.forEach(term => {
|
||||
if ( term._id ) return;
|
||||
const method = config[term.denomination] || CONFIG.Dice.fulfillment.defaultMethod;
|
||||
const id = foundry.utils.randomID();
|
||||
term._id = id;
|
||||
term.method = method;
|
||||
this.fulfillable.set(id, { id, term, method, isNew });
|
||||
});
|
||||
return fulfillable;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a new term to the resolver.
|
||||
* @param {DiceTerm} term The term.
|
||||
* @returns {Promise<void>} Returns a Promise that resolves when the term's results have been externally fulfilled.
|
||||
*/
|
||||
async addTerm(term) {
|
||||
if ( !(term instanceof foundry.dice.terms.DiceTerm) ) {
|
||||
throw new Error("Only DiceTerm instances may be added to the RollResolver.");
|
||||
}
|
||||
const fulfillable = await this.#identifyFulfillableTerms([term], { isNew: true });
|
||||
if ( !fulfillable.length ) return;
|
||||
this.render({ force: true, position: { height: "auto" } });
|
||||
return new Promise(resolve => this.#resolve = resolve);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check if all rolls have been fulfilled.
|
||||
* @protected
|
||||
*/
|
||||
_checkDone() {
|
||||
// If the form has already in the submission state, we don't need to re-submit.
|
||||
const submitter = this.element.querySelector('button[type="submit"]');
|
||||
if ( submitter.disabled ) return;
|
||||
|
||||
// If there are any manual inputs, or if there are any empty inputs, then fulfillment is not done.
|
||||
if ( this.element.querySelector("input:not([readonly], :disabled)") ) return;
|
||||
for ( const input of this.element.querySelectorAll("input[readonly]:not(:disabled)") ) {
|
||||
if ( input.value === "" ) return;
|
||||
}
|
||||
this.element.requestSubmit(submitter);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle the state of the submit button.
|
||||
* @param {boolean} enabled Whether the button is enabled.
|
||||
* @protected
|
||||
*/
|
||||
_toggleSubmission(enabled) {
|
||||
const submit = this.element.querySelector('button[type="submit"]');
|
||||
const icon = submit.querySelector("i");
|
||||
icon.className = `fas ${enabled ? "fa-check" : "fa-spinner fa-pulse"}`;
|
||||
submit.disabled = !enabled;
|
||||
}
|
||||
}
|
||||
34
resources/app/client-esm/applications/elements/_module.mjs
Normal file
34
resources/app/client-esm/applications/elements/_module.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Custom HTMLElement implementations for use in template rendering.
|
||||
* @module elements
|
||||
*/
|
||||
|
||||
import HTMLDocumentTagsElement from "./document-tags.mjs";
|
||||
import HTMLFilePickerElement from "./file-picker.mjs";
|
||||
import HTMLHueSelectorSlider from "./hue-slider.mjs";
|
||||
import {HTMLMultiSelectElement, HTMLMultiCheckboxElement} from "./multi-select.mjs";
|
||||
import HTMLStringTagsElement from "./string-tags.mjs";
|
||||
import HTMLColorPickerElement from "./color-picker.mjs";
|
||||
import HTMLRangePickerElement from "./range-picker.mjs";
|
||||
import HTMLProseMirrorElement from "./prosemirror-editor.mjs";
|
||||
|
||||
export {default as AbstractFormInputElement} from "./form-element.mjs";
|
||||
export {default as HTMLColorPickerElement} from "./color-picker.mjs";
|
||||
export {default as HTMLDocumentTagsElement} from "./document-tags.mjs";
|
||||
export {default as HTMLFilePickerElement} from "./file-picker.mjs";
|
||||
export {default as HTMLHueSelectorSlider} from "./hue-slider.mjs"
|
||||
export {default as HTMLRangePickerElement} from "./range-picker.mjs"
|
||||
export {default as HTMLStringTagsElement} from "./string-tags.mjs"
|
||||
export {default as HTMLProseMirrorElement} from "./prosemirror-editor.mjs";
|
||||
export * from "./multi-select.mjs";
|
||||
|
||||
// Define custom elements
|
||||
window.customElements.define(HTMLColorPickerElement.tagName, HTMLColorPickerElement);
|
||||
window.customElements.define(HTMLDocumentTagsElement.tagName, HTMLDocumentTagsElement);
|
||||
window.customElements.define(HTMLFilePickerElement.tagName, HTMLFilePickerElement);
|
||||
window.customElements.define(HTMLHueSelectorSlider.tagName, HTMLHueSelectorSlider);
|
||||
window.customElements.define(HTMLMultiSelectElement.tagName, HTMLMultiSelectElement);
|
||||
window.customElements.define(HTMLMultiCheckboxElement.tagName, HTMLMultiCheckboxElement);
|
||||
window.customElements.define(HTMLRangePickerElement.tagName, HTMLRangePickerElement);
|
||||
window.customElements.define(HTMLStringTagsElement.tagName, HTMLStringTagsElement);
|
||||
window.customElements.define(HTMLProseMirrorElement.tagName, HTMLProseMirrorElement);
|
||||
103
resources/app/client-esm/applications/elements/color-picker.mjs
Normal file
103
resources/app/client-esm/applications/elements/color-picker.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
import AbstractFormInputElement from "./form-element.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
|
||||
*/
|
||||
|
||||
/**
|
||||
* A custom HTMLElement used to select a color using a linked pair of input fields.
|
||||
* @extends {AbstractFormInputElement<string>}
|
||||
*/
|
||||
export default class HTMLColorPickerElement extends AbstractFormInputElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._setValue(this.getAttribute("value")); // Initialize existing color value
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static tagName = "color-picker";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The button element to add a new document.
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#colorSelector;
|
||||
|
||||
/**
|
||||
* The input element to define a Document UUID.
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#colorString;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_buildElements() {
|
||||
|
||||
// Create string input element
|
||||
this.#colorString = this._primaryInput = document.createElement("input");
|
||||
this.#colorString.type = "text";
|
||||
this.#colorString.placeholder = this.getAttribute("placeholder") || "";
|
||||
this._applyInputAttributes(this.#colorString);
|
||||
|
||||
// Create color selector element
|
||||
this.#colorSelector = document.createElement("input");
|
||||
this.#colorSelector.type = "color";
|
||||
this._applyInputAttributes(this.#colorSelector);
|
||||
return [this.#colorString, this.#colorSelector];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_refresh() {
|
||||
if ( !this.#colorString ) return; // Not yet connected
|
||||
this.#colorString.value = this._value;
|
||||
this.#colorSelector.value = this._value || this.#colorString.placeholder || "#000000";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_activateListeners() {
|
||||
const onChange = this.#onChangeInput.bind(this);
|
||||
this.#colorString.addEventListener("change", onChange);
|
||||
this.#colorSelector.addEventListener("change", onChange);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle changes to one of the inputs of the color picker element.
|
||||
* @param {InputEvent} event The originating input change event
|
||||
*/
|
||||
#onChangeInput(event) {
|
||||
event.stopPropagation();
|
||||
this.value = event.currentTarget.value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_toggleDisabled(disabled) {
|
||||
this.#colorString.toggleAttribute("disabled", disabled);
|
||||
this.#colorSelector.toggleAttribute("disabled", disabled);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a HTMLColorPickerElement using provided configuration data.
|
||||
* @param {FormInputConfig} config
|
||||
* @returns {HTMLColorPickerElement}
|
||||
*/
|
||||
static create(config) {
|
||||
const picker = document.createElement(HTMLColorPickerElement.tagName);
|
||||
picker.name = config.name;
|
||||
picker.setAttribute("value", config.value ?? "");
|
||||
foundry.applications.fields.setInputAttributes(picker, config);
|
||||
return picker;
|
||||
}
|
||||
}
|
||||
344
resources/app/client-esm/applications/elements/document-tags.mjs
Normal file
344
resources/app/client-esm/applications/elements/document-tags.mjs
Normal file
@@ -0,0 +1,344 @@
|
||||
import AbstractFormInputElement from "./form-element.mjs";
|
||||
import HTMLStringTagsElement from "./string-tags.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DocumentTagsInputConfig
|
||||
* @property {string} [type] A specific document type in CONST.ALL_DOCUMENT_TYPES
|
||||
* @property {boolean} [single] Only allow referencing a single document. In this case the submitted form value will
|
||||
* be a single UUID string rather than an array
|
||||
* @property {number} [max] Only allow attaching a maximum number of documents
|
||||
*/
|
||||
|
||||
/**
|
||||
* A custom HTMLElement used to render a set of associated Documents referenced by UUID.
|
||||
* @extends {AbstractFormInputElement<string|string[]|null>}
|
||||
*/
|
||||
export default class HTMLDocumentTagsElement extends AbstractFormInputElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._initializeTags();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static tagName = "document-tags";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @type {Record<string, string>}
|
||||
* @protected
|
||||
*/
|
||||
_value = {};
|
||||
|
||||
/**
|
||||
* The button element to add a new document.
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#button;
|
||||
|
||||
/**
|
||||
* The input element to define a Document UUID.
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#input;
|
||||
|
||||
/**
|
||||
* The list of tagged documents.
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#tags;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Restrict this element to documents of a particular type.
|
||||
* @type {string|null}
|
||||
*/
|
||||
get type() {
|
||||
return this.getAttribute("type");
|
||||
}
|
||||
|
||||
set type(value) {
|
||||
if ( !value ) return this.removeAttribute("type");
|
||||
if ( !CONST.ALL_DOCUMENT_TYPES.includes(value) ) {
|
||||
throw new Error(`"${value}" is not a valid Document type in CONST.ALL_DOCUMENT_TYPES`);
|
||||
}
|
||||
this.setAttribute("type", value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Restrict to only allow referencing a single Document instead of an array of documents.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get single() {
|
||||
return this.hasAttribute("single");
|
||||
}
|
||||
|
||||
set single(value) {
|
||||
this.toggleAttribute("single", value === true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Allow a maximum number of documents to be tagged to the element.
|
||||
* @type {number}
|
||||
*/
|
||||
get max() {
|
||||
const max = parseInt(this.getAttribute("max"));
|
||||
return isNaN(max) ? Infinity : max;
|
||||
}
|
||||
|
||||
set max(value) {
|
||||
if ( Number.isInteger(value) && (value > 0) ) this.setAttribute("max", String(value));
|
||||
else this.removeAttribute("max");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize innerText or an initial value attribute of the element as a serialized JSON array.
|
||||
* @protected
|
||||
*/
|
||||
_initializeTags() {
|
||||
const initial = this.getAttribute("value") || this.innerText || "";
|
||||
const tags = initial ? initial.split(",") : [];
|
||||
for ( const t of tags ) {
|
||||
try {
|
||||
this.#add(t);
|
||||
} catch(err) {
|
||||
this._value[t] = `${t} [INVALID]`; // Display invalid UUIDs as a raw string
|
||||
}
|
||||
}
|
||||
this.innerText = "";
|
||||
this.removeAttribute("value");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_buildElements() {
|
||||
|
||||
// Create tags list
|
||||
this.#tags = document.createElement("div");
|
||||
this.#tags.className = "tags input-element-tags";
|
||||
|
||||
// Create input element
|
||||
this.#input = this._primaryInput = document.createElement("input");
|
||||
this.#input.type = "text";
|
||||
this.#input.placeholder = game.i18n.format("HTMLDocumentTagsElement.PLACEHOLDER", {
|
||||
type: game.i18n.localize(this.type ? getDocumentClass(this.type).metadata.label : "DOCUMENT.Document")});
|
||||
|
||||
// Create button
|
||||
this.#button = document.createElement("button");
|
||||
this.#button.type = "button"
|
||||
this.#button.className = "icon fa-solid fa-file-plus";
|
||||
this.#button.dataset.tooltip = game.i18n.localize("ELEMENTS.DOCUMENT_TAGS.Add");
|
||||
this.#button.setAttribute("aria-label", this.#button.dataset.tooltip);
|
||||
return [this.#tags, this.#input, this.#button];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_refresh() {
|
||||
if ( !this.#tags ) return; // Not yet connected
|
||||
const tags = Object.entries(this._value).map(([k, v]) => this.constructor.renderTag(k, v, this.editable));
|
||||
this.#tags.replaceChildren(...tags);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create an HTML string fragment for a single document tag.
|
||||
* @param {string} uuid The document UUID
|
||||
* @param {string} name The document name
|
||||
* @param {boolean} [editable=true] Is the tag editable?
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
static renderTag(uuid, name, editable=true) {
|
||||
const div = HTMLStringTagsElement.renderTag(uuid, TextEditor.truncateText(name, {maxLength: 32}), editable);
|
||||
div.classList.add("document-tag");
|
||||
div.querySelector("span").dataset.tooltip = uuid;
|
||||
if ( editable ) {
|
||||
const t = game.i18n.localize("ELEMENTS.DOCUMENT_TAGS.Remove");
|
||||
const a = div.querySelector("a");
|
||||
a.dataset.tooltip = t;
|
||||
a.ariaLabel = t;
|
||||
}
|
||||
return div;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_activateListeners() {
|
||||
this.#button.addEventListener("click", () => this.#tryAdd(this.#input.value));
|
||||
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
|
||||
this.#input.addEventListener("keydown", this.#onKeydown.bind(this));
|
||||
this.addEventListener("drop", this.#onDrop.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a single coefficient by clicking on its tag.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
#onClickTag(event) {
|
||||
if ( !event.target.classList.contains("remove") ) return;
|
||||
const tag = event.target.closest(".tag");
|
||||
delete this._value[tag.dataset.key];
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a new document tag by pressing the ENTER key in the UUID input field.
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onKeydown(event) {
|
||||
if ( event.key !== "Enter" ) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.#tryAdd(this.#input.value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle data dropped onto the form element.
|
||||
* @param {DragEvent} event
|
||||
*/
|
||||
#onDrop(event) {
|
||||
event.preventDefault();
|
||||
const dropData = TextEditor.getDragEventData(event);
|
||||
if ( dropData.uuid ) this.#tryAdd(dropData.uuid);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a Document to the tagged set using the value of the input field.
|
||||
* @param {string} uuid The UUID to attempt to add
|
||||
*/
|
||||
#tryAdd(uuid) {
|
||||
try {
|
||||
this.#add(uuid);
|
||||
this._refresh();
|
||||
} catch(err) {
|
||||
ui.notifications.error(err.message);
|
||||
}
|
||||
this.#input.value = "";
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
||||
this.#input.focus();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate that the tagged document is allowed to be added to this field.
|
||||
* Subclasses may impose more strict validation as to which types of documents are allowed.
|
||||
* @param {foundry.abstract.Document|object} document A candidate document or compendium index entry to tag
|
||||
* @throws {Error} An error if the candidate document is not allowed
|
||||
*/
|
||||
_validateDocument(document) {
|
||||
const {type, max} = this;
|
||||
if ( type && (document.documentName !== type) ) throw new Error(`Incorrect document type "${document.documentName}"`
|
||||
+ ` provided to document tag field which requires "${type}" documents.`);
|
||||
const n = Object.keys(this._value).length;
|
||||
if ( n >= max ) throw new Error(`You may only attach at most ${max} Documents to the "${this.name}" field`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a new UUID to the tagged set, throwing an error if the UUID is not valid.
|
||||
* @param {string} uuid The UUID to add
|
||||
* @throws {Error} If the UUID is not valid
|
||||
*/
|
||||
#add(uuid) {
|
||||
|
||||
// Require the UUID to exist
|
||||
let record;
|
||||
const {id} = foundry.utils.parseUuid(uuid);
|
||||
if ( id ) record = fromUuidSync(uuid);
|
||||
else if ( this.type ) {
|
||||
const collection = game.collections.get(this.type);
|
||||
record = collection.get(uuid);
|
||||
}
|
||||
if ( !record ) throw new Error(`Invalid document UUID "${uuid}" provided to document tag field.`);
|
||||
|
||||
// Require a certain type of document
|
||||
this._validateDocument(record);
|
||||
|
||||
// Replace singleton
|
||||
if ( this.single ) {
|
||||
for ( const k of Object.keys(this._value) ) delete this._value[k];
|
||||
}
|
||||
|
||||
// Record the document
|
||||
this._value[uuid] = record.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Form Handling */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getValue() {
|
||||
const uuids = Object.keys(this._value);
|
||||
if ( this.single ) return uuids[0] ?? null;
|
||||
else return uuids;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_setValue(value) {
|
||||
this._value = {};
|
||||
if ( !value ) return;
|
||||
if ( typeof value === "string" ) value = [value];
|
||||
for ( const uuid of value ) this.#add(uuid);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_toggleDisabled(disabled) {
|
||||
this.#input?.toggleAttribute("disabled", disabled);
|
||||
this.#button?.toggleAttribute("disabled", disabled);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a HTMLDocumentTagsElement using provided configuration data.
|
||||
* @param {FormInputConfig & DocumentTagsInputConfig} config
|
||||
* @returns {HTMLDocumentTagsElement}
|
||||
*/
|
||||
static create(config) {
|
||||
const tags = /** @type {HTMLDocumentTagsElement} */ document.createElement(HTMLDocumentTagsElement.tagName);
|
||||
tags.name = config.name;
|
||||
|
||||
// Coerce value to an array
|
||||
let values;
|
||||
if ( config.value instanceof Set ) values = Array.from(config.value);
|
||||
else if ( !Array.isArray(config.value) ) values = [config.value];
|
||||
else values = config.value;
|
||||
|
||||
tags.setAttribute("value", values);
|
||||
tags.type = config.type;
|
||||
tags.max = config.max;
|
||||
tags.single = config.single;
|
||||
foundry.applications.fields.setInputAttributes(tags, config);
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
158
resources/app/client-esm/applications/elements/file-picker.mjs
Normal file
158
resources/app/client-esm/applications/elements/file-picker.mjs
Normal file
@@ -0,0 +1,158 @@
|
||||
import AbstractFormInputElement from "./form-element.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FilePickerInputConfig
|
||||
* @property {FilePickerOptions.type} [type]
|
||||
* @property {string} [placeholder]
|
||||
* @property {boolean} [noupload]
|
||||
*/
|
||||
|
||||
/**
|
||||
* A custom HTML element responsible for rendering a file input field and associated FilePicker button.
|
||||
* @extends {AbstractFormInputElement<string>}
|
||||
*/
|
||||
export default class HTMLFilePickerElement extends AbstractFormInputElement {
|
||||
|
||||
/** @override */
|
||||
static tagName = "file-picker";
|
||||
|
||||
/**
|
||||
* The file path selected.
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
input;
|
||||
|
||||
/**
|
||||
* A button to open the file picker interface.
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
button;
|
||||
|
||||
/**
|
||||
* A reference to the FilePicker application instance originated by this element.
|
||||
* @type {FilePicker}
|
||||
*/
|
||||
picker;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A type of file which can be selected in this field.
|
||||
* @see {@link FilePicker.FILE_TYPES}
|
||||
* @type {FilePickerOptions.type}
|
||||
*/
|
||||
get type() {
|
||||
return this.getAttribute("type") ?? "any";
|
||||
}
|
||||
|
||||
set type(value) {
|
||||
if ( !FilePicker.FILE_TYPES.includes(value) ) throw new Error(`Invalid type "${value}" provided which must be a `
|
||||
+ "value in FilePicker.TYPES");
|
||||
this.setAttribute("type", value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prevent uploading new files as part of this element's FilePicker dialog.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get noupload() {
|
||||
return this.hasAttribute("noupload");
|
||||
}
|
||||
|
||||
set noupload(value) {
|
||||
this.toggleAttribute("noupload", value === true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_buildElements() {
|
||||
|
||||
// Initialize existing value
|
||||
this._value ??= this.getAttribute("value") || this.innerText || "";
|
||||
this.removeAttribute("value");
|
||||
|
||||
// Create an input field
|
||||
const elements = [];
|
||||
this.input = this._primaryInput = document.createElement("input");
|
||||
this.input.className = "image";
|
||||
this.input.type = "text";
|
||||
this.input.placeholder = this.getAttribute("placeholder") ?? "path/to/file.ext";
|
||||
elements.push(this.input);
|
||||
|
||||
// Disallow browsing for some users
|
||||
if ( game.world && !game.user.can("FILES_BROWSE") ) return elements;
|
||||
|
||||
// Create a FilePicker button
|
||||
this.button = document.createElement("button");
|
||||
this.button.className = "fa-solid fa-file-import fa-fw";
|
||||
this.button.type = "button";
|
||||
this.button.dataset.tooltip = game.i18n.localize("FILES.BrowseTooltip");
|
||||
this.button.setAttribute("aria-label", this.button.dataset.tooltip);
|
||||
this.button.tabIndex = -1;
|
||||
elements.push(this.button);
|
||||
return elements;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_refresh() {
|
||||
this.input.value = this._value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_toggleDisabled(disabled) {
|
||||
this.input.disabled = disabled;
|
||||
if ( this.button ) this.button.disabled = disabled;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_activateListeners() {
|
||||
this.input.addEventListener("input", () => this._value = this.input.value);
|
||||
this.button?.addEventListener("click", this.#onClickButton.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clicks on the button element to render the FilePicker UI.
|
||||
* @param {PointerEvent} event The initiating click event
|
||||
*/
|
||||
#onClickButton(event) {
|
||||
event.preventDefault();
|
||||
this.picker = new FilePicker({
|
||||
type: this.type,
|
||||
current: this.value,
|
||||
allowUpload: !this.noupload,
|
||||
callback: src => this.value = src
|
||||
});
|
||||
return this.picker.browse();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a HTMLFilePickerElement using provided configuration data.
|
||||
* @param {FormInputConfig<string> & FilePickerInputConfig} config
|
||||
*/
|
||||
static create(config) {
|
||||
const picker = document.createElement(this.tagName);
|
||||
picker.name = config.name;
|
||||
picker.setAttribute("value", config.value || "");
|
||||
picker.type = config.type;
|
||||
picker.noupload = config.noupload;
|
||||
foundry.applications.fields.setInputAttributes(picker, config);
|
||||
return picker;
|
||||
}
|
||||
}
|
||||
214
resources/app/client-esm/applications/elements/form-element.mjs
Normal file
214
resources/app/client-esm/applications/elements/form-element.mjs
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* An abstract custom HTMLElement designed for use with form inputs.
|
||||
* @abstract
|
||||
* @template {any} FormInputValueType
|
||||
*
|
||||
* @fires {Event} input An "input" event when the value of the input changes
|
||||
* @fires {Event} change A "change" event when the value of the element changes
|
||||
*/
|
||||
export default class AbstractFormInputElement extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._internals = this.attachInternals();
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTML tag name used by this element.
|
||||
* @type {string}
|
||||
*/
|
||||
static tagName;
|
||||
|
||||
/**
|
||||
* Declare that this custom element provides form element functionality.
|
||||
* @type {boolean}
|
||||
*/
|
||||
static formAssociated = true;
|
||||
|
||||
/**
|
||||
* Attached ElementInternals which provides form handling functionality.
|
||||
* @type {ElementInternals}
|
||||
* @protected
|
||||
*/
|
||||
_internals;
|
||||
|
||||
/**
|
||||
* The primary input (if any). Used to determine what element should receive focus when an associated label is clicked
|
||||
* on.
|
||||
* @type {HTMLElement}
|
||||
* @protected
|
||||
*/
|
||||
_primaryInput;
|
||||
|
||||
/**
|
||||
* The form this element belongs to.
|
||||
* @type {HTMLFormElement}
|
||||
*/
|
||||
get form() {
|
||||
return this._internals.form;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Element Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The input element name.
|
||||
* @type {string}
|
||||
*/
|
||||
get name() {
|
||||
return this.getAttribute("name");
|
||||
}
|
||||
|
||||
set name(value) {
|
||||
this.setAttribute("name", value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The value of the input element.
|
||||
* @type {FormInputValueType}
|
||||
*/
|
||||
get value() {
|
||||
return this._getValue();
|
||||
}
|
||||
|
||||
set value(value) {
|
||||
this._setValue(value);
|
||||
this.dispatchEvent(new Event("input", {bubbles: true, cancelable: true}));
|
||||
this.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* The underlying value of the element.
|
||||
* @type {FormInputValueType}
|
||||
* @protected
|
||||
*/
|
||||
_value;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the value of the input element which should be submitted to the form.
|
||||
* @returns {FormInputValueType}
|
||||
* @protected
|
||||
*/
|
||||
_getValue() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Translate user-provided input value into the format that should be stored.
|
||||
* @param {FormInputValueType} value A new value to assign to the element
|
||||
* @throws {Error} An error if the provided value is invalid
|
||||
* @protected
|
||||
*/
|
||||
_setValue(value) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this element disabled?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get disabled() {
|
||||
return this.hasAttribute("disabled");
|
||||
}
|
||||
|
||||
set disabled(value) {
|
||||
this.toggleAttribute("disabled", value);
|
||||
this._toggleDisabled(!this.editable);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this field editable? The field can be neither disabled nor readonly.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get editable() {
|
||||
return !(this.hasAttribute("disabled") || this.hasAttribute("readonly"));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Special behaviors that the subclass should implement when toggling the disabled state of the input.
|
||||
* @param {boolean} disabled The new disabled state
|
||||
* @protected
|
||||
*/
|
||||
_toggleDisabled(disabled) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Element Lifecycle */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the custom element, constructing its HTML.
|
||||
*/
|
||||
connectedCallback() {
|
||||
const elements = this._buildElements();
|
||||
this.replaceChildren(...elements);
|
||||
this._refresh();
|
||||
this._toggleDisabled(!this.editable);
|
||||
this.addEventListener("click", this._onClick.bind(this));
|
||||
this._activateListeners();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the HTML elements that should be included in this custom element.
|
||||
* Elements are returned as an array of ordered children.
|
||||
* @returns {HTMLElement[]}
|
||||
* @protected
|
||||
*/
|
||||
_buildElements() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Refresh the active state of the custom element.
|
||||
* @protected
|
||||
*/
|
||||
_refresh() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply key attributes on the containing custom HTML element to input elements contained within it.
|
||||
* @internal
|
||||
*/
|
||||
_applyInputAttributes(input) {
|
||||
input.toggleAttribute("required", this.hasAttribute("required"));
|
||||
input.toggleAttribute("disabled", this.hasAttribute("disabled"));
|
||||
input.toggleAttribute("readonly", this.hasAttribute("readonly"));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate event listeners which add dynamic behavior to the custom element.
|
||||
* @protected
|
||||
*/
|
||||
_activateListeners() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Special handling when the custom element is clicked. This should be implemented to transfer focus to an
|
||||
* appropriate internal element.
|
||||
* @param {PointerEvent} event
|
||||
* @protected
|
||||
*/
|
||||
_onClick(event) {
|
||||
if ( event.target === this ) this._primaryInput?.focus?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import AbstractFormInputElement from "./form-element.mjs";
|
||||
|
||||
/**
|
||||
* A class designed to standardize the behavior for a hue selector UI component.
|
||||
* @extends {AbstractFormInputElement<number>}
|
||||
*/
|
||||
export default class HTMLHueSelectorSlider extends AbstractFormInputElement {
|
||||
|
||||
/** @override */
|
||||
static tagName = "hue-slider";
|
||||
|
||||
/**
|
||||
* The color range associated with this element.
|
||||
* @type {HTMLInputElement|null}
|
||||
*/
|
||||
#input;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_buildElements() {
|
||||
|
||||
// Initialize existing value
|
||||
this._setValue(this.getAttribute("value"));
|
||||
|
||||
// Build elements
|
||||
this.#input = this._primaryInput = document.createElement("input");
|
||||
this.#input.className = "color-range";
|
||||
this.#input.type = "range";
|
||||
this.#input.min = "0";
|
||||
this.#input.max = "360";
|
||||
this.#input.step = "1";
|
||||
this.#input.disabled = this.disabled;
|
||||
this.#input.value = this._value * 360;
|
||||
return [this.#input];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Refresh the active state of the custom element.
|
||||
* @protected
|
||||
*/
|
||||
_refresh() {
|
||||
this.#input.style.setProperty("--color-thumb", Color.fromHSL([this._value, 1, 0.5]).css);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate event listeners which add dynamic behavior to the custom element.
|
||||
* @protected
|
||||
*/
|
||||
_activateListeners() {
|
||||
this.#input.oninput = this.#onInputColorRange.bind(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the thumb and the value.
|
||||
* @param {FormDataEvent} event
|
||||
*/
|
||||
#onInputColorRange(event) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
this.value = this.#input.value / 360;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Form Handling
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_setValue(value) {
|
||||
value = Number(value);
|
||||
if ( !value.between(0, 1) ) throw new Error("The value of a hue-slider must be on the range [0,1]");
|
||||
this._value = value;
|
||||
this.setAttribute("value", String(value));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_toggleDisabled(disabled) {
|
||||
this.#input.disabled = disabled;
|
||||
}
|
||||
}
|
||||
361
resources/app/client-esm/applications/elements/multi-select.mjs
Normal file
361
resources/app/client-esm/applications/elements/multi-select.mjs
Normal file
@@ -0,0 +1,361 @@
|
||||
import AbstractFormInputElement from "./form-element.mjs";
|
||||
import HTMLStringTagsElement from "./string-tags.mjs";
|
||||
|
||||
/**
|
||||
* An abstract base class designed to standardize the behavior for a multi-select UI component.
|
||||
* Multi-select components return an array of values as part of form submission.
|
||||
* Different implementations may provide different experiences around how inputs are presented to the user.
|
||||
* @extends {AbstractFormInputElement<Set<string>>}
|
||||
*/
|
||||
export class AbstractMultiSelectElement extends AbstractFormInputElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._value = new Set();
|
||||
this._initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined <option> and <optgroup> elements which were defined in the original HTML.
|
||||
* @type {(HTMLOptionElement|HTMLOptGroupElement)[]}
|
||||
* @protected
|
||||
*/
|
||||
_options;
|
||||
|
||||
/**
|
||||
* An object which maps option values to displayed labels.
|
||||
* @type {Record<string, string>}
|
||||
* @protected
|
||||
*/
|
||||
_choices = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Preserve existing <option> and <optgroup> elements which are defined in the original HTML.
|
||||
* @protected
|
||||
*/
|
||||
_initialize() {
|
||||
this._options = [...this.children];
|
||||
for ( const option of this.querySelectorAll("option") ) {
|
||||
if ( !option.value ) continue; // Skip predefined options which are already blank
|
||||
this._choices[option.value] = option.innerText;
|
||||
if ( option.selected ) {
|
||||
this._value.add(option.value);
|
||||
option.selected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Mark a choice as selected.
|
||||
* @param {string} value The value to add to the chosen set
|
||||
*/
|
||||
select(value) {
|
||||
const exists = this._value.has(value);
|
||||
if ( !exists ) {
|
||||
if ( !(value in this._choices) ) {
|
||||
throw new Error(`"${value}" is not an option allowed by this multi-select element`);
|
||||
}
|
||||
this._value.add(value);
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
||||
this._refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Mark a choice as un-selected.
|
||||
* @param {string} value The value to delete from the chosen set
|
||||
*/
|
||||
unselect(value) {
|
||||
const exists = this._value.has(value);
|
||||
if ( exists ) {
|
||||
this._value.delete(value);
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
||||
this._refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Form Handling */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getValue() {
|
||||
return Array.from(this._value);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_setValue(value) {
|
||||
if ( !Array.isArray(value) ) {
|
||||
throw new Error("The value assigned to a multi-select element must be an array.");
|
||||
}
|
||||
if ( value.some(v => !(v in this._choices)) ) {
|
||||
throw new Error("The values assigned to a multi-select element must all be valid options.");
|
||||
}
|
||||
this._value.clear();
|
||||
for ( const v of value ) this._value.add(v);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a multi-select workflow using a select element as the input mechanism.
|
||||
*
|
||||
* @example Multi-Select HTML Markup
|
||||
* ```html
|
||||
* <multi-select name="select-many-things">
|
||||
* <optgroup label="Basic Options">
|
||||
* <option value="foo">Foo</option>
|
||||
* <option value="bar">Bar</option>
|
||||
* <option value="baz">Baz</option>
|
||||
* </optgroup>
|
||||
* <optgroup label="Advanced Options">
|
||||
* <option value="fizz">Fizz</option>
|
||||
* <option value="buzz">Buzz</option>
|
||||
* </optgroup>
|
||||
* </multi-select>
|
||||
* ```
|
||||
*/
|
||||
export class HTMLMultiSelectElement extends AbstractMultiSelectElement {
|
||||
|
||||
/** @override */
|
||||
static tagName = "multi-select";
|
||||
|
||||
/**
|
||||
* A select element used to choose options.
|
||||
* @type {HTMLSelectElement}
|
||||
*/
|
||||
#select;
|
||||
|
||||
/**
|
||||
* A display element which lists the chosen options.
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#tags;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_buildElements() {
|
||||
|
||||
// Create select element
|
||||
this.#select = this._primaryInput = document.createElement("select");
|
||||
this.#select.insertAdjacentHTML("afterbegin", '<option value=""></option>');
|
||||
this.#select.append(...this._options);
|
||||
this.#select.disabled = !this.editable;
|
||||
|
||||
// Create a div element for display
|
||||
this.#tags = document.createElement("div");
|
||||
this.#tags.className = "tags input-element-tags";
|
||||
return [this.#tags, this.#select];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_refresh() {
|
||||
|
||||
// Update the displayed tags
|
||||
const tags = Array.from(this._value).map(id => {
|
||||
return HTMLStringTagsElement.renderTag(id, this._choices[id], this.editable);
|
||||
});
|
||||
this.#tags.replaceChildren(...tags);
|
||||
|
||||
// Disable selected options
|
||||
for ( const option of this.#select.querySelectorAll("option") ) {
|
||||
option.disabled = this._value.has(option.value);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_activateListeners() {
|
||||
this.#select.addEventListener("change", this.#onChangeSelect.bind(this));
|
||||
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle changes to the Select input, marking the selected option as a chosen value.
|
||||
* @param {Event} event The change event on the select element
|
||||
*/
|
||||
#onChangeSelect(event) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const select = event.currentTarget;
|
||||
if ( !select.value ) return; // Ignore selection of the blank value
|
||||
this.select(select.value);
|
||||
select.value = "";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events on a tagged value, removing it from the chosen set.
|
||||
* @param {PointerEvent} event The originating click event on a chosen tag
|
||||
*/
|
||||
#onClickTag(event) {
|
||||
event.preventDefault();
|
||||
if ( !event.target.classList.contains("remove") ) return;
|
||||
if ( !this.editable ) return;
|
||||
const tag = event.target.closest(".tag");
|
||||
this.unselect(tag.dataset.key);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_toggleDisabled(disabled) {
|
||||
this.#select.toggleAttribute("disabled", disabled);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a HTMLMultiSelectElement using provided configuration data.
|
||||
* @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
|
||||
* @returns {HTMLMultiSelectElement}
|
||||
*/
|
||||
static create(config) {
|
||||
return foundry.applications.fields.createMultiSelectInput(config);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a multi-select workflow as a grid of input checkbox elements.
|
||||
*
|
||||
* @example Multi-Checkbox HTML Markup
|
||||
* ```html
|
||||
* <multi-checkbox name="check-many-boxes">
|
||||
* <optgroup label="Basic Options">
|
||||
* <option value="foo">Foo</option>
|
||||
* <option value="bar">Bar</option>
|
||||
* <option value="baz">Baz</option>
|
||||
* </optgroup>
|
||||
* <optgroup label="Advanced Options">
|
||||
* <option value="fizz">Fizz</option>
|
||||
* <option value="buzz">Buzz</option>
|
||||
* </optgroup>
|
||||
* </multi-checkbox>
|
||||
* ```
|
||||
*/
|
||||
export class HTMLMultiCheckboxElement extends AbstractMultiSelectElement {
|
||||
|
||||
/** @override */
|
||||
static tagName = "multi-checkbox";
|
||||
|
||||
/**
|
||||
* The checkbox elements used to select inputs
|
||||
* @type {HTMLInputElement[]}
|
||||
*/
|
||||
#checkboxes;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_buildElements() {
|
||||
this.#checkboxes = [];
|
||||
const children = [];
|
||||
for ( const option of this._options ) {
|
||||
if ( option instanceof HTMLOptGroupElement ) children.push(this.#buildGroup(option));
|
||||
else children.push(this.#buildOption(option));
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Translate an input <optgroup> element into a <fieldset> of checkboxes.
|
||||
* @param {HTMLOptGroupElement} optgroup The originally configured optgroup
|
||||
* @returns {HTMLFieldSetElement} The created fieldset grouping
|
||||
*/
|
||||
#buildGroup(optgroup) {
|
||||
|
||||
// Create fieldset group
|
||||
const group = document.createElement("fieldset");
|
||||
group.classList.add("checkbox-group");
|
||||
const legend = document.createElement("legend");
|
||||
legend.innerText = optgroup.label;
|
||||
group.append(legend);
|
||||
|
||||
// Add child options
|
||||
for ( const option of optgroup.children ) {
|
||||
if ( option instanceof HTMLOptionElement ) {
|
||||
group.append(this.#buildOption(option));
|
||||
}
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build an input <option> element into a <label class="checkbox"> element.
|
||||
* @param {HTMLOptionElement} option The originally configured option
|
||||
* @returns {HTMLLabelElement} The created labeled checkbox element
|
||||
*/
|
||||
#buildOption(option) {
|
||||
const label = document.createElement("label");
|
||||
label.classList.add("checkbox");
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.value = option.value;
|
||||
checkbox.checked = this._value.has(option.value);
|
||||
checkbox.disabled = this.disabled;
|
||||
label.append(checkbox, option.innerText);
|
||||
this.#checkboxes.push(checkbox);
|
||||
return label;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_refresh() {
|
||||
for ( const checkbox of this.#checkboxes ) {
|
||||
checkbox.checked = this._value.has(checkbox.value);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_activateListeners() {
|
||||
for ( const checkbox of this.#checkboxes ) {
|
||||
checkbox.addEventListener("change", this.#onChangeCheckbox.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle changes to a checkbox input, marking the selected option as a chosen value.
|
||||
* @param {Event} event The change event on the checkbox input element
|
||||
*/
|
||||
#onChangeCheckbox(event) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const checkbox = event.currentTarget;
|
||||
if ( checkbox.checked ) this.select(checkbox.value);
|
||||
else this.unselect(checkbox.value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_toggleDisabled(disabled) {
|
||||
for ( const checkbox of this.#checkboxes ) {
|
||||
checkbox.disabled = disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import AbstractFormInputElement from "./form-element.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProseMirrorInputConfig
|
||||
* @property {boolean} toggled Is this editor toggled (true) or always active (false)
|
||||
* @property {string} [enriched] If the editor is toggled, provide the enrichedHTML which is displayed while
|
||||
* the editor is not active.
|
||||
* @property {boolean} collaborate Does this editor instance support collaborative editing?
|
||||
* @property {boolean} compact Should the editor be presented in compact mode?
|
||||
* @property {string} documentUUID A Document UUID. Required for collaborative editing
|
||||
*/
|
||||
|
||||
/**
|
||||
* A custom HTML element responsible displaying a ProseMirror rich text editor.
|
||||
* @extends {AbstractFormInputElement<string>}
|
||||
*/
|
||||
export default class HTMLProseMirrorElement extends AbstractFormInputElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Initialize raw content
|
||||
this._setValue(this.getAttribute("value") || "");
|
||||
this.removeAttribute("value");
|
||||
|
||||
// Initialize enriched content
|
||||
this.#toggled = this.hasAttribute("toggled");
|
||||
this.#enriched = this.innerHTML;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static tagName = "prose-mirror";
|
||||
|
||||
/**
|
||||
* Is the editor in active edit mode?
|
||||
* @type {boolean}
|
||||
*/
|
||||
#active = false;
|
||||
|
||||
/**
|
||||
* The ProseMirror editor instance.
|
||||
* @type {ProseMirrorEditor}
|
||||
*/
|
||||
#editor;
|
||||
|
||||
/**
|
||||
* Current editor contents
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#content;
|
||||
|
||||
/**
|
||||
* Does this editor function via a toggle button? Or is it always active?
|
||||
* @type {boolean}
|
||||
*/
|
||||
#toggled;
|
||||
|
||||
/**
|
||||
* Enriched content which is optionally used if the editor is toggled.
|
||||
* @type {string}
|
||||
*/
|
||||
#enriched;
|
||||
|
||||
/**
|
||||
* An optional edit button which activates edit mode for the editor
|
||||
* @type {HTMLButtonElement|null}
|
||||
*/
|
||||
#button = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Actions to take when the custom element is removed from the document.
|
||||
*/
|
||||
disconnectedCallback() {
|
||||
this.#editor?.destroy();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_buildElements() {
|
||||
this.classList.add("editor", "prosemirror", "inactive");
|
||||
const elements = [];
|
||||
this.#content = document.createElement("div");
|
||||
this.#content.className = "editor-content";
|
||||
elements.push(this.#content);
|
||||
if ( this.#toggled ) {
|
||||
this.#button = document.createElement("button");
|
||||
this.#button.type = "button";
|
||||
this.#button.className = "icon toggle";
|
||||
this.#button.innerHTML = `<i class="fa-solid fa-edit"></i>`;
|
||||
elements.push(this.#button);
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_refresh() {
|
||||
if ( this.#active ) return; // It is not safe to replace the content while the editor is active
|
||||
if ( this.#toggled ) this.#content.innerHTML = this.#enriched ?? this._value;
|
||||
else this.#content.innerHTML = this._value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_activateListeners() {
|
||||
if ( this.#toggled ) this.#button.addEventListener("click", this.#onClickButton.bind(this));
|
||||
else this.#activateEditor();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getValue() {
|
||||
if ( this.#active ) return ProseMirror.dom.serializeString(this.#editor.view.state.doc.content);
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate the ProseMirror editor.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #activateEditor() {
|
||||
|
||||
// If the editor was toggled, replace with raw editable content
|
||||
if ( this.#toggled ) this.#content.innerHTML = this._value;
|
||||
|
||||
// Create the TextEditor instance
|
||||
const document = await fromUuid(this.dataset.documentUuid ?? this.dataset.documentUUID);
|
||||
this.#editor = await TextEditor.create({
|
||||
engine: "prosemirror",
|
||||
plugins: this._configurePlugins(),
|
||||
fieldName: this.name,
|
||||
collaborate: this.hasAttribute("collaborate"),
|
||||
target: this.#content,
|
||||
document
|
||||
}, this._getValue());
|
||||
|
||||
// Toggle active state
|
||||
this.#active = true;
|
||||
if ( this.#button ) this.#button.disabled = true;
|
||||
this.classList.add("active");
|
||||
this.classList.remove("inactive");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Configure ProseMirror editor plugins.
|
||||
* @returns {Record<string, ProseMirror.Plugin>}
|
||||
* @protected
|
||||
*/
|
||||
_configurePlugins() {
|
||||
return {
|
||||
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
|
||||
compact: this.hasAttribute("compact"),
|
||||
destroyOnSave: this.#toggled,
|
||||
onSave: this.#save.bind(this)
|
||||
}),
|
||||
keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema, {
|
||||
onSave: this.#save.bind(this)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clicking the editor activation button.
|
||||
* @param {PointerEvent} event The triggering event.
|
||||
*/
|
||||
#onClickButton(event) {
|
||||
event.preventDefault();
|
||||
this.#activateEditor();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle saving the editor content.
|
||||
* Store new parsed HTML into the _value attribute of the element.
|
||||
* If the editor is toggled, also deactivate editing mode.
|
||||
*/
|
||||
#save() {
|
||||
const value = ProseMirror.dom.serializeString(this.#editor.view.state.doc.content);
|
||||
if ( value !== this._value ) {
|
||||
this._setValue(value);
|
||||
this.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
|
||||
}
|
||||
|
||||
// Deactivate a toggled editor
|
||||
if ( this.#toggled ) {
|
||||
this.#button.disabled = this.disabled;
|
||||
this.#active = false;
|
||||
this.#editor.destroy();
|
||||
this.classList.remove("active");
|
||||
this.classList.add("inactive");
|
||||
this.replaceChildren(this.#button, this.#content);
|
||||
this._refresh();
|
||||
this.dispatchEvent(new Event("close", {bubbles: true, cancelable: true}));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_toggleDisabled(disabled) {
|
||||
if ( this.#toggled ) this.#button.disabled = disabled;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a HTMLProseMirrorElement using provided configuration data.
|
||||
* @param {FormInputConfig & ProseMirrorInputConfig} config
|
||||
* @returns {HTMLProseMirrorElement}
|
||||
*/
|
||||
static create(config) {
|
||||
const editor = document.createElement(HTMLProseMirrorElement.tagName);
|
||||
editor.name = config.name;
|
||||
|
||||
// Configure editor properties
|
||||
editor.toggleAttribute("collaborate", config.collaborate ?? false);
|
||||
editor.toggleAttribute("compact", config.compact ?? false);
|
||||
editor.toggleAttribute("toggled", config.toggled ?? false);
|
||||
if ( "documentUUID" in config ) Object.assign(editor.dataset, {
|
||||
documentUuid: config.documentUUID,
|
||||
documentUUID: config.documentUUID
|
||||
});
|
||||
if ( Number.isNumeric(config.height) ) editor.style.height = `${config.height}px`;
|
||||
|
||||
// Un-enriched content gets temporarily assigned to the value property of the element
|
||||
editor.setAttribute("value", config.value);
|
||||
|
||||
// Enriched content gets temporarily assigned as the innerHTML of the element
|
||||
if ( config.toggled && config.enriched ) editor.innerHTML = config.enriched;
|
||||
return editor;
|
||||
}
|
||||
}
|
||||
166
resources/app/client-esm/applications/elements/range-picker.mjs
Normal file
166
resources/app/client-esm/applications/elements/range-picker.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
import AbstractFormInputElement from "./form-element.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RangePickerInputConfig
|
||||
* @property {number} min
|
||||
* @property {number} max
|
||||
* @property {number} [step]
|
||||
*/
|
||||
|
||||
/**
|
||||
* A custom HTML element responsible selecting a value on a range slider with a linked number input field.
|
||||
* @extends {AbstractFormInputElement<number>}
|
||||
*/
|
||||
export default class HTMLRangePickerElement extends AbstractFormInputElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.#min = Number(this.getAttribute("min")) ?? 0;
|
||||
this.#max = Number(this.getAttribute("max")) ?? 1;
|
||||
this.#step = Number(this.getAttribute("step")) || undefined;
|
||||
this._setValue(Number(this.getAttribute("value"))); // Initialize existing value
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static tagName = "range-picker";
|
||||
|
||||
/**
|
||||
* The range input.
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#rangeInput;
|
||||
|
||||
/**
|
||||
* The number input.
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#numberInput;
|
||||
|
||||
/**
|
||||
* The minimum allowed value for the range.
|
||||
* @type {number}
|
||||
*/
|
||||
#min;
|
||||
|
||||
/**
|
||||
* The maximum allowed value for the range.
|
||||
* @type {number}
|
||||
*/
|
||||
#max;
|
||||
|
||||
/**
|
||||
* A required step size for the range.
|
||||
* @type {number}
|
||||
*/
|
||||
#step;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The value of the input element.
|
||||
* @type {number}
|
||||
*/
|
||||
get valueAsNumber() {
|
||||
return this._getValue();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_buildElements() {
|
||||
|
||||
// Create range input element
|
||||
const r = this.#rangeInput = document.createElement("input");
|
||||
r.type = "range";
|
||||
r.min = String(this.#min);
|
||||
r.max = String(this.#max);
|
||||
r.step = String(this.#step ?? 0.1);
|
||||
this._applyInputAttributes(r);
|
||||
|
||||
// Create the number input element
|
||||
const n = this.#numberInput = this._primaryInput = document.createElement("input");
|
||||
n.type = "number";
|
||||
n.min = String(this.#min);
|
||||
n.max = String(this.#max);
|
||||
n.step = this.#step ?? "any";
|
||||
this._applyInputAttributes(n);
|
||||
return [this.#rangeInput, this.#numberInput];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_setValue(value) {
|
||||
value = Math.clamp(value, this.#min, this.#max);
|
||||
if ( this.#step ) value = value.toNearest(this.#step);
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_refresh() {
|
||||
if ( !this.#rangeInput ) return; // Not yet connected
|
||||
this.#rangeInput.valueAsNumber = this.#numberInput.valueAsNumber = this._value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_activateListeners() {
|
||||
const onChange = this.#onChangeInput.bind(this);
|
||||
this.#rangeInput.addEventListener("input", this.#onDragSlider.bind(this));
|
||||
this.#rangeInput.addEventListener("change", onChange);
|
||||
this.#numberInput.addEventListener("change", onChange);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update display of the number input as the range slider is actively changed.
|
||||
* @param {InputEvent} event The originating input event
|
||||
*/
|
||||
#onDragSlider(event) {
|
||||
event.preventDefault();
|
||||
this.#numberInput.valueAsNumber = this.#rangeInput.valueAsNumber;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle changes to one of the inputs of the range picker element.
|
||||
* @param {InputEvent} event The originating input change event
|
||||
*/
|
||||
#onChangeInput(event) {
|
||||
event.stopPropagation();
|
||||
this.value = event.currentTarget.valueAsNumber;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_toggleDisabled(disabled) {
|
||||
this.#rangeInput.toggleAttribute("disabled", disabled);
|
||||
this.#numberInput.toggleAttribute("disabled", disabled);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a HTMLRangePickerElement using provided configuration data.
|
||||
* @param {FormInputConfig & RangePickerInputConfig} config
|
||||
* @returns {HTMLRangePickerElement}
|
||||
*/
|
||||
static create(config) {
|
||||
const picker = document.createElement(HTMLRangePickerElement.tagName);
|
||||
picker.name = config.name;
|
||||
for ( const attr of ["value", "min", "max", "step"] ) {
|
||||
if ( attr in config ) picker.setAttribute(attr, config[attr]);
|
||||
}
|
||||
foundry.applications.fields.setInputAttributes(picker, config);
|
||||
return picker;
|
||||
}
|
||||
}
|
||||
275
resources/app/client-esm/applications/elements/string-tags.mjs
Normal file
275
resources/app/client-esm/applications/elements/string-tags.mjs
Normal file
@@ -0,0 +1,275 @@
|
||||
import AbstractFormInputElement from "./form-element.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} StringTagsInputConfig
|
||||
* @property {boolean} slug Automatically slugify provided strings?
|
||||
*/
|
||||
|
||||
/**
|
||||
* A custom HTML element which allows for arbitrary assignment of a set of string tags.
|
||||
* This element may be used directly or subclassed to impose additional validation or functionality.
|
||||
* @extends {AbstractFormInputElement<Set<string>>}
|
||||
*/
|
||||
export default class HTMLStringTagsElement extends AbstractFormInputElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.#slug = this.hasAttribute("slug");
|
||||
this._value = new Set();
|
||||
this._initializeTags();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static tagName = "string-tags";
|
||||
|
||||
static icons = {
|
||||
add: "fa-solid fa-tag",
|
||||
remove: "fa-solid fa-times"
|
||||
}
|
||||
|
||||
static labels = {
|
||||
add: "ELEMENTS.TAGS.Add",
|
||||
remove: "ELEMENTS.TAGS.Remove",
|
||||
placeholder: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* The button element to add a new tag.
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#button;
|
||||
|
||||
/**
|
||||
* The input element to enter a new tag.
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#input;
|
||||
|
||||
/**
|
||||
* The tags list of assigned tags.
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#tags;
|
||||
|
||||
/**
|
||||
* Automatically slugify all strings provided to the element?
|
||||
* @type {boolean}
|
||||
*/
|
||||
#slug;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize innerText or an initial value attribute of the element as a comma-separated list of currently assigned
|
||||
* string tags.
|
||||
* @protected
|
||||
*/
|
||||
_initializeTags() {
|
||||
const initial = this.getAttribute("value") || this.innerText || "";
|
||||
const tags = initial ? initial.split(",") : [];
|
||||
for ( let tag of tags ) {
|
||||
tag = tag.trim();
|
||||
if ( tag ) {
|
||||
if ( this.#slug ) tag = tag.slugify({strict: true});
|
||||
try {
|
||||
this._validateTag(tag);
|
||||
} catch ( err ) {
|
||||
console.warn(err.message);
|
||||
continue;
|
||||
}
|
||||
this._value.add(tag);
|
||||
}
|
||||
}
|
||||
this.innerText = "";
|
||||
this.removeAttribute("value");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Subclasses may impose more strict validation on what tags are allowed.
|
||||
* @param {string} tag A candidate tag
|
||||
* @throws {Error} An error if the candidate tag is not allowed
|
||||
* @protected
|
||||
*/
|
||||
_validateTag(tag) {
|
||||
if ( !tag ) throw new Error(game.i18n.localize("ELEMENTS.TAGS.ErrorBlank"));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_buildElements() {
|
||||
|
||||
// Create tags list
|
||||
const tags = document.createElement("div");
|
||||
tags.className = "tags input-element-tags";
|
||||
this.#tags = tags;
|
||||
|
||||
// Create input element
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.placeholder = game.i18n.localize(this.constructor.labels.placeholder);
|
||||
this.#input = this._primaryInput = input;
|
||||
|
||||
// Create button
|
||||
const button = document.createElement("button");
|
||||
button.type = "button"
|
||||
button.className = `icon ${this.constructor.icons.add}`;
|
||||
button.dataset.tooltip = this.constructor.labels.add;
|
||||
button.ariaLabel = game.i18n.localize(this.constructor.labels.add);
|
||||
this.#button = button;
|
||||
return [this.#tags, this.#input, this.#button];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_refresh() {
|
||||
const tags = this.value.map(tag => this.constructor.renderTag(tag, tag, this.editable));
|
||||
this.#tags.replaceChildren(...tags);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render the tagged string as an HTML element.
|
||||
* @param {string} tag The raw tag value
|
||||
* @param {string} [label] An optional tag label
|
||||
* @param {boolean} [editable=true] Is the tag editable?
|
||||
* @returns {HTMLDivElement} A rendered HTML element for the tag
|
||||
*/
|
||||
static renderTag(tag, label, editable=true) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "tag";
|
||||
div.dataset.key = tag;
|
||||
const span = document.createElement("span");
|
||||
span.textContent = label ?? tag;
|
||||
div.append(span);
|
||||
if ( editable ) {
|
||||
const t = game.i18n.localize(this.labels.remove);
|
||||
const a = `<a class="remove ${this.icons.remove}" data-tooltip="${t}" aria-label="${t}"></a>`;
|
||||
div.insertAdjacentHTML("beforeend", a);
|
||||
}
|
||||
return div;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_activateListeners() {
|
||||
this.#button.addEventListener("click", this.#addTag.bind(this));
|
||||
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
|
||||
this.#input.addEventListener("keydown", this.#onKeydown.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a tag from the set when its removal button is clicked.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
#onClickTag(event) {
|
||||
if ( !event.target.classList.contains("remove") ) return;
|
||||
const tag = event.target.closest(".tag");
|
||||
this._value.delete(tag.dataset.key);
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a tag to the set when the ENTER key is pressed in the text input.
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onKeydown(event) {
|
||||
if ( event.key !== "Enter" ) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.#addTag();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a new tag to the set upon user input.
|
||||
*/
|
||||
#addTag() {
|
||||
let tag = this.#input.value.trim();
|
||||
if ( this.#slug ) tag = tag.slugify({strict: true});
|
||||
|
||||
// Validate the proposed code
|
||||
try {
|
||||
this._validateTag(tag);
|
||||
} catch(err) {
|
||||
ui.notifications.error(err.message);
|
||||
this.#input.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure uniqueness
|
||||
if ( this._value.has(tag) ) {
|
||||
const message = game.i18n.format("ELEMENTS.TAGS.ErrorNonUnique", {tag});
|
||||
ui.notifications.error(message);
|
||||
this.#input.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Add hex
|
||||
this._value.add(tag);
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
||||
this.#input.value = "";
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Form Handling */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getValue() {
|
||||
return Array.from(this._value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_setValue(value) {
|
||||
this._value.clear();
|
||||
const toAdd = [];
|
||||
for ( let v of value ) {
|
||||
if ( this.#slug ) v = v.slugify({strict: true});
|
||||
this._validateTag(v);
|
||||
toAdd.push(v);
|
||||
}
|
||||
for ( const v of toAdd ) this._value.add(v);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_toggleDisabled(disabled) {
|
||||
this.#input.toggleAttribute("disabled", disabled);
|
||||
this.#button.toggleAttribute("disabled", disabled);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a HTMLStringTagsElement using provided configuration data.
|
||||
* @param {FormInputConfig & StringTagsInputConfig} config
|
||||
*/
|
||||
static create(config) {
|
||||
const tags = document.createElement(this.tagName);
|
||||
tags.name = config.name;
|
||||
const value = Array.from(config.value || []).join(",");
|
||||
tags.toggleAttribute("slug", !!config.slug)
|
||||
tags.setAttribute("value", value);
|
||||
foundry.applications.fields.setInputAttributes(tags, config);
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
434
resources/app/client-esm/applications/forms/fields.mjs
Normal file
434
resources/app/client-esm/applications/forms/fields.mjs
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* @callback CustomFormGroup
|
||||
* @param {DataField} field
|
||||
* @param {FormGroupConfig} groupConfig
|
||||
* @param {FormInputConfig} inputConfig
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback CustomFormInput
|
||||
* @param {DataField} field
|
||||
* @param {FormInputConfig} config
|
||||
* @returns {HTMLElement|HTMLCollection}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FormGroupConfig
|
||||
* @property {string} label A text label to apply to the form group
|
||||
* @property {string} [units] An optional units string which is appended to the label
|
||||
* @property {HTMLElement|HTMLCollection} input An HTML element or collection of elements which provide the inputs
|
||||
* for the group
|
||||
* @property {string} [hint] Hint text displayed as part of the form group
|
||||
* @property {string} [rootId] Some parent CSS id within which field names are unique. If provided,
|
||||
* this root ID is used to automatically assign "id" attributes to input
|
||||
* elements and "for" attributes to corresponding labels
|
||||
* @property {string[]} [classes] An array of CSS classes applied to the form group element
|
||||
* @property {boolean} [stacked=false] Is the "stacked" class applied to the form group
|
||||
* @property {boolean} [localize=false] Should labels or other elements within this form group be
|
||||
* automatically localized?
|
||||
* @property {CustomFormGroup} [widget] A custom form group widget function which replaces the default
|
||||
* group HTML generation
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template FormInputValue
|
||||
* @typedef {Object} FormInputConfig
|
||||
* @property {string} name The name of the form element
|
||||
* @property {FormInputValue} [value] The current value of the form element
|
||||
* @property {boolean} [required=false] Is the field required?
|
||||
* @property {boolean} [disabled=false] Is the field disabled?
|
||||
* @property {boolean} [readonly=false] Is the field readonly?
|
||||
* @property {boolean} [autofocus=false] Is the field autofocused?
|
||||
* @property {boolean} [localize=false] Localize values of this field?
|
||||
* @property {Record<string,string>} [dataset] Additional dataset attributes to assign to the input
|
||||
* @property {string} [placeholder] A placeholder value, if supported by the element type
|
||||
* @property {CustomFormInput} [input]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a standardized form field group.
|
||||
* @param {FormGroupConfig} config
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function createFormGroup(config) {
|
||||
let {classes, hint, label, input, rootId, stacked, localize, units} = config;
|
||||
classes ||= [];
|
||||
if ( stacked ) classes.unshift("stacked");
|
||||
classes.unshift("form-group");
|
||||
|
||||
// Assign identifiers to each input
|
||||
input = input instanceof HTMLCollection ? input : [input];
|
||||
let labelFor;
|
||||
if ( rootId ) {
|
||||
for ( const [i, el] of input.entries() ) {
|
||||
const id = [rootId, el.name, input.length > 1 ? i : ""].filterJoin("-");
|
||||
labelFor ||= id;
|
||||
el.setAttribute("id", id);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the group element
|
||||
const group = document.createElement("div");
|
||||
group.className = classes.join(" ");
|
||||
|
||||
// Label element
|
||||
const lbl = document.createElement("label");
|
||||
lbl.innerText = localize ? game.i18n.localize(label) : label;
|
||||
if ( labelFor ) lbl.setAttribute("for", labelFor);
|
||||
if ( units ) lbl.insertAdjacentHTML("beforeend", ` <span class="units">(${game.i18n.localize(units)})</span>`);
|
||||
group.prepend(lbl);
|
||||
|
||||
// Form fields and inputs
|
||||
const fields = document.createElement("div");
|
||||
fields.className = "form-fields";
|
||||
fields.append(...input);
|
||||
group.append(fields);
|
||||
|
||||
// Hint element
|
||||
if ( hint ) {
|
||||
const h = document.createElement("p");
|
||||
h.className = "hint";
|
||||
h.innerText = localize ? game.i18n.localize(hint) : hint;
|
||||
group.append(h);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create an `<input type="checkbox">` element for a BooleanField.
|
||||
* @param {FormInputConfig<boolean>} config
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createCheckboxInput(config) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.name = config.name;
|
||||
if ( config.value ) input.setAttribute("checked", "");
|
||||
setInputAttributes(input, config);
|
||||
return input;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} EditorInputConfig
|
||||
* @property {string} [engine="prosemirror"]
|
||||
* @property {number} [height]
|
||||
* @property {boolean} [editable=true]
|
||||
* @property {boolean} [button=false]
|
||||
* @property {boolean} [collaborate=false]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a `<div class="editor">` element for a StringField.
|
||||
* @param {FormInputConfig<string> & EditorInputConfig} config
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function createEditorInput(config) {
|
||||
const {engine="prosemirror", editable=true, button=false, collaborate=false, height} = config;
|
||||
const editor = document.createElement("div");
|
||||
editor.className = "editor";
|
||||
if ( height !== undefined ) editor.style.height = `${height}px`;
|
||||
|
||||
// Dataset attributes
|
||||
let dataset = { engine, collaborate };
|
||||
if ( editable ) dataset.edit = config.name;
|
||||
dataset = Object.entries(dataset).map(([k, v]) => `data-${k}="${v}"`).join(" ");
|
||||
|
||||
// Editor HTML
|
||||
let editorHTML = "";
|
||||
if ( button && editable ) editorHTML += '<a class="editor-edit"><i class="fa-solid fa-edit"></i></a>';
|
||||
editorHTML += `<div class="editor-content" ${dataset}>${config.value ?? ""}</div>`;
|
||||
editor.innerHTML = editorHTML;
|
||||
return editor;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a `<multi-select>` element for a StringField.
|
||||
* @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
|
||||
* @returns {HTMLSelectElement}
|
||||
*/
|
||||
export function createMultiSelectInput(config) {
|
||||
const tagName = config.type === "checkboxes" ? "multi-checkbox" : "multi-select";
|
||||
const select = document.createElement(tagName);
|
||||
select.name = config.name;
|
||||
setInputAttributes(select, config);
|
||||
const groups = prepareSelectOptionGroups(config);
|
||||
for ( const g of groups ) {
|
||||
let parent = select;
|
||||
if ( g.group ) parent = _appendOptgroup(g.group, select);
|
||||
for ( const o of g.options ) _appendOption(o, parent);
|
||||
}
|
||||
return select;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} NumberInputConfig
|
||||
* @property {number} min
|
||||
* @property {number} max
|
||||
* @property {number|"any"} step
|
||||
* @property {"range"|"number"} [type]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create an `<input type="number">` element for a NumberField.
|
||||
* @param {FormInputConfig<number> & NumberInputConfig} config
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createNumberInput(config) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
if ( config.name ) input.name = config.name;
|
||||
|
||||
// Assign value
|
||||
let step = typeof config.step === "number" ? config.step : "any";
|
||||
let value = config.value;
|
||||
if ( Number.isNumeric(value) ) {
|
||||
if ( typeof config.step === "number" ) value = value.toNearest(config.step);
|
||||
input.setAttribute("value", String(value));
|
||||
}
|
||||
else input.setAttribute("value", "");
|
||||
|
||||
// Min, max, and step size
|
||||
if ( typeof config.min === "number" ) input.setAttribute("min", String(config.min));
|
||||
if ( typeof config.max === "number" ) input.setAttribute("max", String(config.max));
|
||||
input.setAttribute("step", String(step));
|
||||
setInputAttributes(input, config);
|
||||
return input;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} FormSelectOption
|
||||
* @property {string} [value]
|
||||
* @property {string} [label]
|
||||
* @property {string} [group]
|
||||
* @property {boolean} [disabled]
|
||||
* @property {boolean} [selected]
|
||||
* @property {boolean} [rule]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SelectInputConfig
|
||||
* @property {FormSelectOption[]} options
|
||||
* @property {string[]} [groups] An option to control the order and display of optgroup elements. The order of
|
||||
* strings defines the displayed order of optgroup elements.
|
||||
* A blank string may be used to define the position of ungrouped options.
|
||||
* If not defined, the order of groups corresponds to the order of options.
|
||||
* @property {string} [blank]
|
||||
* @property {string} [valueAttr] An alternative value key of the object passed to the options array
|
||||
* @property {string} [labelAttr] An alternative label key of the object passed to the options array
|
||||
* @property {boolean} [localize=false] Localize value labels
|
||||
* @property {boolean} [sort=false] Sort options alphabetically by label within groups
|
||||
* @property {"single"|"multi"|"checkboxes"} [type] Customize the type of select that is created
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a `<select>` element for a StringField.
|
||||
* @param {FormInputConfig<string> & SelectInputConfig} config
|
||||
* @returns {HTMLSelectElement}
|
||||
*/
|
||||
export function createSelectInput(config) {
|
||||
const select = document.createElement("select");
|
||||
select.name = config.name;
|
||||
setInputAttributes(select, config);
|
||||
const groups = prepareSelectOptionGroups(config);
|
||||
for ( const g of groups ) {
|
||||
let parent = select;
|
||||
if ( g.group ) parent = _appendOptgroup(g.group, select);
|
||||
for ( const o of g.options ) _appendOption(o, parent);
|
||||
}
|
||||
return select;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} TextAreaInputConfig
|
||||
* @property {number} rows
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a `<textarea>` element for a StringField.
|
||||
* @param {FormInputConfig<string> & TextAreaInputConfig} config
|
||||
* @returns {HTMLTextAreaElement}
|
||||
*/
|
||||
export function createTextareaInput(config) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.name = config.name;
|
||||
textarea.textContent = config.value ?? "";
|
||||
if ( config.rows ) textarea.setAttribute("rows", String(config.rows));
|
||||
setInputAttributes(textarea, config);
|
||||
return textarea;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create an `<input type="text">` element for a StringField.
|
||||
* @param {FormInputConfig<string>} config
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createTextInput(config) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = config.name;
|
||||
input.setAttribute("value", config.value ?? "");
|
||||
setInputAttributes(input, config);
|
||||
return input;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Helper Methods */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Structure a provided array of select options into a standardized format for rendering optgroup and option elements.
|
||||
* @param {FormInputConfig & SelectInputConfig} config
|
||||
* @returns {{group: string, options: FormSelectOption[]}[]}
|
||||
*
|
||||
* @example
|
||||
* const options = [
|
||||
* {value: "bar", label: "Bar", selected: true, group: "Good Options"},
|
||||
* {value: "foo", label: "Foo", disabled: true, group: "Bad Options"},
|
||||
* {value: "baz", label: "Baz", group: "Good Options"}
|
||||
* ];
|
||||
* const groups = ["Good Options", "Bad Options", "Unused Options"];
|
||||
* const optgroups = foundry.applications.fields.prepareSelectOptionGroups({options, groups, blank: true, sort: true});
|
||||
*/
|
||||
export function prepareSelectOptionGroups(config) {
|
||||
|
||||
// Coerce values to string array
|
||||
let values = [];
|
||||
if ( (config.value === undefined) || (config.value === null) ) values = [];
|
||||
else if ( typeof config.value === "object" ) {
|
||||
for ( const v of config.value ) values.push(String(v));
|
||||
}
|
||||
else values = [String(config.value)];
|
||||
const isSelected = value => values.includes(value);
|
||||
|
||||
// Organize options into groups
|
||||
let hasBlank = false;
|
||||
const groups = {};
|
||||
for ( const option of (config.options || []) ) {
|
||||
let {group, value, label, disabled, rule} = option;
|
||||
|
||||
// Value
|
||||
if ( config.valueAttr ) value = option[config.valueAttr];
|
||||
if ( value !== undefined ) {
|
||||
value = String(value);
|
||||
if ( value === "" ) hasBlank = true;
|
||||
}
|
||||
|
||||
// Label
|
||||
if ( config.labelAttr ) label = option[config.labelAttr];
|
||||
label ??= value;
|
||||
if ( label !== undefined ) {
|
||||
if ( typeof label !== "string" ) label = label.toString();
|
||||
if ( config.localize ) label = game.i18n.localize(label);
|
||||
}
|
||||
|
||||
const selected = option.selected || isSelected(value);
|
||||
disabled = !!disabled;
|
||||
|
||||
// Add to group
|
||||
group ||= "";
|
||||
groups[group] ||= [];
|
||||
groups[group].push({type: "option", value, label, selected, disabled, rule})
|
||||
}
|
||||
|
||||
// Add groups into an explicitly desired order
|
||||
const result = [];
|
||||
if ( config.groups instanceof Array ) {
|
||||
for ( let group of config.groups ) {
|
||||
const options = groups[group] ?? [];
|
||||
delete groups[group];
|
||||
if ( config.localize ) group = game.i18n.localize(group);
|
||||
result.push({group, options});
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining groups
|
||||
for ( let [groupName, options] of Object.entries(groups) ) {
|
||||
if ( groupName && config.localize ) groupName = game.i18n.localize(groupName);
|
||||
result.push({group: groupName, options});
|
||||
}
|
||||
|
||||
// Sort options
|
||||
if ( config.sort ) {
|
||||
for ( const group of result ) group.options.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
|
||||
}
|
||||
|
||||
// A blank option always comes first
|
||||
if ( (typeof config.blank === "string") && !hasBlank ) result.unshift({group: "", options: [{
|
||||
value: "",
|
||||
label: config.localize ? game.i18n.localize(config.blank) : config.blank,
|
||||
selected: isSelected("")
|
||||
}]});
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create and append an option element to a parent select or optgroup.
|
||||
* @param {FormSelectOption} option
|
||||
* @param {HTMLSelectElement|HTMLOptGroupElement} parent
|
||||
* @internal
|
||||
*/
|
||||
function _appendOption(option, parent) {
|
||||
const { value, label, selected, disabled, rule } = option;
|
||||
if ( (value !== undefined) && (label !== undefined) ) {
|
||||
const o = document.createElement("option");
|
||||
o.value = value;
|
||||
o.innerText = label;
|
||||
if ( selected ) o.toggleAttribute("selected", true);
|
||||
if ( disabled ) o.toggleAttribute("disabled", true);
|
||||
parent.appendChild(o);
|
||||
}
|
||||
if ( rule ) parent.insertAdjacentHTML("beforeend", "<hr>");
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create and append an optgroup element to a parent select.
|
||||
* @param {string} label
|
||||
* @param {HTMLSelectElement} parent
|
||||
* @returns {HTMLOptGroupElement}
|
||||
* @internal
|
||||
*/
|
||||
function _appendOptgroup(label, parent) {
|
||||
const g = document.createElement("optgroup");
|
||||
g.label = label;
|
||||
parent.appendChild(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply standard attributes to all input elements.
|
||||
* @param {HTMLElement} input The element being configured
|
||||
* @param {FormInputConfig<*>} config Configuration for the element
|
||||
*/
|
||||
export function setInputAttributes(input, config) {
|
||||
input.toggleAttribute("required", config.required === true);
|
||||
input.toggleAttribute("disabled", config.disabled === true);
|
||||
input.toggleAttribute("readonly", config.readonly === true);
|
||||
input.toggleAttribute("autofocus", config.autofocus === true);
|
||||
if ( config.placeholder ) input.setAttribute("placeholder", config.placeholder);
|
||||
if ( "dataset" in config ) {
|
||||
for ( const [k, v] of Object.entries(config.dataset) ) {
|
||||
input.dataset[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
resources/app/client-esm/applications/sheets/_module.mjs
Normal file
7
resources/app/client-esm/applications/sheets/_module.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
export {default as ActorSheetV2} from "./actor-sheet.mjs";
|
||||
export {default as AmbientSoundConfig} from "./ambient-sound-config.mjs";
|
||||
export {default as AmbientLightConfig} from "./ambient-light-config.mjs";
|
||||
export {default as ItemSheetV2} from "./item-sheet.mjs";
|
||||
export {default as RegionBehaviorConfig} from "./region-behavior-config.mjs";
|
||||
export {default as RegionConfig} from "./region-config.mjs";
|
||||
export {default as UserConfig} from "./user-config.mjs";
|
||||
130
resources/app/client-esm/applications/sheets/actor-sheet.mjs
Normal file
130
resources/app/client-esm/applications/sheets/actor-sheet.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
import DocumentSheetV2 from "../api/document-sheet.mjs";
|
||||
|
||||
/**
|
||||
* A base class for providing Actor Sheet behavior using ApplicationV2.
|
||||
*/
|
||||
export default class ActorSheetV2 extends DocumentSheetV2 {
|
||||
|
||||
/** @inheritDoc */
|
||||
static DEFAULT_OPTIONS = {
|
||||
position: {
|
||||
width: 600
|
||||
},
|
||||
window: {
|
||||
controls: [
|
||||
{
|
||||
action: "configurePrototypeToken",
|
||||
icon: "fa-solid fa-user-circle",
|
||||
label: "TOKEN.TitlePrototype",
|
||||
ownership: "OWNER"
|
||||
},
|
||||
{
|
||||
action: "showPortraitArtwork",
|
||||
icon: "fa-solid fa-image",
|
||||
label: "SIDEBAR.CharArt",
|
||||
ownership: "OWNER"
|
||||
},
|
||||
{
|
||||
action: "showTokenArtwork",
|
||||
icon: "fa-solid fa-image",
|
||||
label: "SIDEBAR.TokenArt",
|
||||
ownership: "OWNER"
|
||||
}
|
||||
]
|
||||
},
|
||||
actions: {
|
||||
configurePrototypeToken: ActorSheetV2.#onConfigurePrototypeToken,
|
||||
showPortraitArtwork: ActorSheetV2.#onShowPortraitArtwork,
|
||||
showTokenArtwork: ActorSheetV2.#onShowTokenArtwork,
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The Actor document managed by this sheet.
|
||||
* @type {ClientDocument}
|
||||
*/
|
||||
get actor() {
|
||||
return this.document;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* If this sheet manages the ActorDelta of an unlinked Token, reference that Token document.
|
||||
* @type {TokenDocument|null}
|
||||
*/
|
||||
get token() {
|
||||
return this.document.token || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getHeaderControls() {
|
||||
const controls = this.options.window.controls;
|
||||
|
||||
// Portrait image
|
||||
const img = this.actor.img;
|
||||
if ( img === CONST.DEFAULT_TOKEN ) controls.findSplice(c => c.action === "showPortraitArtwork");
|
||||
|
||||
// Token image
|
||||
const pt = this.actor.prototypeToken;
|
||||
const tex = pt.texture.src;
|
||||
if ( pt.randomImg || [null, undefined, CONST.DEFAULT_TOKEN].includes(tex) ) {
|
||||
controls.findSplice(c => c.action === "showTokenArtwork");
|
||||
}
|
||||
return controls;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
async _renderHTML(context, options) {
|
||||
return `<p>TESTING</p>`;
|
||||
}
|
||||
|
||||
_replaceHTML(result, content, options) {
|
||||
content.insertAdjacentHTML("beforeend", result);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle header control button clicks to render the Prototype Token configuration sheet.
|
||||
* @this {ActorSheetV2}
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
static #onConfigurePrototypeToken(event) {
|
||||
event.preventDefault();
|
||||
const renderOptions = {
|
||||
left: Math.max(this.position.left - 560 - 10, 10),
|
||||
top: this.position.top
|
||||
};
|
||||
new CONFIG.Token.prototypeSheetClass(this.actor.prototypeToken, renderOptions).render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle header control button clicks to display actor portrait artwork.
|
||||
* @this {ActorSheetV2}
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
static #onShowPortraitArtwork(event) {
|
||||
const {img, name, uuid} = this.actor;
|
||||
new ImagePopout(img, {title: name, uuid: uuid}).render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle header control button clicks to display actor portrait artwork.
|
||||
* @this {ActorSheetV2}
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
static #onShowTokenArtwork(event) {
|
||||
const {prototypeToken, name, uuid} = this.actor;
|
||||
new ImagePopout(prototypeToken.texture.src, {title: name, uuid: uuid}).render(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import DocumentSheetV2 from "../api/document-sheet.mjs";
|
||||
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
|
||||
|
||||
/**
|
||||
* The AmbientLight configuration application.
|
||||
* @extends DocumentSheetV2
|
||||
* @mixes HandlebarsApplication
|
||||
* @alias AmbientLightConfig
|
||||
*/
|
||||
export default class AmbientLightConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
|
||||
|
||||
/** @inheritDoc */
|
||||
static DEFAULT_OPTIONS = {
|
||||
classes: ["ambient-light-config"],
|
||||
window: {
|
||||
contentClasses: ["standard-form"]
|
||||
},
|
||||
position: {
|
||||
width: 560,
|
||||
height: "auto"
|
||||
},
|
||||
form: {
|
||||
handler: this.#onSubmit,
|
||||
closeOnSubmit: true
|
||||
},
|
||||
actions:{
|
||||
reset: this.#onReset
|
||||
}
|
||||
};
|
||||
|
||||
/** @override */
|
||||
static PARTS = {
|
||||
tabs: {
|
||||
template: "templates/generic/tab-navigation.hbs"
|
||||
},
|
||||
basic: {
|
||||
template: "templates/scene/parts/light-basic.hbs"
|
||||
},
|
||||
animation: {
|
||||
template: "templates/scene/parts/light-animation.hbs"
|
||||
},
|
||||
advanced: {
|
||||
template: "templates/scene/parts/light-advanced.hbs"
|
||||
},
|
||||
footer: {
|
||||
template: "templates/generic/form-footer.hbs"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintain a copy of the original to show a real-time preview of changes.
|
||||
* @type {AmbientLightDocument}
|
||||
*/
|
||||
preview;
|
||||
|
||||
/** @override */
|
||||
tabGroups = {
|
||||
sheet: "basic"
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Application Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preRender(context, options) {
|
||||
await super._preRender(context, options);
|
||||
if ( this.preview?.rendered ) {
|
||||
await this.preview.object.draw();
|
||||
this.document.object.initializeLightSource({deleted: true});
|
||||
this.preview.object.layer.preview.addChild(this.preview.object);
|
||||
this._previewChanges();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onRender(context, options) {
|
||||
super._onRender(context, options);
|
||||
this.#toggleReset();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onClose(options) {
|
||||
super._onClose(options);
|
||||
if ( this.preview ) this._resetPreview();
|
||||
if ( this.document.rendered ) this.document.object.initializeLightSource();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _prepareContext(options) {
|
||||
|
||||
// Create the preview on first render
|
||||
if ( options.isFirstRender && this.document.object ) {
|
||||
const clone = this.document.object.clone();
|
||||
this.preview = clone.document;
|
||||
}
|
||||
|
||||
// Prepare context
|
||||
const document = this.preview ?? this.document;
|
||||
const isDarkness = document.config.negative;
|
||||
return {
|
||||
light: document,
|
||||
source: document.toObject(),
|
||||
fields: document.schema.fields,
|
||||
colorationTechniques: AdaptiveLightingShader.SHADER_TECHNIQUES,
|
||||
gridUnits: document.parent.grid.units || game.i18n.localize("GridUnits"),
|
||||
isDarkness,
|
||||
lightAnimations: isDarkness ? CONFIG.Canvas.darknessAnimations : CONFIG.Canvas.lightAnimations,
|
||||
tabs: this.#getTabs(),
|
||||
buttons: [
|
||||
{
|
||||
type: "reset",
|
||||
action: "reset",
|
||||
icon: "fa-solid fa-undo",
|
||||
label: "AMBIENT_LIGHT.ACTIONS.RESET"
|
||||
},
|
||||
{
|
||||
type: "submit",
|
||||
icon: "fa-solid fa-save",
|
||||
label: this.document.id ? "AMBIENT_LIGHT.ACTIONS.UPDATE" : "AMBIENT_LIGHT.ACTIONS.CREATE"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare an array of form header tabs.
|
||||
* @returns {Record<string, Partial<ApplicationTab>>}
|
||||
*/
|
||||
#getTabs() {
|
||||
const tabs = {
|
||||
basic: {id: "basic", group: "sheet", icon: "fa-solid fa-lightbulb", label: "AMBIENT_LIGHT.SECTIONS.BASIC"},
|
||||
animation: {id: "animation", group: "sheet", icon: "fa-solid fa-play", label: "AMBIENT_LIGHT.SECTIONS.ANIMATION"},
|
||||
advanced: {id: "advanced", group: "sheet", icon: "fa-solid fa-cogs", label: "AMBIENT_LIGHT.SECTIONS.ADVANCED"}
|
||||
}
|
||||
for ( const v of Object.values(tabs) ) {
|
||||
v.active = this.tabGroups[v.group] === v.id;
|
||||
v.cssClass = v.active ? "active" : "";
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle visibility of the reset button which is only visible on the advanced tab.
|
||||
*/
|
||||
#toggleReset() {
|
||||
const reset = this.element.querySelector("button[data-action=reset]");
|
||||
reset.classList.toggle("hidden", this.tabGroups.sheet !== "advanced");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
changeTab(...args) {
|
||||
super.changeTab(...args);
|
||||
this.#toggleReset();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Real-Time Preview */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onChangeForm(formConfig, event) {
|
||||
super._onChangeForm(formConfig, event);
|
||||
const formData = new FormDataExtended(this.element);
|
||||
this._previewChanges(formData.object);
|
||||
|
||||
// Special handling for darkness state change
|
||||
if ( event.target.name === "config.negative") this.render({parts: ["animation", "advanced"]});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Preview changes to the AmbientLight document as if they were true document updates.
|
||||
* @param {object} [change] A change to preview.
|
||||
* @protected
|
||||
*/
|
||||
_previewChanges(change) {
|
||||
if ( !this.preview ) return;
|
||||
if ( change ) this.preview.updateSource(change);
|
||||
if ( this.preview?.rendered ) {
|
||||
this.preview.object.renderFlags.set({refresh: true});
|
||||
this.preview.object.initializeLightSource();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Restore the true data for the AmbientLight document when the form is submitted or closed.
|
||||
* @protected
|
||||
*/
|
||||
_resetPreview() {
|
||||
if ( !this.preview ) return;
|
||||
if ( this.preview.rendered ) {
|
||||
this.preview.object.destroy({children: true});
|
||||
}
|
||||
this.preview = null;
|
||||
if ( this.document.rendered ) {
|
||||
const object = this.document.object;
|
||||
object.renderable = true;
|
||||
object.initializeLightSource();
|
||||
object.renderFlags.set({refresh: true});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Process form submission for the sheet.
|
||||
* @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
|
||||
* @this {AmbientLightConfig}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async #onSubmit(event, form, formData) {
|
||||
const submitData = this._prepareSubmitData(event, form, formData);
|
||||
if ( this.document.id ) await this.document.update(submitData);
|
||||
else await this.document.constructor.create(submitData, {parent: canvas.scene});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Process reset button click
|
||||
* @param {PointerEvent} event The originating button click
|
||||
* @this {AmbientLightConfig}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async #onReset(event) {
|
||||
event.preventDefault();
|
||||
const defaults = AmbientLightDocument.cleanData();
|
||||
const keys = ["vision", "config"];
|
||||
const configKeys = ["coloration", "contrast", "attenuation", "luminosity", "saturation", "shadows"];
|
||||
for ( const k in defaults ) {
|
||||
if ( !keys.includes(k) ) delete defaults[k];
|
||||
}
|
||||
for ( const k in defaults.config ) {
|
||||
if ( !configKeys.includes(k) ) delete defaults.config[k];
|
||||
}
|
||||
this._previewChanges(defaults);
|
||||
await this.render();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import DocumentSheetV2 from "../api/document-sheet.mjs";
|
||||
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
|
||||
|
||||
/**
|
||||
* The AmbientSound configuration application.
|
||||
* @extends DocumentSheetV2
|
||||
* @mixes HandlebarsApplication
|
||||
* @alias AmbientSoundConfig
|
||||
*/
|
||||
export default class AmbientSoundConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
|
||||
|
||||
/** @inheritDoc */
|
||||
static DEFAULT_OPTIONS = {
|
||||
classes: ["ambient-sound-config"],
|
||||
window: {
|
||||
contentClasses: ["standard-form"]
|
||||
},
|
||||
position: {
|
||||
width: 560,
|
||||
height: "auto"
|
||||
},
|
||||
form: {
|
||||
handler: this.#onSubmit,
|
||||
closeOnSubmit: true
|
||||
}
|
||||
};
|
||||
|
||||
/** @override */
|
||||
static PARTS = {
|
||||
body: {
|
||||
template: "templates/scene/ambient-sound-config.hbs"
|
||||
},
|
||||
footer: {
|
||||
template: "templates/generic/form-footer.hbs"
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
get title() {
|
||||
if ( !this.document.id ) return game.i18n.localize("AMBIENT_SOUND.ACTIONS.CREATE");
|
||||
return super.title;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _prepareContext(_options) {
|
||||
return {
|
||||
sound: this.document,
|
||||
source: this.document.toObject(),
|
||||
fields: this.document.schema.fields,
|
||||
gridUnits: this.document.parent.grid.units || game.i18n.localize("GridUnits"),
|
||||
soundEffects: CONFIG.soundEffects,
|
||||
buttons: [{
|
||||
type: "submit",
|
||||
icon: "fa-solid fa-save",
|
||||
label: game.i18n.localize(this.document.id ? "AMBIENT_SOUND.ACTIONS.UPDATE" : "AMBIENT_SOUND.ACTIONS.CREATE")
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onRender(context, options) {
|
||||
this.#toggleDisabledFields();
|
||||
return super._onRender(context, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onClose(_options) {
|
||||
if ( !this.document.id ) canvas.sounds.clearPreviewContainer();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onChangeForm(formConfig, event) {
|
||||
this.#toggleDisabledFields();
|
||||
return super._onChangeForm(formConfig, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Special logic to toggle the disabled state of form fields depending on the values of other fields.
|
||||
*/
|
||||
#toggleDisabledFields() {
|
||||
const form = this.element;
|
||||
form["effects.base.intensity"].disabled = !form["effects.base.type"].value;
|
||||
form["effects.muffled.type"].disabled = form.walls.checked;
|
||||
form["effects.muffled.intensity"].disabled = form.walls.checked || !form["effects.muffled.type"].value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Process form submission for the sheet.
|
||||
* @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
|
||||
* @this {AmbientSoundConfig}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async #onSubmit(event, form, formData) {
|
||||
const submitData = this._prepareSubmitData(event, form, formData);
|
||||
if ( this.document.id ) await this.document.update(submitData);
|
||||
else await this.document.constructor.create(submitData, {parent: canvas.scene});
|
||||
}
|
||||
}
|
||||
30
resources/app/client-esm/applications/sheets/item-sheet.mjs
Normal file
30
resources/app/client-esm/applications/sheets/item-sheet.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import DocumentSheetV2 from "../api/document-sheet.mjs";
|
||||
|
||||
/**
|
||||
* A base class for providing Item Sheet behavior using ApplicationV2.
|
||||
*/
|
||||
export default class ItemSheetV2 extends DocumentSheetV2 {
|
||||
|
||||
/** @inheritDoc */
|
||||
static DEFAULT_OPTIONS = {
|
||||
position: {
|
||||
width: 480
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The Item document managed by this sheet.
|
||||
* @type {ClientDocument}
|
||||
*/
|
||||
get item() {
|
||||
return this.document;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Actor instance which owns this Item, if any.
|
||||
* @type {Actor|null}
|
||||
*/
|
||||
get actor() {
|
||||
return this.document.actor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import DocumentSheetV2 from "../api/document-sheet.mjs";
|
||||
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("../_types.mjs").FormNode} FormNode
|
||||
* @typedef {import("../_types.mjs").FormFooterButton} FormFooterButton
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Scene Region configuration application.
|
||||
* @extends DocumentSheetV2
|
||||
* @mixes HandlebarsApplication
|
||||
* @alias RegionBehaviorConfig
|
||||
*/
|
||||
export default class RegionBehaviorConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.options.window.icon = CONFIG.RegionBehavior.typeIcons[this.document.type];
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
static DEFAULT_OPTIONS = {
|
||||
classes: ["region-behavior-config"],
|
||||
window: {
|
||||
contentClasses: ["standard-form"],
|
||||
icon: undefined // Defined in constructor
|
||||
},
|
||||
position: {
|
||||
width: 480,
|
||||
height: "auto"
|
||||
},
|
||||
form: {
|
||||
closeOnSubmit: true
|
||||
}
|
||||
};
|
||||
|
||||
/** @override */
|
||||
static PARTS = {
|
||||
form: {
|
||||
template: "templates/generic/form-fields.hbs"
|
||||
},
|
||||
footer: {
|
||||
template: "templates/generic/form-footer.hbs"
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Context Preparation */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _prepareContext(_options) {
|
||||
const doc = this.document;
|
||||
return {
|
||||
region: doc,
|
||||
source: doc._source,
|
||||
fields: this._getFields(),
|
||||
buttons: this._getButtons()
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare form field structure for rendering.
|
||||
* @returns {FormNode[]}
|
||||
*/
|
||||
_getFields() {
|
||||
const doc = this.document;
|
||||
const source = doc._source;
|
||||
const fields = doc.schema.fields;
|
||||
const {events, ...systemFields} = CONFIG.RegionBehavior.dataModels[doc.type]?.schema.fields;
|
||||
const fieldsets = [];
|
||||
|
||||
// Identity
|
||||
fieldsets.push({
|
||||
fieldset: true,
|
||||
legend: "BEHAVIOR.SECTIONS.identity",
|
||||
fields: [
|
||||
{field: fields.name, value: source.name}
|
||||
]
|
||||
});
|
||||
|
||||
// Status
|
||||
fieldsets.push({
|
||||
fieldset: true,
|
||||
legend: "BEHAVIOR.SECTIONS.status",
|
||||
fields: [
|
||||
{field: fields.disabled, value: source.disabled}
|
||||
]
|
||||
});
|
||||
|
||||
// Subscribed events
|
||||
if ( events ) {
|
||||
fieldsets.push({
|
||||
fieldset: true,
|
||||
legend: "BEHAVIOR.TYPES.base.SECTIONS.events",
|
||||
fields: [
|
||||
{field: events, value: source.system.events}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Other system fields
|
||||
const sf = {fieldset: true, legend: CONFIG.RegionBehavior.typeLabels[doc.type], fields: []};
|
||||
this.#addSystemFields(sf, systemFields, source);
|
||||
if ( sf.fields.length ) fieldsets.push(sf);
|
||||
return fieldsets;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Recursively add system model fields to the fieldset.
|
||||
*/
|
||||
#addSystemFields(fieldset, schema, source, _path="system") {
|
||||
for ( const field of Object.values(schema) ) {
|
||||
const path = `${_path}.${field.name}`;
|
||||
if ( field instanceof foundry.data.fields.SchemaField ) {
|
||||
this.#addSystemFields(fieldset, field.fields, source, path);
|
||||
}
|
||||
else if ( field.constructor.hasFormSupport ) {
|
||||
fieldset.fields.push({field, value: foundry.utils.getProperty(source, path)});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get footer buttons for this behavior config sheet.
|
||||
* @returns {FormFooterButton[]}
|
||||
* @protected
|
||||
*/
|
||||
_getButtons() {
|
||||
return [
|
||||
{type: "submit", icon: "fa-solid fa-save", label: "BEHAVIOR.ACTIONS.update"}
|
||||
]
|
||||
}
|
||||
}
|
||||
383
resources/app/client-esm/applications/sheets/region-config.mjs
Normal file
383
resources/app/client-esm/applications/sheets/region-config.mjs
Normal file
@@ -0,0 +1,383 @@
|
||||
import DocumentSheetV2 from "../api/document-sheet.mjs";
|
||||
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
|
||||
import {DOCUMENT_OWNERSHIP_LEVELS} from "../../../common/constants.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("../_types.mjs").ApplicationTab} ApplicationTab
|
||||
* @typedef {import("../_types.mjs").FormFooterButton} FormFooterButton
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Scene Region configuration application.
|
||||
* @extends DocumentSheetV2
|
||||
* @mixes HandlebarsApplication
|
||||
* @alias RegionConfig
|
||||
*/
|
||||
export default class RegionConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
|
||||
|
||||
/** @inheritDoc */
|
||||
static DEFAULT_OPTIONS = {
|
||||
classes: ["region-config"],
|
||||
window: {
|
||||
contentClasses: ["standard-form"],
|
||||
icon: "fa-regular fa-game-board"
|
||||
},
|
||||
position: {
|
||||
width: 480,
|
||||
height: "auto"
|
||||
},
|
||||
form: {
|
||||
closeOnSubmit: true
|
||||
},
|
||||
viewPermission: DOCUMENT_OWNERSHIP_LEVELS.OWNER,
|
||||
actions: {
|
||||
shapeCreateFromWalls: RegionConfig.#onShapeCreateFromWalls,
|
||||
shapeToggleHole: RegionConfig.#onShapeToggleHole,
|
||||
shapeMoveUp: RegionConfig.#onShapeMoveUp,
|
||||
shapeMoveDown: RegionConfig.#onShapeMoveDown,
|
||||
shapeRemove: RegionConfig.#onShapeRemove,
|
||||
behaviorCreate: RegionConfig.#onBehaviorCreate,
|
||||
behaviorDelete: RegionConfig.#onBehaviorDelete,
|
||||
behaviorEdit: RegionConfig.#onBehaviorEdit,
|
||||
behaviorToggle: RegionConfig.#onBehaviorToggle
|
||||
}
|
||||
};
|
||||
|
||||
/** @override */
|
||||
static PARTS = {
|
||||
tabs: {
|
||||
template: "templates/generic/tab-navigation.hbs"
|
||||
},
|
||||
identity: {
|
||||
template: "templates/scene/parts/region-identity.hbs"
|
||||
},
|
||||
shapes: {
|
||||
template: "templates/scene/parts/region-shapes.hbs",
|
||||
scrollable: [".scrollable"]
|
||||
},
|
||||
behaviors: {
|
||||
template: "templates/scene/parts/region-behaviors.hbs",
|
||||
scrollable: [".scrollable"]
|
||||
},
|
||||
footer: {
|
||||
template: "templates/generic/form-footer.hbs"
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
tabGroups = {
|
||||
sheet: "identity"
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Context Preparation */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _prepareContext(_options) {
|
||||
const doc = this.document;
|
||||
return {
|
||||
region: doc,
|
||||
source: doc.toObject(),
|
||||
fields: doc.schema.fields,
|
||||
tabs: this.#getTabs(),
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _preparePartContext(partId, context) {
|
||||
const doc = this.document;
|
||||
switch ( partId ) {
|
||||
case "footer":
|
||||
context.buttons = this.#getFooterButtons();
|
||||
break;
|
||||
case "behaviors":
|
||||
context.tab = context.tabs.behaviors;
|
||||
context.behaviors = doc.behaviors.map(b => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
typeLabel: game.i18n.localize(CONFIG.RegionBehavior.typeLabels[b.type]),
|
||||
typeIcon: CONFIG.RegionBehavior.typeIcons[b.type] || "fa-regular fa-notdef",
|
||||
disabled: b.disabled
|
||||
})).sort((a, b) => (a.disabled - b.disabled) || a.name.localeCompare(b.name, game.i18n.lang));
|
||||
break;
|
||||
case "identity":
|
||||
context.tab = context.tabs.identity;
|
||||
break;
|
||||
case "shapes":
|
||||
context.tab = context.tabs.shapes;
|
||||
break;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onRender(context, options) {
|
||||
super._onRender(context, options);
|
||||
this.element.querySelectorAll(".region-shape").forEach(e => {
|
||||
e.addEventListener("mouseover", this.#onShapeHoverIn.bind(this));
|
||||
e.addEventListener("mouseout", this.#onShapeHoverOut.bind(this));
|
||||
});
|
||||
this.document.object?.renderFlags.set({refreshState: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onClose(options) {
|
||||
super._onClose(options);
|
||||
this.document.object?.renderFlags.set({refreshState: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare an array of form header tabs.
|
||||
* @returns {Record<string, Partial<ApplicationTab>>}
|
||||
*/
|
||||
#getTabs() {
|
||||
const tabs = {
|
||||
identity: {id: "identity", group: "sheet", icon: "fa-solid fa-tag", label: "REGION.SECTIONS.identity"},
|
||||
shapes: {id: "shapes", group: "sheet", icon: "fa-solid fa-shapes", label: "REGION.SECTIONS.shapes"},
|
||||
behaviors: {id: "behaviors", group: "sheet", icon: "fa-solid fa-child-reaching", label: "REGION.SECTIONS.behaviors"}
|
||||
}
|
||||
for ( const v of Object.values(tabs) ) {
|
||||
v.active = this.tabGroups[v.group] === v.id;
|
||||
v.cssClass = v.active ? "active" : "";
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare an array of form footer buttons.
|
||||
* @returns {Partial<FormFooterButton>[]}
|
||||
*/
|
||||
#getFooterButtons() {
|
||||
return [
|
||||
{type: "submit", icon: "fa-solid fa-save", label: "REGION.ACTIONS.update"}
|
||||
]
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-hover events on a shape.
|
||||
*/
|
||||
#onShapeHoverIn(event) {
|
||||
event.preventDefault();
|
||||
if ( !this.document.parent.isView ) return;
|
||||
const index = this.#getControlShapeIndex(event);
|
||||
canvas.regions._highlightShape(this.document.shapes[index]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-unhover events for shape.
|
||||
*/
|
||||
#onShapeHoverOut(event) {
|
||||
event.preventDefault();
|
||||
if ( !this.document.parent.isView ) return;
|
||||
canvas.regions._highlightShape(null);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button clicks to move the shape up.
|
||||
* @param {PointerEvent} event
|
||||
* @this {RegionConfig}
|
||||
*/
|
||||
static async #onShapeMoveUp(event) {
|
||||
if ( this.document.shapes.length <= 1 ) return;
|
||||
const index = this.#getControlShapeIndex(event);
|
||||
if ( index === 0 ) return;
|
||||
const shapes = [...this.document.shapes];
|
||||
[shapes[index - 1], shapes[index]] = [shapes[index], shapes[index - 1]];
|
||||
await this.document.update({shapes});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button clicks to move the shape down.
|
||||
* @param {PointerEvent} event
|
||||
* @this {RegionConfig}
|
||||
*/
|
||||
static async #onShapeMoveDown(event) {
|
||||
if ( this.document.shapes.length <= 1 ) return;
|
||||
const index = this.#getControlShapeIndex(event);
|
||||
if ( index === this.document.shapes.length - 1 ) return;
|
||||
const shapes = [...this.document.shapes];
|
||||
[shapes[index], shapes[index + 1]] = [shapes[index + 1], shapes[index]];
|
||||
await this.document.update({shapes});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button clicks to create shapes from the controlled walls.
|
||||
* @param {PointerEvent} event
|
||||
* @this {RegionConfig}
|
||||
*/
|
||||
static async #onShapeCreateFromWalls(event) {
|
||||
event.preventDefault(); // Don't open context menu
|
||||
event.stopPropagation(); // Don't trigger other events
|
||||
if ( !canvas.ready || (event.detail > 1) ) return; // Ignore repeated clicks
|
||||
|
||||
// If no walls are controlled, inform the user they need to control walls
|
||||
if ( !canvas.walls.controlled.length ) {
|
||||
if ( canvas.walls.active ) {
|
||||
ui.notifications.error("REGION.NOTIFICATIONS.NoControlledWalls", {localize: true});
|
||||
}
|
||||
else {
|
||||
canvas.walls.activate({tool: "select"});
|
||||
ui.notifications.info("REGION.NOTIFICATIONS.ControlWalls", {localize: true});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the shape
|
||||
const polygons = canvas.walls.identifyInteriorArea(canvas.walls.controlled);
|
||||
if ( polygons.length === 0 ) {
|
||||
ui.notifications.error("REGION.NOTIFICATIONS.EmptyEnclosedArea", {localize: true});
|
||||
return;
|
||||
}
|
||||
const shapes = polygons.map(p => new foundry.data.PolygonShapeData({points: p.points}));
|
||||
|
||||
// Merge the new shape with form submission data
|
||||
const form = this.element;
|
||||
const formData = new FormDataExtended(form);
|
||||
const submitData = this._prepareSubmitData(event, form, formData);
|
||||
submitData.shapes = [...this.document._source.shapes, ...shapes];
|
||||
|
||||
// Update the region
|
||||
await this.document.update(submitData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button clicks to toggle the hold field of a shape.
|
||||
* @param {PointerEvent} event
|
||||
* @this {RegionConfig}
|
||||
*/
|
||||
static async #onShapeToggleHole(event) {
|
||||
const index = this.#getControlShapeIndex(event);
|
||||
const shapes = this.document.shapes.map(s => s.toObject());
|
||||
shapes[index].hole = !shapes[index].hole;
|
||||
await this.document.update({shapes});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button clicks to remove a shape.
|
||||
* @param {PointerEvent} event
|
||||
* @this {RegionConfig}
|
||||
*/
|
||||
static async #onShapeRemove(event) {
|
||||
const index = this.#getControlShapeIndex(event);
|
||||
let shapes = this.document.shapes;
|
||||
return foundry.applications.api.DialogV2.confirm({
|
||||
window: {
|
||||
title: game.i18n.localize("REGION.ACTIONS.shapeRemove")
|
||||
},
|
||||
content: `<p>${game.i18n.localize("AreYouSure")}</p>`,
|
||||
rejectClose: false,
|
||||
yes: {
|
||||
callback: () => {
|
||||
// Test that there haven't been any changes to the shapes since the dialog the button was clicked
|
||||
if ( this.document.shapes !== shapes ) return false;
|
||||
shapes = [...shapes];
|
||||
shapes.splice(index, 1);
|
||||
this.document.update({shapes});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the shape index from a control button click.
|
||||
* @param {PointerEvent} event The button-click event
|
||||
* @returns {number} The shape index
|
||||
*/
|
||||
#getControlShapeIndex(event) {
|
||||
const button = event.target;
|
||||
const li = button.closest(".region-shape");
|
||||
return Number(li.dataset.shapeIndex);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
|
||||
/**
|
||||
* Handle button clicks to create a new behavior.
|
||||
* @this {RegionConfig}
|
||||
*/
|
||||
static async #onBehaviorCreate(_event) {
|
||||
await RegionBehavior.implementation.createDialog({}, {parent: this.document});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button clicks to delete a behavior.
|
||||
* @param {PointerEvent} event
|
||||
* @this {RegionConfig}
|
||||
*/
|
||||
static async #onBehaviorDelete(event) {
|
||||
const behavior = this.#getControlBehavior(event);
|
||||
await behavior.deleteDialog();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button clicks to edit a behavior.
|
||||
* @param {PointerEvent} event
|
||||
* @this {RegionConfig}
|
||||
*/
|
||||
static async #onBehaviorEdit(event) {
|
||||
const target = event.target;
|
||||
if ( target.closest(".region-element-name") && (event.detail !== 2) ) return; // Double-click on name
|
||||
const behavior = this.#getControlBehavior(event);
|
||||
await behavior.sheet.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button clicks to toggle a behavior.
|
||||
* @param {PointerEvent} event
|
||||
* @this {RegionConfig}
|
||||
*/
|
||||
static async #onBehaviorToggle(event) {
|
||||
const behavior = this.#getControlBehavior(event);
|
||||
await behavior.update({disabled: !behavior.disabled});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the RegionBehavior document from a control button click.
|
||||
* @param {PointerEvent} event The button-click event
|
||||
* @returns {RegionBehavior} The region behavior document
|
||||
*/
|
||||
#getControlBehavior(event) {
|
||||
const button = event.target;
|
||||
const li = button.closest(".region-behavior");
|
||||
return this.document.behaviors.get(li.dataset.behaviorId);
|
||||
}
|
||||
}
|
||||
118
resources/app/client-esm/applications/sheets/user-config.mjs
Normal file
118
resources/app/client-esm/applications/sheets/user-config.mjs
Normal file
@@ -0,0 +1,118 @@
|
||||
import DocumentSheetV2 from "../api/document-sheet.mjs";
|
||||
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
|
||||
|
||||
/**
|
||||
* The User configuration application.
|
||||
* @extends DocumentSheetV2
|
||||
* @mixes HandlebarsApplication
|
||||
* @alias UserConfig
|
||||
*/
|
||||
export default class UserConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
|
||||
|
||||
/** @inheritDoc */
|
||||
static DEFAULT_OPTIONS = {
|
||||
classes: ["user-config"],
|
||||
position: {
|
||||
width: 480,
|
||||
height: "auto"
|
||||
},
|
||||
actions: {
|
||||
releaseCharacter: UserConfig.#onReleaseCharacter
|
||||
},
|
||||
form: {
|
||||
closeOnSubmit: true
|
||||
}
|
||||
};
|
||||
|
||||
/** @override */
|
||||
static PARTS = {
|
||||
form: {
|
||||
id: "form",
|
||||
template: "templates/sheets/user-config.hbs"
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
get title() {
|
||||
return `${game.i18n.localize("PLAYERS.ConfigTitle")}: ${this.document.name}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _prepareContext(_options) {
|
||||
return {
|
||||
user: this.document,
|
||||
source: this.document.toObject(),
|
||||
fields: this.document.schema.fields,
|
||||
characterWidget: this.#characterChoiceWidget.bind(this)
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render the Character field as a choice between observed Actors.
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
#characterChoiceWidget(field, _groupConfig, inputConfig) {
|
||||
|
||||
// Create the form field
|
||||
const fg = document.createElement("div");
|
||||
fg.className = "form-group stacked character";
|
||||
const ff = fg.appendChild(document.createElement("div"));
|
||||
ff.className = "form-fields";
|
||||
fg.insertAdjacentHTML("beforeend", `<p class="hint">${field.hint}</p>`);
|
||||
|
||||
// Actor select
|
||||
const others = game.users.reduce((s, u) => {
|
||||
if ( u.character && !u.isSelf ) s.add(u.character.id);
|
||||
return s;
|
||||
}, new Set());
|
||||
|
||||
const options = [];
|
||||
const ownerGroup = game.i18n.localize("OWNERSHIP.OWNER");
|
||||
const observerGroup = game.i18n.localize("OWNERSHIP.OBSERVER");
|
||||
for ( const actor of game.actors ) {
|
||||
if ( !actor.testUserPermission(this.document, "OBSERVER") ) continue;
|
||||
const a = {value: actor.id, label: actor.name, disabled: others.has(actor.id)};
|
||||
options.push({group: actor.isOwner ? ownerGroup : observerGroup, ...a});
|
||||
}
|
||||
|
||||
const input = foundry.applications.fields.createSelectInput({...inputConfig,
|
||||
name: field.fieldPath,
|
||||
options,
|
||||
blank: "",
|
||||
sort: true
|
||||
});
|
||||
ff.appendChild(input);
|
||||
|
||||
// Player character
|
||||
const c = this.document.character;
|
||||
if ( c ) {
|
||||
ff.insertAdjacentHTML("afterbegin", `<img class="avatar" src="${c.img}" alt="${c.name}">`);
|
||||
const release = `<button type="button" class="icon fa-solid fa-ban" data-action="releaseCharacter"
|
||||
data-tooltip="USER.SHEET.BUTTONS.RELEASE"></button>`
|
||||
ff.insertAdjacentHTML("beforeend", release);
|
||||
}
|
||||
return fg;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button clicks to release the currently selected character.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
static #onReleaseCharacter(event) {
|
||||
event.preventDefault();
|
||||
const button = event.target;
|
||||
const fields = button.parentElement;
|
||||
fields.querySelector("select[name=character]").value = "";
|
||||
fields.querySelector("img.avatar").remove();
|
||||
button.remove();
|
||||
this.setPosition({height: "auto"});
|
||||
}
|
||||
}
|
||||
1
resources/app/client-esm/applications/ui/_module.mjs
Normal file
1
resources/app/client-esm/applications/ui/_module.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export {default as RegionLegend} from "./region-legend.mjs";
|
||||
389
resources/app/client-esm/applications/ui/region-legend.mjs
Normal file
389
resources/app/client-esm/applications/ui/region-legend.mjs
Normal file
@@ -0,0 +1,389 @@
|
||||
import ApplicationV2 from "../api/application.mjs";
|
||||
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
|
||||
|
||||
/**
|
||||
* Scene Region Legend.
|
||||
* @extends ApplicationV2
|
||||
* @mixes HandlebarsApplication
|
||||
* @alias RegionLegend
|
||||
*/
|
||||
export default class RegionLegend extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
|
||||
/** @inheritDoc */
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: "region-legend",
|
||||
tag: "aside",
|
||||
position: {
|
||||
width: 320,
|
||||
height: "auto"
|
||||
},
|
||||
window: {
|
||||
title: "REGION.LEGEND.title",
|
||||
icon: "fa-regular fa-game-board",
|
||||
minimizable: false
|
||||
},
|
||||
actions: {
|
||||
config: RegionLegend.#onConfig,
|
||||
control: RegionLegend.#onControl,
|
||||
create: RegionLegend.#onCreate,
|
||||
delete: RegionLegend.#onDelete,
|
||||
lock: RegionLegend.#onLock
|
||||
},
|
||||
};
|
||||
|
||||
/** @override */
|
||||
static PARTS = {
|
||||
list: {
|
||||
id: "list",
|
||||
template: "templates/scene/region-legend.hbs",
|
||||
scrollable: ["ol.region-list"]
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The currently filtered Regions.
|
||||
* @type {{bottom: number, top: number}}
|
||||
*/
|
||||
#visibleRegions = new Set();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The currently viewed elevation range.
|
||||
* @type {{bottom: number, top: number}}
|
||||
*/
|
||||
elevation = {bottom: -Infinity, top: Infinity};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @type {SearchFilter} */
|
||||
#searchFilter = new SearchFilter({
|
||||
inputSelector: 'input[name="search"]',
|
||||
contentSelector: ".region-list",
|
||||
callback: this.#onSearchFilter.bind(this)
|
||||
});
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Record a reference to the currently highlighted Region.
|
||||
* @type {Region|null}
|
||||
*/
|
||||
#hoveredRegion = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_configureRenderOptions(options) {
|
||||
super._configureRenderOptions(options);
|
||||
if ( options.isFirstRender ) {
|
||||
options.position.left ??= ui.nav?.element[0].getBoundingClientRect().left;
|
||||
options.position.top ??= ui.controls?.element[0].getBoundingClientRect().top;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_canRender(options) {
|
||||
const rc = options.renderContext;
|
||||
if ( rc && !["createregions", "updateregions", "deleteregions"].includes(rc) ) return false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _renderFrame(options) {
|
||||
const frame = await super._renderFrame(options);
|
||||
this.window.close.remove(); // Prevent closing
|
||||
return frame;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async close(options={}) {
|
||||
if ( !options.closeKey ) return super.close(options);
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onFirstRender(context, options) {
|
||||
super._onFirstRender(context, options);
|
||||
canvas.scene.apps[this.id] = this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onRender(context, options) {
|
||||
super._onRender(context, options);
|
||||
this.#searchFilter.bind(this.element);
|
||||
for ( const li of this.element.querySelectorAll(".region") ) {
|
||||
li.addEventListener("mouseover", this.#onRegionHoverIn.bind(this));
|
||||
li.addEventListener("mouseout", this.#onRegionHoverOut.bind(this));
|
||||
}
|
||||
this.element.querySelector(`input[name="elevationBottom"]`)
|
||||
.addEventListener("change", this.#onElevationBottomChange.bind(this));
|
||||
this.element.querySelector(`input[name="elevationTop"]`)
|
||||
.addEventListener("change", this.#onElevationTopChange.bind(this));
|
||||
this.#updateVisibleRegions();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onClose(options) {
|
||||
super._onClose(options);
|
||||
this.#visibleRegions.clear();
|
||||
this.elevation.bottom = -Infinity;
|
||||
this.elevation.top = Infinity;
|
||||
delete canvas.scene.apps[this.id];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _prepareContext(_options) {
|
||||
const regions = canvas.scene.regions.map(r => this.#prepareRegion(r));
|
||||
regions.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
|
||||
return {
|
||||
regions,
|
||||
elevation: {
|
||||
bottom: Number.isFinite(this.elevation.bottom) ? this.elevation.bottom : "",
|
||||
top: Number.isFinite(this.elevation.top) ? this.elevation.top : "",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/*-------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare each Region for rendering in the legend.
|
||||
* @param {Region} region
|
||||
* @returns {object}
|
||||
*/
|
||||
#prepareRegion(region) {
|
||||
const hasElv = (region.elevation.bottom !== null) || (region.elevation.top !== null);
|
||||
return {
|
||||
id: region.id,
|
||||
name: region.name,
|
||||
color: region.color.css,
|
||||
elevation: region.elevation,
|
||||
elevationLabel: hasElv ? `[${region.elevation.bottom ?? "∞"}, ${region.elevation.top ?? "∞"}]` : "",
|
||||
empty: !region.shapes.length,
|
||||
locked: region.locked,
|
||||
controlled: region.object?.controlled,
|
||||
hover: region.object?.hover,
|
||||
buttons: [
|
||||
{
|
||||
action: "config",
|
||||
icon: "fa-cogs",
|
||||
tooltip: game.i18n.localize("REGION.LEGEND.config"),
|
||||
disabled: ""
|
||||
},
|
||||
{
|
||||
action: "lock",
|
||||
icon: region.locked ? "fa-lock" : "fa-unlock",
|
||||
tooltip: game.i18n.localize(region.locked ? "REGION.LEGEND.unlock" : "REGION.LEGEND.lock"),
|
||||
disabled: ""
|
||||
},
|
||||
{
|
||||
action: "delete",
|
||||
icon: "fa-trash",
|
||||
tooltip: game.i18n.localize("REGION.LEGEND.delete"),
|
||||
disabled: region.locked ? "disabled" : ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the region list and hide regions that are not visible.
|
||||
*/
|
||||
#updateVisibleRegions() {
|
||||
this.#visibleRegions.clear();
|
||||
for ( const li of this.element.querySelectorAll(".region-list > .region") ) {
|
||||
const id = li.dataset.regionId;
|
||||
const region = canvas.scene.regions.get(id);
|
||||
const hidden = !((this.#searchFilter.rgx?.test(SearchFilter.cleanQuery(region.name)) !== false)
|
||||
&& (Math.max(region.object.bottom, this.elevation.bottom) <= Math.min(region.object.top, this.elevation.top)));
|
||||
if ( !hidden ) this.#visibleRegions.add(region);
|
||||
li.classList.toggle("hidden", hidden);
|
||||
}
|
||||
this.setPosition({height: "auto"});
|
||||
for ( const region of canvas.regions.placeables ) region.renderFlags.set({refreshState: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Filter regions.
|
||||
* @param {KeyboardEvent} event The key-up event from keyboard input
|
||||
* @param {string} query The raw string input to the search field
|
||||
* @param {RegExp} rgx The regular expression to test against
|
||||
* @param {HTMLElement} html The HTML element which should be filtered
|
||||
*/
|
||||
#onSearchFilter(event, query, rgx, html) {
|
||||
if ( !this.rendered ) return;
|
||||
this.#updateVisibleRegions();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle change events of the elevation range (bottom) input.
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onElevationBottomChange(event) {
|
||||
this.elevation.bottom = Number(event.currentTarget.value || -Infinity);
|
||||
this.#updateVisibleRegions();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle change events of the elevation range (top) input.
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onElevationTopChange(event) {
|
||||
this.elevation.top = Number(event.currentTarget.value || Infinity);
|
||||
this.#updateVisibleRegions();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this Region visible in this RegionLegend?
|
||||
* @param {Region} region The region
|
||||
* @returns {boolean}
|
||||
* @internal
|
||||
*/
|
||||
_isRegionVisible(region) {
|
||||
if ( !this.rendered ) return true;
|
||||
return this.#visibleRegions.has(region.document);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-in events on a region in the legend.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
#onRegionHoverIn(event) {
|
||||
event.preventDefault();
|
||||
if ( !canvas.ready ) return;
|
||||
const li = event.currentTarget.closest(".region");
|
||||
const region = canvas.regions.get(li.dataset.regionId);
|
||||
region._onHoverIn(event, {hoverOutOthers: true, updateLegend: false});
|
||||
this.#hoveredRegion = region;
|
||||
li.classList.add("hovered");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-out events for a region in the legend.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
#onRegionHoverOut(event) {
|
||||
event.preventDefault();
|
||||
const li = event.currentTarget.closest(".region");
|
||||
this.#hoveredRegion?._onHoverOut(event, {updateLegend: false});
|
||||
this.#hoveredRegion = null;
|
||||
li.classList.remove("hovered");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Highlight a hovered region in the legend.
|
||||
* @param {Region} region The Region
|
||||
* @param {boolean} hover Whether they are being hovered in or out.
|
||||
* @internal
|
||||
*/
|
||||
_hoverRegion(region, hover) {
|
||||
if ( !this.rendered ) return;
|
||||
const li = this.element.querySelector(`.region[data-region-id="${region.id}"]`);
|
||||
if ( !li ) return;
|
||||
if ( hover ) li.classList.add("hovered");
|
||||
else li.classList.remove("hovered");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clicks to configure a Region.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
static #onConfig(event) {
|
||||
const regionId = event.target.closest(".region").dataset.regionId;
|
||||
const region = canvas.scene.regions.get(regionId);
|
||||
region.sheet.render({force: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clicks to assume control over a Region.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
static #onControl(event) {
|
||||
const regionId = event.target.closest(".region").dataset.regionId;
|
||||
const region = canvas.scene.regions.get(regionId);
|
||||
|
||||
// Double-click = toggle sheet
|
||||
if ( event.detail === 2 ) {
|
||||
region.object.control({releaseOthers: true});
|
||||
region.sheet.render({force: true});
|
||||
}
|
||||
|
||||
// Single-click = toggle control
|
||||
else if ( event.detail === 1 ) {
|
||||
if ( region.object.controlled ) region.object.release();
|
||||
else region.object.control({releaseOthers: true});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button clicks to create a new Region.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
static async #onCreate(event) {
|
||||
await canvas.scene.createEmbeddedDocuments("Region", [{
|
||||
name: RegionDocument.implementation.defaultName({parent: canvas.scene})
|
||||
}]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clicks to delete a Region.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
static async #onDelete(event) {
|
||||
const regionId = event.target.closest(".region").dataset.regionId;
|
||||
const region = canvas.scene.regions.get(regionId);
|
||||
await region.deleteDialog();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clicks to toggle the locked state of a Region.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
static async #onLock(event) {
|
||||
const regionId = event.target.closest(".region").dataset.regionId;
|
||||
const region = canvas.scene.regions.get(regionId);
|
||||
await region.update({locked: !region.locked});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user