Initial
This commit is contained in:
21
LICENSE.electron.txt
Normal file
21
LICENSE.electron.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
Copyright (c) Electron contributors
|
||||
Copyright (c) 2013-2020 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
166038
LICENSES.chromium.html
Normal file
166038
LICENSES.chromium.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
chrome-sandbox
Executable file
BIN
chrome-sandbox
Executable file
Binary file not shown.
BIN
chrome_100_percent.pak
Normal file
BIN
chrome_100_percent.pak
Normal file
Binary file not shown.
BIN
chrome_200_percent.pak
Normal file
BIN
chrome_200_percent.pak
Normal file
Binary file not shown.
BIN
chrome_crashpad_handler
Executable file
BIN
chrome_crashpad_handler
Executable file
Binary file not shown.
BIN
foundryvtt
Executable file
BIN
foundryvtt
Executable file
Binary file not shown.
BIN
icudtl.dat
Normal file
BIN
icudtl.dat
Normal file
Binary file not shown.
BIN
libGLESv2.so
Executable file
BIN
libGLESv2.so
Executable file
Binary file not shown.
BIN
libffmpeg.so
Executable file
BIN
libffmpeg.so
Executable file
Binary file not shown.
BIN
libvk_swiftshader.so
Executable file
BIN
libvk_swiftshader.so
Executable file
Binary file not shown.
BIN
libvulkan.so.1
Executable file
BIN
libvulkan.so.1
Executable file
Binary file not shown.
BIN
locales/af.pak
Normal file
BIN
locales/af.pak
Normal file
Binary file not shown.
BIN
locales/am.pak
Normal file
BIN
locales/am.pak
Normal file
Binary file not shown.
BIN
locales/ar.pak
Normal file
BIN
locales/ar.pak
Normal file
Binary file not shown.
BIN
locales/bg.pak
Normal file
BIN
locales/bg.pak
Normal file
Binary file not shown.
BIN
locales/bn.pak
Normal file
BIN
locales/bn.pak
Normal file
Binary file not shown.
BIN
locales/ca.pak
Normal file
BIN
locales/ca.pak
Normal file
Binary file not shown.
BIN
locales/cs.pak
Normal file
BIN
locales/cs.pak
Normal file
Binary file not shown.
BIN
locales/da.pak
Normal file
BIN
locales/da.pak
Normal file
Binary file not shown.
BIN
locales/de.pak
Normal file
BIN
locales/de.pak
Normal file
Binary file not shown.
BIN
locales/el.pak
Normal file
BIN
locales/el.pak
Normal file
Binary file not shown.
BIN
locales/en-GB.pak
Normal file
BIN
locales/en-GB.pak
Normal file
Binary file not shown.
BIN
locales/en-US.pak
Normal file
BIN
locales/en-US.pak
Normal file
Binary file not shown.
BIN
locales/es-419.pak
Normal file
BIN
locales/es-419.pak
Normal file
Binary file not shown.
BIN
locales/es.pak
Normal file
BIN
locales/es.pak
Normal file
Binary file not shown.
BIN
locales/et.pak
Normal file
BIN
locales/et.pak
Normal file
Binary file not shown.
BIN
locales/fa.pak
Normal file
BIN
locales/fa.pak
Normal file
Binary file not shown.
BIN
locales/fi.pak
Normal file
BIN
locales/fi.pak
Normal file
Binary file not shown.
BIN
locales/fil.pak
Normal file
BIN
locales/fil.pak
Normal file
Binary file not shown.
BIN
locales/fr.pak
Normal file
BIN
locales/fr.pak
Normal file
Binary file not shown.
BIN
locales/gu.pak
Normal file
BIN
locales/gu.pak
Normal file
Binary file not shown.
BIN
locales/he.pak
Normal file
BIN
locales/he.pak
Normal file
Binary file not shown.
BIN
locales/hi.pak
Normal file
BIN
locales/hi.pak
Normal file
Binary file not shown.
BIN
locales/hr.pak
Normal file
BIN
locales/hr.pak
Normal file
Binary file not shown.
BIN
locales/hu.pak
Normal file
BIN
locales/hu.pak
Normal file
Binary file not shown.
BIN
locales/id.pak
Normal file
BIN
locales/id.pak
Normal file
Binary file not shown.
BIN
locales/it.pak
Normal file
BIN
locales/it.pak
Normal file
Binary file not shown.
BIN
locales/ja.pak
Normal file
BIN
locales/ja.pak
Normal file
Binary file not shown.
BIN
locales/kn.pak
Normal file
BIN
locales/kn.pak
Normal file
Binary file not shown.
BIN
locales/ko.pak
Normal file
BIN
locales/ko.pak
Normal file
Binary file not shown.
BIN
locales/lt.pak
Normal file
BIN
locales/lt.pak
Normal file
Binary file not shown.
BIN
locales/lv.pak
Normal file
BIN
locales/lv.pak
Normal file
Binary file not shown.
BIN
locales/ml.pak
Normal file
BIN
locales/ml.pak
Normal file
Binary file not shown.
BIN
locales/mr.pak
Normal file
BIN
locales/mr.pak
Normal file
Binary file not shown.
BIN
locales/ms.pak
Normal file
BIN
locales/ms.pak
Normal file
Binary file not shown.
BIN
locales/nb.pak
Normal file
BIN
locales/nb.pak
Normal file
Binary file not shown.
BIN
locales/nl.pak
Normal file
BIN
locales/nl.pak
Normal file
Binary file not shown.
BIN
locales/pl.pak
Normal file
BIN
locales/pl.pak
Normal file
Binary file not shown.
BIN
locales/pt-BR.pak
Normal file
BIN
locales/pt-BR.pak
Normal file
Binary file not shown.
BIN
locales/pt-PT.pak
Normal file
BIN
locales/pt-PT.pak
Normal file
Binary file not shown.
BIN
locales/ro.pak
Normal file
BIN
locales/ro.pak
Normal file
Binary file not shown.
BIN
locales/ru.pak
Normal file
BIN
locales/ru.pak
Normal file
Binary file not shown.
BIN
locales/sk.pak
Normal file
BIN
locales/sk.pak
Normal file
Binary file not shown.
BIN
locales/sl.pak
Normal file
BIN
locales/sl.pak
Normal file
Binary file not shown.
BIN
locales/sr.pak
Normal file
BIN
locales/sr.pak
Normal file
Binary file not shown.
BIN
locales/sv.pak
Normal file
BIN
locales/sv.pak
Normal file
Binary file not shown.
BIN
locales/sw.pak
Normal file
BIN
locales/sw.pak
Normal file
Binary file not shown.
BIN
locales/ta.pak
Normal file
BIN
locales/ta.pak
Normal file
Binary file not shown.
BIN
locales/te.pak
Normal file
BIN
locales/te.pak
Normal file
Binary file not shown.
BIN
locales/th.pak
Normal file
BIN
locales/th.pak
Normal file
Binary file not shown.
BIN
locales/tr.pak
Normal file
BIN
locales/tr.pak
Normal file
Binary file not shown.
BIN
locales/uk.pak
Normal file
BIN
locales/uk.pak
Normal file
Binary file not shown.
BIN
locales/ur.pak
Normal file
BIN
locales/ur.pak
Normal file
Binary file not shown.
BIN
locales/vi.pak
Normal file
BIN
locales/vi.pak
Normal file
Binary file not shown.
BIN
locales/zh-CN.pak
Normal file
BIN
locales/zh-CN.pak
Normal file
Binary file not shown.
BIN
locales/zh-TW.pak
Normal file
BIN
locales/zh-TW.pak
Normal file
Binary file not shown.
BIN
resources.pak
Normal file
BIN
resources.pak
Normal file
Binary file not shown.
4
resources/app-update.yml
Normal file
4
resources/app-update.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
owner: foundryvtt
|
||||
repo: foundryvtt
|
||||
provider: github
|
||||
updaterCacheDirName: foundryvtt-updater
|
||||
211
resources/app/.eslintrc.json
Normal file
211
resources/app/.eslintrc.json
Normal file
@@ -0,0 +1,211 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true,
|
||||
"node": true,
|
||||
"jquery": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"requireConfigFile": false
|
||||
},
|
||||
"plugins": [
|
||||
"jsdoc"
|
||||
],
|
||||
"rules": {
|
||||
"array-bracket-spacing": ["warn", "never"],
|
||||
"array-callback-return": "warn",
|
||||
"arrow-spacing": "warn",
|
||||
"comma-dangle": ["warn", "never"],
|
||||
"comma-style": "warn",
|
||||
"computed-property-spacing": "warn",
|
||||
"constructor-super": "error",
|
||||
"default-param-last": "warn",
|
||||
"dot-location": ["warn", "property"],
|
||||
"eol-last": ["error", "always"],
|
||||
"eqeqeq": ["warn", "smart"],
|
||||
"func-call-spacing": "warn",
|
||||
"func-names": ["warn", "never"],
|
||||
"getter-return": "warn",
|
||||
"lines-between-class-members": "warn",
|
||||
"new-parens": ["warn", "always"],
|
||||
"no-alert": "warn",
|
||||
"no-array-constructor": "warn",
|
||||
"no-class-assign": "warn",
|
||||
"no-compare-neg-zero": "warn",
|
||||
"no-cond-assign": "warn",
|
||||
"no-const-assign": "error",
|
||||
"no-constant-condition": "warn",
|
||||
"no-constructor-return": "warn",
|
||||
"no-delete-var": "warn",
|
||||
"no-dupe-args": "warn",
|
||||
"no-dupe-class-members": "warn",
|
||||
"no-dupe-keys": "warn",
|
||||
"no-duplicate-case": "warn",
|
||||
"no-duplicate-imports": ["warn", {"includeExports": true}],
|
||||
"no-empty": ["warn", {"allowEmptyCatch": true}],
|
||||
"no-empty-character-class": "warn",
|
||||
"no-empty-pattern": "warn",
|
||||
"no-func-assign": "warn",
|
||||
"no-global-assign": "warn",
|
||||
"no-implicit-coercion": ["warn", {"allow": ["!!"]}],
|
||||
"no-implied-eval": "warn",
|
||||
"no-import-assign": "warn",
|
||||
"no-invalid-regexp": "warn",
|
||||
"no-irregular-whitespace": "warn",
|
||||
"no-iterator": "warn",
|
||||
"no-lone-blocks": "warn",
|
||||
"no-lonely-if": "off",
|
||||
"no-loop-func": "warn",
|
||||
"no-misleading-character-class": "warn",
|
||||
"no-mixed-operators": "warn",
|
||||
"no-multi-str": "warn",
|
||||
"no-multiple-empty-lines": "warn",
|
||||
"no-new-func": "warn",
|
||||
"no-new-object": "warn",
|
||||
"no-new-symbol": "warn",
|
||||
"no-new-wrappers": "warn",
|
||||
"no-nonoctal-decimal-escape": "warn",
|
||||
"no-obj-calls": "warn",
|
||||
"no-octal": "warn",
|
||||
"no-octal-escape": "warn",
|
||||
"no-promise-executor-return": "warn",
|
||||
"no-proto": "warn",
|
||||
"no-regex-spaces": "warn",
|
||||
"no-script-url": "warn",
|
||||
"no-self-assign": "warn",
|
||||
"no-self-compare": "warn",
|
||||
"no-setter-return": "warn",
|
||||
"no-sequences": "warn",
|
||||
"no-template-curly-in-string": "warn",
|
||||
"no-this-before-super": "error",
|
||||
"no-unexpected-multiline": "warn",
|
||||
"no-unmodified-loop-condition": "warn",
|
||||
"no-unneeded-ternary": "warn",
|
||||
"no-unreachable": "warn",
|
||||
"no-unreachable-loop": "warn",
|
||||
"no-unsafe-negation": ["warn", {"enforceForOrderingRelations": true}],
|
||||
"no-unsafe-optional-chaining": ["warn", {"disallowArithmeticOperators": true}],
|
||||
"no-unused-expressions": "warn",
|
||||
"no-useless-backreference": "warn",
|
||||
"no-useless-call": "warn",
|
||||
"no-useless-catch": "warn",
|
||||
"no-useless-computed-key": ["warn", {"enforceForClassMembers": true}],
|
||||
"no-useless-concat": "warn",
|
||||
"no-useless-constructor": "warn",
|
||||
"no-useless-rename": "warn",
|
||||
"no-useless-return": "warn",
|
||||
"no-var": "warn",
|
||||
"no-void": "warn",
|
||||
"no-whitespace-before-property": "warn",
|
||||
"prefer-numeric-literals": "warn",
|
||||
"prefer-object-spread": "warn",
|
||||
"prefer-regex-literals": "warn",
|
||||
"prefer-spread": "warn",
|
||||
"rest-spread-spacing": ["warn", "never"],
|
||||
"semi-spacing": "warn",
|
||||
"semi-style": ["warn", "last"],
|
||||
"space-unary-ops": ["warn", {"words": true, "nonwords": false}],
|
||||
"switch-colon-spacing": "warn",
|
||||
"symbol-description": "warn",
|
||||
"template-curly-spacing": ["warn", "never"],
|
||||
"unicode-bom": ["warn", "never"],
|
||||
"use-isnan": ["warn", {"enforceForSwitchCase": true, "enforceForIndexOf": true}],
|
||||
"valid-typeof": ["warn", {"requireStringLiterals": true}],
|
||||
"wrap-iife": ["warn", "inside"],
|
||||
|
||||
"arrow-parens": ["warn", "as-needed", {"requireForBlockBody": false}],
|
||||
"capitalized-comments": ["warn", "always", {
|
||||
"ignoreConsecutiveComments": true,
|
||||
"ignorePattern": "noinspection"
|
||||
}],
|
||||
"comma-spacing": "warn",
|
||||
"dot-notation": "warn",
|
||||
"indent": ["warn", 2, {"SwitchCase": 1}],
|
||||
"key-spacing": "warn",
|
||||
"keyword-spacing": ["warn", {"overrides": {"catch": {"before": true, "after": false}}}],
|
||||
"max-len": ["warn", {
|
||||
"code": 120,
|
||||
"ignoreTrailingComments": true,
|
||||
"ignoreUrls": true,
|
||||
"ignoreStrings": true,
|
||||
"ignoreTemplateLiterals": true
|
||||
}],
|
||||
"no-extra-boolean-cast": ["warn", {"enforceForLogicalOperands": true}],
|
||||
"no-extra-semi": "warn",
|
||||
"no-multi-spaces": ["warn", {"ignoreEOLComments": true}],
|
||||
"no-tabs": "warn",
|
||||
"no-throw-literal": "error",
|
||||
"no-trailing-spaces": "warn",
|
||||
"no-useless-escape": "warn",
|
||||
"nonblock-statement-body-position": ["warn", "beside"],
|
||||
"one-var": ["warn", "never"],
|
||||
"operator-linebreak": ["warn", "before", {
|
||||
"overrides": {"=": "after", "+=": "after", "-=": "after"}
|
||||
}],
|
||||
"prefer-template": "warn",
|
||||
"quote-props": ["warn", "as-needed", {"keywords": false}],
|
||||
"quotes": ["warn", "double", {"avoidEscape": true, "allowTemplateLiterals": false}],
|
||||
"semi": "warn",
|
||||
"space-before-blocks": ["warn", "always"],
|
||||
"space-before-function-paren": ["warn", {
|
||||
"anonymous": "never",
|
||||
"named": "never",
|
||||
"asyncArrow": "always"
|
||||
}],
|
||||
"spaced-comment": "warn",
|
||||
"jsdoc/check-access": "warn",
|
||||
"jsdoc/check-alignment": "warn",
|
||||
"jsdoc/check-examples": "off",
|
||||
"jsdoc/check-indentation": "off",
|
||||
"jsdoc/check-line-alignment": "off",
|
||||
"jsdoc/check-param-names": "warn",
|
||||
"jsdoc/check-property-names": "warn",
|
||||
"jsdoc/check-syntax": "off",
|
||||
"jsdoc/check-tag-names": ["warn", { "definedTags": ["category"] }],
|
||||
"jsdoc/check-types": "warn",
|
||||
"jsdoc/check-values": "warn",
|
||||
"jsdoc/empty-tags": "warn",
|
||||
"jsdoc/implements-on-classes": "warn",
|
||||
"jsdoc/match-description": "off",
|
||||
"jsdoc/newline-after-description": "off",
|
||||
"jsdoc/no-bad-blocks": "warn",
|
||||
"jsdoc/no-defaults": "off",
|
||||
"jsdoc/no-types": "off",
|
||||
"jsdoc/no-undefined-types": "off",
|
||||
"jsdoc/require-description": "warn",
|
||||
"jsdoc/require-description-complete-sentence": "off",
|
||||
"jsdoc/require-example": "off",
|
||||
"jsdoc/require-file-overview": "off",
|
||||
"jsdoc/require-hyphen-before-param-description": ["warn", "never"],
|
||||
"jsdoc/require-jsdoc": "warn",
|
||||
"jsdoc/require-param": "warn",
|
||||
"jsdoc/require-param-description": "off",
|
||||
"jsdoc/require-param-name": "warn",
|
||||
"jsdoc/require-param-type": "warn",
|
||||
"jsdoc/require-property": "warn",
|
||||
"jsdoc/require-property-description": "off",
|
||||
"jsdoc/require-property-name": "warn",
|
||||
"jsdoc/require-property-type": "warn",
|
||||
"jsdoc/require-returns": "off",
|
||||
"jsdoc/require-returns-check": "warn",
|
||||
"jsdoc/require-returns-description": "off",
|
||||
"jsdoc/require-returns-type": "warn",
|
||||
"jsdoc/require-throws": "off",
|
||||
"jsdoc/require-yields": "warn",
|
||||
"jsdoc/require-yields-check": "warn",
|
||||
"jsdoc/valid-types": "off"
|
||||
},
|
||||
"settings": {
|
||||
"jsdoc": {
|
||||
"preferredTypes": {
|
||||
".<>": "<>",
|
||||
"object": "Object",
|
||||
"Object": "object"
|
||||
},
|
||||
"mode": "typescript",
|
||||
"tagNamePreference": {
|
||||
"augments": "extends"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user