/* -------------------------------------------- */ /* HTML Template Loading */ /* -------------------------------------------- */ /** * Get a template from the server by fetch request and caching the retrieved result * @param {string} path The web-accessible HTML template URL * @param {string} [id] An ID to register the partial with. * @returns {Promise} A Promise which resolves to the compiled Handlebars template */ async function getTemplate(path, id) { if ( path in Handlebars.partials ) return Handlebars.partials[path]; const htmlString = await new Promise((resolve, reject) => { game.socket.emit("template", path, resp => { if ( resp.error ) return reject(new Error(resp.error)); return resolve(resp.html); }); }); const compiled = Handlebars.compile(htmlString); Handlebars.registerPartial(id ?? path, compiled); console.log(`Foundry VTT | Retrieved and compiled template ${path}`); return compiled; } /* -------------------------------------------- */ /** * Load and cache a set of templates by providing an Array of paths * @param {string[]|Record} paths An array of template file paths to load, or an object of Handlebars partial * IDs to paths. * @returns {Promise} * * @example Loading a list of templates. * ```js * await loadTemplates(["templates/apps/foo.html", "templates/apps/bar.html"]); * ``` * ```hbs * * {{> "templates/apps/foo.html" }} * ``` * * @example Loading an object of templates. * ```js * await loadTemplates({ * foo: "templates/apps/foo.html", * bar: "templates/apps/bar.html" * }); * ``` * ```hbs * * {{> foo }} * ``` */ async function loadTemplates(paths) { let promises; if ( foundry.utils.getType(paths) === "Object" ) promises = Object.entries(paths).map(([k, p]) => getTemplate(p, k)); else promises = paths.map(p => getTemplate(p)); return Promise.all(promises); } /* -------------------------------------------- */ /** * Get and render a template using provided data and handle the returned HTML * Support asynchronous file template file loading with a client-side caching layer * * Allow resolution of prototype methods and properties since this all occurs within the safety of the client. * @see {@link https://handlebarsjs.com/api-reference/runtime-options.html#options-to-control-prototype-access} * * @param {string} path The file path to the target HTML template * @param {Object} data A data object against which to compile the template * * @returns {Promise} Returns the compiled and rendered template as a string */ async function renderTemplate(path, data) { const template = await getTemplate(path); return template(data || {}, { allowProtoMethodsByDefault: true, allowProtoPropertiesByDefault: true }); } /* -------------------------------------------- */ /* Handlebars Template Helpers */ /* -------------------------------------------- */ // Register Handlebars Extensions HandlebarsIntl.registerWith(Handlebars); /** * A collection of Handlebars template helpers which can be used within HTML templates. */ class HandlebarsHelpers { /** * For checkboxes, if the value of the checkbox is true, add the "checked" property, otherwise add nothing. * @returns {string} * * @example * ```hbs * * * ``` */ static checked(value) { return Boolean(value) ? "checked" : ""; } /* -------------------------------------------- */ /** * For use in form inputs. If the supplied value is truthy, add the "disabled" property, otherwise add nothing. * @returns {string} * * @example * ```hbs * * ``` */ static disabled(value) { return value ? "disabled" : ""; } /* -------------------------------------------- */ /** * Concatenate a number of string terms into a single string. * This is useful for passing arguments with variable names. * @param {string[]} values The values to concatenate * @returns {Handlebars.SafeString} * * @example Concatenate several string parts to create a dynamic variable * ```hbs * {{filePicker target=(concat "faces." i ".img") type="image"}} * ``` */ static concat(...values) { const options = values.pop(); const join = options.hash?.join || ""; return new Handlebars.SafeString(values.join(join)); } /* -------------------------------------------- */ /** * Construct an editor element for rich text editing with TinyMCE or ProseMirror. * @param {string} content The content to display and edit. * @param {object} [options] * @param {string} [options.target] The named target data element * @param {boolean} [options.button] Include a button used to activate the editor later? * @param {string} [options.class] A specific CSS class to add to the editor container * @param {boolean} [options.editable=true] Is the text editor area currently editable? * @param {string} [options.engine=tinymce] The editor engine to use, see {@link TextEditor.create}. * @param {boolean} [options.collaborate=false] Whether to turn on collaborative editing features for ProseMirror. * @returns {Handlebars.SafeString} * * @example * ```hbs * {{editor world.description target="description" button=false engine="prosemirror" collaborate=false}} * ``` */ static editor(content, options) { const { target, editable=true, button, engine="tinymce", collaborate=false, class: cssClass } = options.hash; const config = {name: target, value: content, button, collaborate, editable, engine}; const element = foundry.applications.fields.createEditorInput(config); if ( cssClass ) element.querySelector(".editor-content").classList.add(cssClass); return new Handlebars.SafeString(element.outerHTML); } /* -------------------------------------------- */ /** * A ternary expression that allows inserting A or B depending on the value of C. * @param {boolean} criteria The test criteria * @param {string} ifTrue The string to output if true * @param {string} ifFalse The string to output if false * @returns {string} The ternary result * * @example Ternary if-then template usage * ```hbs * {{ifThen true "It is true" "It is false"}} * ``` */ static ifThen(criteria, ifTrue, ifFalse) { return criteria ? ifTrue : ifFalse; } /* -------------------------------------------- */ /** * Translate a provided string key by using the loaded dictionary of localization strings. * @returns {string} * * @example Translate a provided localization string, optionally including formatting parameters * ```hbs * * * ``` */ static localize(value, options) { if ( value instanceof Handlebars.SafeString ) value = value.toString(); const data = options.hash; return foundry.utils.isEmpty(data) ? game.i18n.localize(value) : game.i18n.format(value, data); } /* -------------------------------------------- */ /** * A string formatting helper to display a number with a certain fixed number of decimals and an explicit sign. * @param {number|string} value A numeric value to format * @param {object} options Additional options which customize the resulting format * @param {number} [options.decimals=0] The number of decimal places to include in the resulting string * @param {boolean} [options.sign=false] Whether to include an explicit "+" sign for positive numbers * * @returns {Handlebars.SafeString} The formatted string to be included in a template * * @example * ```hbs * {{formatNumber 5.5}} * {{formatNumber 5.5 decimals=2}} * {{formatNumber 5.5 decimals=2 sign=true}} * {{formatNumber null decimals=2 sign=false}} * {{formatNumber undefined decimals=0 sign=true}} * ``` */ static numberFormat(value, options) { const originalValue = value; const dec = options.hash.decimals ?? 0; const sign = options.hash.sign || false; if ( (typeof value === "string") || (value == null) ) value = parseFloat(value); if ( Number.isNaN(value) ) { console.warn("An invalid value was passed to numberFormat:", { originalValue, valueType: typeof originalValue, options }); } let strVal = sign && (value >= 0) ? `+${value.toFixed(dec)}` : value.toFixed(dec); return new Handlebars.SafeString(strVal); } /* --------------------------------------------- */ /** * Render a form input field of type number with value appropriately rounded to step size. * @param {number} value * @param {FormInputConfig & NumberInputConfig} options * @returns {Handlebars.SafeString} * * @example * ```hbs * {{numberInput value name="numberField" step=1 min=0 max=10}} * ``` */ static numberInput(value, options) { const {class: cssClass, ...config} = options.hash; config.value = value; const element = foundry.applications.fields.createNumberInput(config); if ( cssClass ) element.className = cssClass; return new Handlebars.SafeString(element.outerHTML); } /* -------------------------------------------- */ /** * A helper to create a set of radio checkbox input elements in a named set. * The provided keys are the possible radio values while the provided values are human readable labels. * * @param {string} name The radio checkbox field name * @param {object} choices A mapping of radio checkbox values to human readable labels * @param {object} options Options which customize the radio boxes creation * @param {string} options.checked Which key is currently checked? * @param {boolean} options.localize Pass each label through string localization? * @returns {Handlebars.SafeString} * * @example The provided input data * ```js * let groupName = "importantChoice"; * let choices = {a: "Choice A", b: "Choice B"}; * let chosen = "a"; * ``` * * @example The template HTML structure * ```hbs *
* *
* {{radioBoxes groupName choices checked=chosen localize=true}} *
*
* ``` */ static radioBoxes(name, choices, options) { const checked = options.hash['checked'] || null; const localize = options.hash['localize'] || false; let html = ""; for ( let [key, label] of Object.entries(choices) ) { if ( localize ) label = game.i18n.localize(label); const isChecked = checked === key; html += ``; } return new Handlebars.SafeString(html); } /* -------------------------------------------- */ /** * Render a pair of inputs for selecting a value in a range. * @param {object} options Helper options * @param {string} [options.name] The name of the field to create * @param {number} [options.value] The current range value * @param {number} [options.min] The minimum allowed value * @param {number} [options.max] The maximum allowed value * @param {number} [options.step] The allowed step size * @returns {Handlebars.SafeString} * * @example * ```hbs * {{rangePicker name="foo" value=bar min=0 max=10 step=1}} * ``` */ static rangePicker(options) { let {name, value, min, max, step} = options.hash; name = name || "range"; value = value ?? ""; if ( Number.isNaN(value) ) value = ""; const html = ` ${value}`; return new Handlebars.SafeString(html); } /* -------------------------------------------- */ /** * @typedef {Object} SelectOptionsHelperOptions * @property {boolean} invert Invert the key/value order of a provided choices object * @property {string|string[]|Set} selected The currently selected value or values */ /** * A helper to create a set of <option> elements in a <select> block based on a provided dictionary. * The provided keys are the option values while the provided values are human-readable labels. * This helper supports both single-select and multi-select input fields. * * @param {object|Array} choices A mapping of radio checkbox values to human-readable labels * @param {SelectInputConfig & SelectOptionsHelperOptions} options Options which configure how select options are * generated by the helper * @returns {Handlebars.SafeString} Generated HTML safe for rendering into a Handlebars template * * @example The provided input data * ```js * let choices = {a: "Choice A", b: "Choice B"}; * let value = "a"; * ``` * The template HTML structure * ```hbs * * ``` * The resulting HTML * ```html * * ``` * * @example Using inverted choices * ```js * let choices = {"Choice A": "a", "Choice B": "b"}; * let value = "a"; * ``` * The template HTML structure * ```hbs * * ``` * * @example Using nameAttr and labelAttr with objects * ```js * let choices = {foo: {key: "a", label: "Choice A"}, bar: {key: "b", label: "Choice B"}}; * let value = "b"; * ``` * The template HTML structure * ```hbs * * ``` * * @example Using nameAttr and labelAttr with arrays * ```js * let choices = [{key: "a", label: "Choice A"}, {key: "b", label: "Choice B"}]; * let value = "b"; * ``` * The template HTML structure * ```hbs * * ``` */ static selectOptions(choices, options) { let {localize=false, selected, blank, sort, nameAttr, valueAttr, labelAttr, inverted, groups} = options.hash; if ( (selected === undefined) || (selected === null) ) selected = []; else if ( !(selected instanceof Array) ) selected = [selected]; if ( nameAttr && !valueAttr ) { foundry.utils.logCompatibilityWarning(`The "nameAttr" property of the {{selectOptions}} handlebars helper is renamed to "valueAttr" for consistency with other methods.`, {since: 12, until: 14}); valueAttr = nameAttr; } // Prepare the choices as an array of objects const selectOptions = []; if ( choices instanceof Array ) { for ( const [i, choice] of choices.entries() ) { if ( typeof choice === "object" ) selectOptions.push(choice); else selectOptions.push({value: i, label: choice}); } } // Object of keys and values else { for ( const choice of Object.entries(choices) ) { const [k, v] = inverted ? choice.reverse() : choice; const value = valueAttr ? v[valueAttr] : k; if ( typeof v === "object" ) selectOptions.push({value, ...v}); else selectOptions.push({value, label: v}); } } // Delegate to new fields helper const select = foundry.applications.fields.createSelectInput({ options: selectOptions, value: selected, blank, groups, labelAttr, localize, sort, valueAttr }); return new Handlebars.SafeString(select.innerHTML); } /* -------------------------------------------- */ /** * Convert a DataField instance into an HTML input fragment. * @param {DataField} field The DataField instance to convert to an input * @param {object} options Helper options * @returns {Handlebars.SafeString} */ static formInput(field, options) { const input = field.toInput(options.hash); return new Handlebars.SafeString(input.outerHTML); } /* -------------------------------------------- */ /** * Convert a DataField instance into an HTML input fragment. * @param {DataField} field The DataField instance to convert to an input * @param {object} options Helper options * @returns {Handlebars.SafeString} */ static formGroup(field, options) { const {classes, label, hint, rootId, stacked, units, widget, ...inputConfig} = options.hash; const groupConfig = {label, hint, rootId, stacked, widget, localize: inputConfig.localize, units, classes: typeof classes === "string" ? classes.split(" ") : []}; const group = field.toFormGroup(groupConfig, inputConfig); return new Handlebars.SafeString(group.outerHTML); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static filePicker(options) { foundry.utils.logCompatibilityWarning("The {{filePicker}} Handlebars helper is deprecated and replaced by" + " use of the custom HTML element", {since: 12, until: 14, once: true}); const type = options.hash.type; const target = options.hash.target; if ( !target ) throw new Error("You must define the name of the target field."); if ( game.world && !game.user.can("FILES_BROWSE" ) ) return ""; const tooltip = game.i18n.localize("FILES.BrowseTooltip"); return new Handlebars.SafeString(` `); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static colorPicker(options) { foundry.utils.logCompatibilityWarning("The {{colorPicker}} Handlebars helper is deprecated and replaced by" + " use of the custom HTML element", {since: 12, until: 14, once: true}); let {name, default: defaultColor, value} = options.hash; name = name || "color"; value = value || defaultColor || ""; const htmlString = ``; return new Handlebars.SafeString(htmlString); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static select(selected, options) { foundry.utils.logCompatibilityWarning("The {{select}} handlebars helper is deprecated in favor of using the " + "{{selectOptions}} helper or the foundry.applications.fields.createSelectInput, " + "foundry.applications.fields.createMultiSelectElement, or " + "foundry.applications.fields.prepareSelectOptionGroups methods.", {since: 12, until: 14}); const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected)); const rgx = new RegExp(` value=[\"']${escapedValue}[\"\']`); const html = options.fn(this); return html.replace(rgx, "$& selected"); } } // Register all handlebars helpers Handlebars.registerHelper({ checked: HandlebarsHelpers.checked, disabled: HandlebarsHelpers.disabled, colorPicker: HandlebarsHelpers.colorPicker, concat: HandlebarsHelpers.concat, editor: HandlebarsHelpers.editor, formInput: HandlebarsHelpers.formInput, formGroup: HandlebarsHelpers.formGroup, formField: HandlebarsHelpers.formGroup, // Alias filePicker: HandlebarsHelpers.filePicker, ifThen: HandlebarsHelpers.ifThen, numberFormat: HandlebarsHelpers.numberFormat, numberInput: HandlebarsHelpers.numberInput, localize: HandlebarsHelpers.localize, radioBoxes: HandlebarsHelpers.radioBoxes, rangePicker: HandlebarsHelpers.rangePicker, select: HandlebarsHelpers.select, selectOptions: HandlebarsHelpers.selectOptions, timeSince: foundry.utils.timeSince, eq: (v1, v2) => v1 === v2, ne: (v1, v2) => v1 !== v2, lt: (v1, v2) => v1 < v2, gt: (v1, v2) => v1 > v2, lte: (v1, v2) => v1 <= v2, gte: (v1, v2) => v1 >= v2, not: pred => !pred, and() {return Array.prototype.every.call(arguments, Boolean);}, or() {return Array.prototype.slice.call(arguments, 0, -1).some(Boolean);} });