Initial commit: Fresh start with current state

This commit is contained in:
Claude Code
2025-11-06 14:04:48 +01:00
commit 15355c35ea
20152 changed files with 1191077 additions and 0 deletions

View 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.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,4 @@
owner: foundryvtt
repo: foundryvtt
provider: github
updaterCacheDirName: foundryvtt-updater

View 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": "warn",
"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"
}
}
}
}

View File

@@ -0,0 +1,983 @@
/**
* A namespace containing the user interface applications which are defined throughout the Foundry VTT ecosystem.
* @namespace applications
*/
let _appId = 0;
let _maxZ = 100;
const MIN_WINDOW_WIDTH = 200;
const MIN_WINDOW_HEIGHT = 50;
/**
* @typedef {object} ApplicationOptions
* @property {string|null} [baseApplication] A named "base application" which generates an additional hook
* @property {number|null} [width] The default pixel width for the rendered HTML
* @property {number|string|null} [height] The default pixel height for the rendered HTML
* @property {number|null} [top] The default offset-top position for the rendered HTML
* @property {number|null} [left] The default offset-left position for the rendered HTML
* @property {number|null} [scale] A transformation scale for the rendered HTML
* @property {boolean} [popOut] Whether to display the application as a pop-out container
* @property {boolean} [minimizable] Whether the rendered application can be minimized (popOut only)
* @property {boolean} [resizable] Whether the rendered application can be drag-resized (popOut only)
* @property {string} [id] The default CSS id to assign to the rendered HTML
* @property {string[]} [classes] An array of CSS string classes to apply to the rendered HTML
* @property {string} [title] A default window title string (popOut only)
* @property {string|null} [template] The default HTML template path to render for this Application
* @property {string[]} [scrollY] A list of unique CSS selectors which target containers that should have their
* vertical scroll positions preserved during a re-render.
* @property {TabsConfiguration[]} [tabs] An array of tabbed container configurations which should be enabled for the
* application.
* @property {DragDropConfiguration[]} dragDrop An array of CSS selectors for configuring the application's
* {@link DragDrop} behaviour.
* @property {SearchFilterConfiguration[]} filters An array of {@link SearchFilter} configuration objects.
*/
/**
* The standard application window that is rendered for a large variety of UI elements in Foundry VTT.
* @abstract
* @param {ApplicationOptions} [options] Configuration options which control how the application is rendered.
* Application subclasses may add additional supported options, but these base
* configurations are supported for all Applications. The values passed to the
* constructor are combined with the defaultOptions defined at the class level.
*/
class Application {
constructor(options={}) {
/**
* The options provided to this application upon initialization
* @type {object}
*/
this.options = foundry.utils.mergeObject(this.constructor.defaultOptions, options, {
insertKeys: true,
insertValues: true,
overwrite: true,
inplace: false
});
/**
* The application ID is a unique incrementing integer which is used to identify every application window
* drawn by the VTT
* @type {number}
*/
this.appId = _appId += 1;
/**
* An internal reference to the HTML element this application renders
* @type {jQuery}
*/
this._element = null;
/**
* Track the current position and dimensions of the Application UI
* @type {object}
*/
this.position = {
width: this.options.width,
height: this.options.height,
left: this.options.left,
top: this.options.top,
scale: this.options.scale,
zIndex: 0
};
/**
* DragDrop workflow handlers which are active for this Application
* @type {DragDrop[]}
*/
this._dragDrop = this._createDragDropHandlers();
/**
* Tab navigation handlers which are active for this Application
* @type {Tabs[]}
*/
this._tabs = this._createTabHandlers();
/**
* SearchFilter handlers which are active for this Application
* @type {SearchFilter[]}
*/
this._searchFilters = this._createSearchFilters();
/**
* Track whether the Application is currently minimized
* @type {boolean|null}
*/
this._minimized = false;
/**
* The current render state of the Application
* @see {Application.RENDER_STATES}
* @type {number}
* @protected
*/
this._state = Application.RENDER_STATES.NONE;
/**
* The prior render state of this Application.
* This allows for rendering logic to understand if the application is being rendered for the first time.
* @see {Application.RENDER_STATES}
* @type {number}
* @protected
*/
this._priorState = this._state;
/**
* Track the most recent scroll positions for any vertically scrolling containers
* @type {object | null}
*/
this._scrollPositions = null;
}
/**
* The sequence of rendering states that track the Application life-cycle.
* @enum {number}
*/
static RENDER_STATES = Object.freeze({
CLOSING: -2,
CLOSED: -1,
NONE: 0,
RENDERING: 1,
RENDERED: 2,
ERROR: 3
});
/* -------------------------------------------- */
/**
* Create drag-and-drop workflow handlers for this Application
* @returns {DragDrop[]} An array of DragDrop handlers
* @private
*/
_createDragDropHandlers() {
return this.options.dragDrop.map(d => {
d.permissions = {
dragstart: this._canDragStart.bind(this),
drop: this._canDragDrop.bind(this)
};
d.callbacks = {
dragstart: this._onDragStart.bind(this),
dragover: this._onDragOver.bind(this),
drop: this._onDrop.bind(this)
};
return new DragDrop(d);
});
}
/* -------------------------------------------- */
/**
* Create tabbed navigation handlers for this Application
* @returns {Tabs[]} An array of Tabs handlers
* @private
*/
_createTabHandlers() {
return this.options.tabs.map(t => {
t.callback = this._onChangeTab.bind(this);
return new Tabs(t);
});
}
/* -------------------------------------------- */
/**
* Create search filter handlers for this Application
* @returns {SearchFilter[]} An array of SearchFilter handlers
* @private
*/
_createSearchFilters() {
return this.options.filters.map(f => {
f.callback = this._onSearchFilter.bind(this);
return new SearchFilter(f);
});
}
/* -------------------------------------------- */
/**
* Assign the default options configuration which is used by this Application class. The options and values defined
* in this object are merged with any provided option values which are passed to the constructor upon initialization.
* Application subclasses may include additional options which are specific to their usage.
* @returns {ApplicationOptions}
*/
static get defaultOptions() {
return {
baseApplication: null,
width: null,
height: null,
top: null,
left: null,
scale: null,
popOut: true,
minimizable: true,
resizable: false,
id: "",
classes: [],
dragDrop: [],
tabs: [],
filters: [],
title: "",
template: null,
scrollY: []
};
}
/* -------------------------------------------- */
/**
* Return the CSS application ID which uniquely references this UI element
* @type {string}
*/
get id() {
return this.options.id ? this.options.id : `app-${this.appId}`;
}
/* -------------------------------------------- */
/**
* Return the active application element, if it currently exists in the DOM
* @type {jQuery}
*/
get element() {
if ( this._element ) return this._element;
let selector = `#${this.id}`;
return $(selector);
}
/* -------------------------------------------- */
/**
* The path to the HTML template file which should be used to render the inner content of the app
* @type {string}
*/
get template() {
return this.options.template;
}
/* -------------------------------------------- */
/**
* Control the rendering style of the application. If popOut is true, the application is rendered in its own
* wrapper window, otherwise only the inner app content is rendered
* @type {boolean}
*/
get popOut() {
return this.options.popOut ?? true;
}
/* -------------------------------------------- */
/**
* Return a flag for whether the Application instance is currently rendered
* @type {boolean}
*/
get rendered() {
return this._state === Application.RENDER_STATES.RENDERED;
}
/* -------------------------------------------- */
/**
* Whether the Application is currently closing.
* @type {boolean}
*/
get closing() {
return this._state === Application.RENDER_STATES.CLOSING;
}
/* -------------------------------------------- */
/**
* An Application window should define its own title definition logic which may be dynamic depending on its data
* @type {string}
*/
get title() {
return game.i18n.localize(this.options.title);
}
/* -------------------------------------------- */
/* Application rendering
/* -------------------------------------------- */
/**
* An application should define the data object used to render its template.
* This function may either return an Object directly, or a Promise which resolves to an Object
* If undefined, the default implementation will return an empty object allowing only for rendering of static HTML
* @param {object} options
* @returns {object|Promise<object>}
*/
getData(options={}) {
return {};
}
/* -------------------------------------------- */
/**
* Render the Application by evaluating it's HTML template against the object of data provided by the getData method
* If the Application is rendered as a pop-out window, wrap the contained HTML in an outer frame with window controls
*
* @param {boolean} force Add the rendered application to the DOM if it is not already present. If false, the
* Application will only be re-rendered if it is already present.
* @param {object} options Additional rendering options which are applied to customize the way that the Application
* is rendered in the DOM.
*
* @param {number} [options.left] The left positioning attribute
* @param {number} [options.top] The top positioning attribute
* @param {number} [options.width] The rendered width
* @param {number} [options.height] The rendered height
* @param {number} [options.scale] The rendered transformation scale
* @param {boolean} [options.focus=false] Apply focus to the application, maximizing it and bringing it to the top
* of the vertical stack.
* @param {string} [options.renderContext] A context-providing string which suggests what event triggered the render
* @param {object} [options.renderData] The data change which motivated the render request
*
* @returns {Application} The rendered Application instance
*
*/
render(force=false, options={}) {
this._render(force, options).catch(err => {
this._state = Application.RENDER_STATES.ERROR;
Hooks.onError("Application#render", err, {
msg: `An error occurred while rendering ${this.constructor.name} ${this.appId}`,
log: "error",
...options
});
});
return this;
}
/* -------------------------------------------- */
/**
* An asynchronous inner function which handles the rendering of the Application
* @fires renderApplication
* @param {boolean} force Render and display the application even if it is not currently displayed.
* @param {object} options Additional options which update the current values of the Application#options object
* @returns {Promise<void>} A Promise that resolves to the Application once rendering is complete
* @protected
*/
async _render(force=false, options={}) {
// Do not render under certain conditions
const states = Application.RENDER_STATES;
this._priorState = this._state;
if ( [states.CLOSING, states.RENDERING].includes(this._state) ) return;
// Applications which are not currently rendered must be forced
if ( !force && (this._state <= states.NONE) ) return;
// Begin rendering the application
if ( [states.NONE, states.CLOSED, states.ERROR].includes(this._state) ) {
console.log(`${vtt} | Rendering ${this.constructor.name}`);
}
this._state = states.RENDERING;
// Merge provided options with those supported by the Application class
foundry.utils.mergeObject(this.options, options, { insertKeys: false });
options.focus ??= force;
// Get the existing HTML element and application data used for rendering
const element = this.element;
const data = await this.getData(this.options);
// Store scroll positions
if ( element.length && this.options.scrollY ) this._saveScrollPositions(element);
// Render the inner content
const inner = await this._renderInner(data);
let html = inner;
// If the application already exists in the DOM, replace the inner content
if ( element.length ) this._replaceHTML(element, html);
// Otherwise render a new app
else {
// Wrap a popOut application in an outer frame
if ( this.popOut ) {
html = await this._renderOuter();
html.find(".window-content").append(inner);
ui.windows[this.appId] = this;
}
// Add the HTML to the DOM and record the element
this._injectHTML(html);
}
if ( !this.popOut && this.options.resizable ) new Draggable(this, html, false, this.options.resizable);
// Activate event listeners on the inner HTML
this._activateCoreListeners(inner);
this.activateListeners(inner);
// Set the application position (if it's not currently minimized)
if ( !this._minimized ) {
foundry.utils.mergeObject(this.position, options, {insertKeys: false});
this.setPosition(this.position);
}
// Apply focus to the application, maximizing it and bringing it to the top
if ( this.popOut && (options.focus === true) ) this.maximize().then(() => this.bringToTop());
// Dispatch Hooks for rendering the base and subclass applications
for ( let cls of this.constructor._getInheritanceChain() ) {
Hooks.callAll(`render${cls.name}`, this, html, data);
}
// Restore prior scroll positions
if ( this.options.scrollY ) this._restoreScrollPositions(html);
this._state = states.RENDERED;
}
/* -------------------------------------------- */
/**
* Return the inheritance chain for this Application class up to (and including) it's base Application class.
* @returns {Function[]}
* @private
*/
static _getInheritanceChain() {
const parents = foundry.utils.getParentClasses(this);
const base = this.defaultOptions.baseApplication;
const chain = [this];
for ( let cls of parents ) {
chain.push(cls);
if ( cls.name === base ) break;
}
return chain;
}
/* -------------------------------------------- */
/**
* Persist the scroll positions of containers within the app before re-rendering the content
* @param {jQuery} html The HTML object being traversed
* @protected
*/
_saveScrollPositions(html) {
const selectors = this.options.scrollY || [];
this._scrollPositions = selectors.reduce((pos, sel) => {
const el = html.find(sel);
pos[sel] = Array.from(el).map(el => el.scrollTop);
return pos;
}, {});
}
/* -------------------------------------------- */
/**
* Restore the scroll positions of containers within the app after re-rendering the content
* @param {jQuery} html The HTML object being traversed
* @protected
*/
_restoreScrollPositions(html) {
const selectors = this.options.scrollY || [];
const positions = this._scrollPositions || {};
for ( let sel of selectors ) {
const el = html.find(sel);
el.each((i, el) => el.scrollTop = positions[sel]?.[i] || 0);
}
}
/* -------------------------------------------- */
/**
* Render the outer application wrapper
* @returns {Promise<jQuery>} A promise resolving to the constructed jQuery object
* @protected
*/
async _renderOuter() {
// Gather basic application data
const classes = this.options.classes;
const windowData = {
id: this.id,
classes: classes.join(" "),
appId: this.appId,
title: this.title,
headerButtons: this._getHeaderButtons()
};
// Render the template and return the promise
let html = await renderTemplate("templates/app-window.html", windowData);
html = $(html);
// Activate header button click listeners after a slight timeout to prevent immediate interaction
setTimeout(() => {
html.find(".header-button").click(event => {
event.preventDefault();
const button = windowData.headerButtons.find(b => event.currentTarget.classList.contains(b.class));
button.onclick(event);
});
}, 500);
// Make the outer window draggable
const header = html.find("header")[0];
new Draggable(this, html, header, this.options.resizable);
// Make the outer window minimizable
if ( this.options.minimizable ) {
header.addEventListener("dblclick", this._onToggleMinimize.bind(this));
}
// Set the outer frame z-index
if ( Object.keys(ui.windows).length === 0 ) _maxZ = 100 - 1;
this.position.zIndex = Math.min(++_maxZ, 9999);
html.css({zIndex: this.position.zIndex});
ui.activeWindow = this;
// Return the outer frame
return html;
}
/* -------------------------------------------- */
/**
* Render the inner application content
* @param {object} data The data used to render the inner template
* @returns {Promise<jQuery>} A promise resolving to the constructed jQuery object
* @private
*/
async _renderInner(data) {
let html = await renderTemplate(this.template, data);
if ( html === "" ) throw new Error(`No data was returned from template ${this.template}`);
return $(html);
}
/* -------------------------------------------- */
/**
* Customize how inner HTML is replaced when the application is refreshed
* @param {jQuery} element The original HTML processed as a jQuery object
* @param {jQuery} html New updated HTML as a jQuery object
* @private
*/
_replaceHTML(element, html) {
if ( !element.length ) return;
// For pop-out windows update the inner content and the window title
if ( this.popOut ) {
element.find(".window-content").html(html);
let t = element.find(".window-title")[0];
if ( t.hasChildNodes() ) t = t.childNodes[0];
t.textContent = this.title;
}
// For regular applications, replace the whole thing
else {
element.replaceWith(html);
this._element = html;
}
}
/* -------------------------------------------- */
/**
* Customize how a new HTML Application is added and first appears in the DOM
* @param {jQuery} html The HTML element which is ready to be added to the DOM
* @private
*/
_injectHTML(html) {
$("body").append(html);
this._element = html;
html.hide().fadeIn(200);
}
/* -------------------------------------------- */
/**
* Specify the set of config buttons which should appear in the Application header.
* Buttons should be returned as an Array of objects.
* The header buttons which are added to the application can be modified by the getApplicationHeaderButtons hook.
* @fires getApplicationHeaderButtons
* @returns {ApplicationHeaderButton[]}
* @protected
*/
_getHeaderButtons() {
const buttons = [
{
label: "Close",
class: "close",
icon: "fas fa-times",
onclick: () => this.close()
}
];
for ( let cls of this.constructor._getInheritanceChain() ) {
Hooks.call(`get${cls.name}HeaderButtons`, this, buttons);
}
return buttons;
}
/* -------------------------------------------- */
/**
* Create a {@link ContextMenu} for this Application.
* @param {jQuery} html The Application's HTML.
* @private
*/
_contextMenu(html) {}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/**
* Activate required listeners which must be enabled on every Application.
* These are internal interactions which should not be overridden by downstream subclasses.
* @param {jQuery} html
* @protected
*/
_activateCoreListeners(html) {
const content = this.popOut ? html[0].parentElement : html[0];
this._tabs.forEach(t => t.bind(content));
this._dragDrop.forEach(d => d.bind(content));
this._searchFilters.forEach(f => f.bind(content));
}
/* -------------------------------------------- */
/**
* After rendering, activate event listeners which provide interactivity for the Application.
* This is where user-defined Application subclasses should attach their event-handling logic.
* @param {JQuery} html
*/
activateListeners(html) {}
/* -------------------------------------------- */
/**
* Change the currently active tab
* @param {string} tabName The target tab name to switch to
* @param {object} options Options which configure changing the tab
* @param {string} options.group A specific named tab group, useful if multiple sets of tabs are present
* @param {boolean} options.triggerCallback Whether to trigger tab-change callback functions
*/
activateTab(tabName, {group, triggerCallback=true}={}) {
if ( !this._tabs.length ) throw new Error(`${this.constructor.name} does not define any tabs`);
const tabs = group ? this._tabs.find(t => t.group === group) : this._tabs[0];
if ( !tabs ) throw new Error(`Tab group "${group}" not found in ${this.constructor.name}`);
tabs.activate(tabName, {triggerCallback});
}
/* -------------------------------------------- */
/**
* Handle changes to the active tab in a configured Tabs controller
* @param {MouseEvent|null} event A left click event
* @param {Tabs} tabs The Tabs controller
* @param {string} active The new active tab name
* @protected
*/
_onChangeTab(event, tabs, active) {
this.setPosition();
}
/* -------------------------------------------- */
/**
* Handle changes to search filtering controllers which are bound to the Application
* @param {KeyboardEvent} event The key-up event from keyboard input
* @param {string} query The raw string input to the search field
* @param {RegExp} rgx The regular expression to test against
* @param {HTMLElement} html The HTML element which should be filtered
* @protected
*/
_onSearchFilter(event, query, rgx, html) {}
/* -------------------------------------------- */
/**
* Define whether a user is able to begin a dragstart workflow for a given drag selector
* @param {string} selector The candidate HTML selector for dragging
* @returns {boolean} Can the current user drag this selector?
* @protected
*/
_canDragStart(selector) {
return game.user.isGM;
}
/* -------------------------------------------- */
/**
* Define whether a user is able to conclude a drag-and-drop workflow for a given drop selector
* @param {string} selector The candidate HTML selector for the drop target
* @returns {boolean} Can the current user drop on this selector?
* @protected
*/
_canDragDrop(selector) {
return game.user.isGM;
}
/* -------------------------------------------- */
/**
* Callback actions which occur at the beginning of a drag start workflow.
* @param {DragEvent} event The originating DragEvent
* @protected
*/
_onDragStart(event) {}
/* -------------------------------------------- */
/**
* Callback actions which occur when a dragged element is over a drop target.
* @param {DragEvent} event The originating DragEvent
* @protected
*/
_onDragOver(event) {}
/* -------------------------------------------- */
/**
* Callback actions which occur when a dragged element is dropped on a target.
* @param {DragEvent} event The originating DragEvent
* @protected
*/
_onDrop(event) {}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Bring the application to the top of the rendering stack
*/
bringToTop() {
const element = this.element[0];
const z = document.defaultView.getComputedStyle(element).zIndex;
if ( z < _maxZ ) {
this.position.zIndex = Math.min(++_maxZ, 99999);
element.style.zIndex = this.position.zIndex;
ui.activeWindow = this;
}
}
/* -------------------------------------------- */
/**
* Close the application and un-register references to it within UI mappings
* This function returns a Promise which resolves once the window closing animation concludes
* @fires closeApplication
* @param {object} [options={}] Options which affect how the Application is closed
* @returns {Promise<void>} A Promise which resolves once the application is closed
*/
async close(options={}) {
const states = Application.RENDER_STATES;
if ( !options.force && ![states.RENDERED, states.ERROR].includes(this._state) ) return;
this._state = states.CLOSING;
// Get the element
let el = this.element;
if ( !el ) return this._state = states.CLOSED;
el.css({minHeight: 0});
// Dispatch Hooks for closing the base and subclass applications
for ( let cls of this.constructor._getInheritanceChain() ) {
Hooks.call(`close${cls.name}`, this, el);
}
// Animate closing the element
return new Promise(resolve => {
el.slideUp(200, () => {
el.remove();
// Clean up data
this._element = null;
delete ui.windows[this.appId];
this._minimized = false;
this._scrollPositions = null;
this._state = states.CLOSED;
resolve();
});
});
}
/* -------------------------------------------- */
/**
* Minimize the pop-out window, collapsing it to a small tab
* Take no action for applications which are not of the pop-out variety or apps which are already minimized
* @returns {Promise<void>} A Promise which resolves once the minimization action has completed
*/
async minimize() {
if ( !this.rendered || !this.popOut || [true, null].includes(this._minimized) ) return;
this._minimized = null;
// Get content
const window = this.element;
const header = window.find(".window-header");
const content = window.find(".window-content");
this._saveScrollPositions(window);
// Remove minimum width and height styling rules
window.css({minWidth: 100, minHeight: 30});
// Slide-up content
content.slideUp(100);
// Slide up window height
return new Promise(resolve => {
window.animate({height: `${header[0].offsetHeight+1}px`}, 100, () => {
window.animate({width: MIN_WINDOW_WIDTH}, 100, () => {
window.addClass("minimized");
this._minimized = true;
resolve();
});
});
});
}
/* -------------------------------------------- */
/**
* Maximize the pop-out window, expanding it to its original size
* Take no action for applications which are not of the pop-out variety or are already maximized
* @returns {Promise<void>} A Promise which resolves once the maximization action has completed
*/
async maximize() {
if ( !this.popOut || [false, null].includes(this._minimized) ) return;
this._minimized = null;
// Get content
let window = this.element;
let content = window.find(".window-content");
// Expand window
return new Promise(resolve => {
window.animate({width: this.position.width, height: this.position.height}, 100, () => {
content.slideDown(100, () => {
window.removeClass("minimized");
this._minimized = false;
window.css({minWidth: "", minHeight: ""}); // Remove explicit dimensions
content.css({display: ""}); // Remove explicit "block" display
this.setPosition(this.position);
this._restoreScrollPositions(window);
resolve();
});
});
});
}
/* -------------------------------------------- */
/**
* Set the application position and store its new location.
* Returns the updated position object for the application containing the new values.
* @param {object} position Positional data
* @param {number|null} position.left The left offset position in pixels
* @param {number|null} position.top The top offset position in pixels
* @param {number|null} position.width The application width in pixels
* @param {number|string|null} position.height The application height in pixels
* @param {number|null} position.scale The application scale as a numeric factor where 1.0 is default
* @returns {{left: number, top: number, width: number, height: number, scale:number}|void}
*/
setPosition({left, top, width, height, scale}={}) {
if ( !this.popOut && !this.options.resizable ) return; // Only configure position for popout or resizable apps.
const el = this.element[0];
const currentPosition = this.position;
const pop = this.popOut;
const styles = window.getComputedStyle(el);
if ( scale === null ) scale = 1;
scale = scale ?? currentPosition.scale ?? 1;
// If Height is "auto" unset current preference
if ( (height === "auto") || (this.options.height === "auto") ) {
el.style.height = "";
height = null;
}
// Update width if an explicit value is passed, or if no width value is set on the element
if ( !el.style.width || width ) {
const tarW = width || el.offsetWidth;
const minW = parseInt(styles.minWidth) || (pop ? MIN_WINDOW_WIDTH : 0);
const maxW = el.style.maxWidth || (window.innerWidth / scale);
currentPosition.width = width = Math.clamped(tarW, minW, maxW);
el.style.width = `${width}px`;
if ( ((width * scale) + currentPosition.left) > window.innerWidth ) left = currentPosition.left;
}
width = el.offsetWidth;
// Update height if an explicit value is passed, or if no height value is set on the element
if ( !el.style.height || height ) {
const tarH = height || (el.offsetHeight + 1);
const minH = parseInt(styles.minHeight) || (pop ? MIN_WINDOW_HEIGHT : 0);
const maxH = el.style.maxHeight || (window.innerHeight / scale);
currentPosition.height = height = Math.clamped(tarH, minH, maxH);
el.style.height = `${height}px`;
if ( ((height * scale) + currentPosition.top) > window.innerHeight + 1 ) top = currentPosition.top - 1;
}
height = el.offsetHeight;
// Update Left
if ( (pop && !el.style.left) || Number.isFinite(left) ) {
const scaledWidth = width * scale;
const tarL = Number.isFinite(left) ? left : (window.innerWidth - scaledWidth) / 2;
const maxL = Math.max(window.innerWidth - scaledWidth, 0);
currentPosition.left = left = Math.clamped(tarL, 0, maxL);
el.style.left = `${left}px`;
}
// Update Top
if ( (pop && !el.style.top) || Number.isFinite(top) ) {
const scaledHeight = height * scale;
const tarT = Number.isFinite(top) ? top : (window.innerHeight - scaledHeight) / 2;
const maxT = Math.max(window.innerHeight - scaledHeight, 0);
currentPosition.top = Math.clamped(tarT, 0, maxT);
el.style.top = `${currentPosition.top}px`;
}
// Update Scale
if ( scale ) {
currentPosition.scale = Math.max(scale, 0);
if ( scale === 1 ) el.style.transform = "";
else el.style.transform = `scale(${scale})`;
}
// Return the updated position object
return currentPosition;
}
/* -------------------------------------------- */
/**
* Handle application minimization behavior - collapsing content and reducing the size of the header
* @param {Event} ev
* @private
*/
_onToggleMinimize(ev) {
ev.preventDefault();
if ( this._minimized ) this.maximize(ev);
else this.minimize(ev);
}
/* -------------------------------------------- */
/**
* Additional actions to take when the application window is resized
* @param {Event} event
* @private
*/
_onResize(event) {}
/* -------------------------------------------- */
/**
* Wait for any images present in the Application to load.
* @returns {Promise<void>} A Promise that resolves when all images have loaded.
* @protected
*/
_waitForImages() {
return new Promise(resolve => {
let loaded = 0;
const images = Array.from(this.element.find("img")).filter(img => !img.complete);
if ( !images.length ) resolve();
for ( const img of images ) {
img.onload = img.onerror = () => {
loaded++;
img.onload = img.onerror = null;
if ( loaded >= images.length ) resolve();
};
}
});
}
}

View File

@@ -0,0 +1,176 @@
/**
* Audio/Video Conferencing Configuration Sheet
* @extends {FormApplication}
*
* @param {AVMaster} object The {@link AVMaster} instance being configured.
* @param {FormApplicationOptions} [options] Application configuration options.
*/
class AVConfig extends FormApplication {
constructor(object, options) {
super(object || game.webrtc, options);
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
title: game.i18n.localize("WEBRTC.Title"),
id: "av-config",
template: "templates/sidebar/apps/av-config.html",
popOut: true,
width: 480,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "general"}]
});
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const settings = this.object.settings;
const videoSources = await this.object.client.getVideoSources();
const audioSources = await this.object.client.getAudioSources();
const audioSinks = await this.object.client.getAudioSinks();
// If the currently chosen device is unavailable, display a separate option for 'unavailable device (use default)'
const { videoSrc, audioSrc, audioSink } = settings.client;
const videoSrcUnavailable = this._isSourceUnavailable(videoSources, videoSrc);
const audioSrcUnavailable = this._isSourceUnavailable(audioSources, audioSrc);
const audioSinkUnavailable = this._isSourceUnavailable(audioSinks, audioSink);
const isSSL = window.location.protocol === "https:";
// Audio/Video modes
const modes = {
[AVSettings.AV_MODES.DISABLED]: "WEBRTC.ModeDisabled",
[AVSettings.AV_MODES.AUDIO]: "WEBRTC.ModeAudioOnly",
[AVSettings.AV_MODES.VIDEO]: "WEBRTC.ModeVideoOnly",
[AVSettings.AV_MODES.AUDIO_VIDEO]: "WEBRTC.ModeAudioVideo"
};
// Voice Broadcast modes
const voiceModes = Object.values(AVSettings.VOICE_MODES).reduce((obj, m) => {
obj[m] = game.i18n.localize(`WEBRTC.VoiceMode${m.titleCase()}`);
return obj;
}, {});
// Nameplate settings.
const nameplates = {
[AVSettings.NAMEPLATE_MODES.OFF]: "WEBRTC.NameplatesOff",
[AVSettings.NAMEPLATE_MODES.PLAYER_ONLY]: "WEBRTC.NameplatesPlayer",
[AVSettings.NAMEPLATE_MODES.CHAR_ONLY]: "WEBRTC.NameplatesCharacter",
[AVSettings.NAMEPLATE_MODES.BOTH]: "WEBRTC.NameplatesBoth"
};
const dockPositions = Object.fromEntries(Object.values(AVSettings.DOCK_POSITIONS).map(p => {
return [p, game.i18n.localize(`WEBRTC.DockPosition${p.titleCase()}`)];
}));
// Return data to the template
return {
user: game.user,
modes,
voiceModes,
serverTypes: {FVTT: "WEBRTC.FVTTSignalingServer", custom: "WEBRTC.CustomSignalingServer"},
turnTypes: {server: "WEBRTC.TURNServerProvisioned", custom: "WEBRTC.CustomTURNServer"},
settings,
canSelectMode: game.user.isGM && isSSL,
noSSL: !isSSL,
videoSources,
audioSources,
audioSinks: foundry.utils.isEmpty(audioSinks) ? false : audioSinks,
videoSrcUnavailable,
audioSrcUnavailable,
audioSinkUnavailable,
audioDisabled: audioSrc === "disabled",
videoDisabled: videoSrc === "disabled",
nameplates,
nameplateSetting: settings.client.nameplates ?? AVSettings.NAMEPLATE_MODES.BOTH,
dockPositions
};
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
// Options below are GM only
if ( !game.user.isGM ) return;
html.find('select[name="world.turn.type"]').change(this._onTurnTypeChanged.bind(this));
// Activate or de-activate the custom server and turn configuration sections based on current settings
const settings = this.object.settings;
this._setConfigSectionEnabled(".webrtc-custom-turn-config", settings.world.turn.type === "custom");
}
/* -------------------------------------------- */
/**
* Set a section's input to enabled or disabled
* @param {string} selector Selector for the section to enable or disable
* @param {boolean} enabled Whether to enable or disable this section
* @private
*/
_setConfigSectionEnabled(selector, enabled = true) {
let section = this.element.find(selector);
if (section) {
section.css("opacity", enabled ? 1.0 : 0.5);
section.find("input").prop("disabled", !enabled);
}
}
/* -------------------------------------------- */
/**
* Determine whether a given video or audio source, or audio sink has become
* unavailable since the last time it was set.
* @param {object} sources The available devices
* @param {string} source The selected device
* @private
*/
_isSourceUnavailable(sources, source) {
const specialValues = ["default", "disabled"];
return source && (!specialValues.includes(source)) && !Object.keys(sources).includes(source);
}
/* -------------------------------------------- */
/**
* Callback when the turn server type changes
* Will enable or disable the turn section based on whether the user selected a custom turn or not
* @param {Event} event The event that triggered the turn server type change
* @private
*/
_onTurnTypeChanged(event) {
event.preventDefault();
const choice = event.currentTarget.value;
this._setConfigSectionEnabled(".webrtc-custom-turn-config", choice === "custom")
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
const settings = game.webrtc.settings;
settings.client.videoSrc = settings.client.videoSrc || null;
settings.client.audioSrc = settings.client.audioSrc || null;
const update = expandObject(formData);
// Update world settings
if ( game.user.isGM ) {
if ( settings.world.mode !== update.world.mode ) SettingsConfig.reloadConfirm({world: true});
const world = mergeObject(settings.world, update.world);
await game.settings.set("core", "rtcWorldSettings", world);
}
// Update client settings
const client = mergeObject(settings.client, update.client);
await game.settings.set("core", "rtcClientSettings", client);
}
}

View File

@@ -0,0 +1,68 @@
/**
* Abstraction of the Application interface to be used with the Draggable class as a substitute for the app
* This class will represent one popout feed window and handle its positioning and draggability
* @param {CameraViews} view The CameraViews application that this popout belongs to
* @param {string} userId ID of the user this popout belongs to
* @param {jQuery} element The div element of this specific popout window
*/
class CameraPopoutAppWrapper {
constructor(view, userId, element) {
this.view = view;
this.element = element;
this.userId = userId;
// "Fake" some application attributes
this.popOut = true;
this.options = {};
// Get the saved position
let setting = game.webrtc.settings.getUser(userId);
this.setPosition(setting);
new Draggable(this, element.find(".camera-view"), element.find(".video-container")[0], true);
}
/* -------------------------------------------- */
/**
* Get the current position of this popout window
*/
get position() {
return foundry.utils.mergeObject(this.element.position(), {
width: this.element.outerWidth(),
height: this.element.outerHeight(),
scale: 1
});
}
/* -------------------------------------------- */
/** @override */
setPosition(options={}) {
const position = Application.prototype.setPosition.call(this, options);
// Let the HTML renderer figure out the height based on width.
this.element[0].style.height = "";
if ( !foundry.utils.isEmpty(position) ) {
const current = game.webrtc.settings.client.users[this.userId] || {};
const update = foundry.utils.mergeObject(current, position);
game.webrtc.settings.set("client", `users.${this.userId}`, update);
}
return position;
}
/* -------------------------------------------- */
_onResize(event) {}
/* -------------------------------------------- */
/** @override */
bringToTop() {
let parent = this.element.parent();
let children = parent.children();
let lastElement = children[children.length - 1];
if (lastElement !== this.element[0]) {
game.webrtc.settings.set("client", `users.${this.userId}.z`, ++this.view.maxZ);
parent.append(this.element);
}
}
}

View File

@@ -0,0 +1,557 @@
/**
* The Camera UI View that displays all the camera feeds as individual video elements.
* @type {Application}
*
* @param {WebRTC} webrtc The WebRTC Implementation to display
* @param {ApplicationOptions} [options] Application configuration options.
*/
class CameraViews extends Application {
constructor(options={}) {
if ( !("width" in options) ) options.width = game.webrtc?.settings.client.dockWidth || 240;
super(options);
if ( game.webrtc?.settings.client.dockPosition === AVSettings.DOCK_POSITIONS.RIGHT ) {
this.options.resizable.rtl = true;
}
}
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "camera-views",
template: "templates/hud/camera-views.html",
popOut: false,
width: 240,
resizable: {selector: ".camera-view-width-control", resizeY: false}
});
}
/* -------------------------------------------- */
/**
* A reference to the master AV orchestrator instance
* @type {AVMaster}
*/
get webrtc() {
return game.webrtc;
}
/* -------------------------------------------- */
/**
* If all camera views are popped out, hide the dock.
* @type {boolean}
*/
get hidden() {
return this.webrtc.client.getConnectedUsers().reduce((hidden, u) => {
const settings = this.webrtc.settings.users[u];
return hidden && (settings.blocked || settings.popout);
}, true);
}
/* -------------------------------------------- */
/* Public API */
/* -------------------------------------------- */
/**
* Obtain a reference to the div.camera-view which is used to portray a given Foundry User.
* @param {string} userId The ID of the User document
* @return {HTMLElement|null}
*/
getUserCameraView(userId) {
return this.element.find(`.camera-view[data-user=${userId}]`)[0] || null;
}
/* -------------------------------------------- */
/**
* Obtain a reference to the video.user-camera which displays the video channel for a requested Foundry User.
* If the user is not broadcasting video this will return null.
* @param {string} userId The ID of the User document
* @return {HTMLVideoElement|null}
*/
getUserVideoElement(userId) {
return this.element.find(`.camera-view[data-user=${userId}] video.user-camera`)[0] || null;
}
/* -------------------------------------------- */
/**
* Sets whether a user is currently speaking or not
*
* @param {string} userId The ID of the user
* @param {boolean} speaking Whether the user is speaking
*/
setUserIsSpeaking(userId, speaking) {
const view = this.getUserCameraView(userId);
if ( view ) view.classList.toggle("speaking", speaking);
}
/* -------------------------------------------- */
/* Application Rendering */
/* -------------------------------------------- */
/**
* Extend the render logic to first check whether a render is necessary based on the context
* If a specific context was provided, make sure an update to the navigation is necessary before rendering
*/
render(force, context={}) {
const { renderContext, renderData } = context;
if ( this.webrtc.mode === AVSettings.AV_MODES.DISABLED ) return this;
if ( renderContext ) {
if ( renderContext !== "updateUser" ) return this;
const updateKeys = ["name", "permissions", "role", "active", "color", "sort", "character", "avatar"];
if ( !updateKeys.some(k => renderData.hasOwnProperty(k)) ) return this;
}
return super.render(force, context);
}
/* -------------------------------------------- */
/** @override */
async _render(force = false, options = {}) {
await super._render(force, options);
this.webrtc.onRender();
}
/* -------------------------------------------- */
/** @inheritdoc */
setPosition({left, top, width, scale} = {}) {
const position = super.setPosition({left, top, width, height: "auto", scale});
if ( foundry.utils.isEmpty(position) ) return position;
const clientSettings = game.webrtc.settings.client;
if ( game.webrtc.settings.verticalDock ) {
clientSettings.dockWidth = width;
game.webrtc.settings.set("client", "dockWidth", width);
}
return position;
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const settings = this.webrtc.settings;
const userSettings = settings.users;
// Get the sorted array of connected users
const connectedIds = this.webrtc.client.getConnectedUsers();
const users = connectedIds.reduce((users, u) => {
const data = this._getDataForUser(u, userSettings[u]);
if ( data && !userSettings[u].blocked ) users.push(data);
return users;
}, []);
users.sort(this.constructor._sortUsers);
// Maximum Z of all user popout windows
this.maxZ = Math.max(...users.map(u => userSettings[u.user.id].z));
// Define a dynamic class for the camera dock container which affects its rendered style
const dockClass = [`camera-position-${settings.client.dockPosition}`];
if ( !users.some(u => !u.settings.popout) ) dockClass.push("webrtc-dock-empty");
if ( settings.client.hideDock ) dockClass.push("webrtc-dock-minimized");
if ( this.hidden ) dockClass.push("hidden");
// Alter the body class depending on whether the players list is hidden
const playersVisible = !settings.client.hidePlayerList || settings.client.hideDock;
document.body.classList.toggle("players-hidden", playersVisible);
const nameplateModes = AVSettings.NAMEPLATE_MODES;
const nameplateSetting = settings.client.nameplates ?? nameplateModes.BOTH;
const nameplates = {
cssClass: [
nameplateSetting === nameplateModes.OFF ? "hidden" : "",
[nameplateModes.PLAYER_ONLY, nameplateModes.CHAR_ONLY].includes(nameplateSetting) ? "noanimate" : ""
].filterJoin(" "),
playerName: [nameplateModes.BOTH, nameplateModes.PLAYER_ONLY].includes(nameplateSetting),
charname: [nameplateModes.BOTH, nameplateModes.CHAR_ONLY].includes(nameplateSetting)
};
// Return data for rendering
return {
self: game.user,
muteAll: settings.muteAll,
borderColors: settings.client.borderColors,
dockClass: dockClass.join(" "),
hidden: this.hidden,
users, nameplates
};
}
/* -------------------------------------------- */
/**
* Prepare rendering data for a single user
* @private
*/
_getDataForUser(userId, settings) {
const user = game.users.get(userId);
if ( !user || !user.active ) return null;
const charname = user.character ? user.character.name.split(" ")[0] : "";
// CSS classes for the frame
const frameClass = settings.popout ? "camera-box-popout" : "camera-box-dock";
const audioClass = this.webrtc.canUserShareAudio(userId) ? null : "no-audio";
const videoClass = this.webrtc.canUserShareVideo(userId) ? null : "no-video";
// Return structured User data
return {
user, settings,
local: user.isSelf,
charname: user.isGM ? game.i18n.localize("GM") : charname,
volume: AudioHelper.volumeToInput(settings.volume),
cameraViewClass: [frameClass, videoClass, audioClass].filterJoin(" ")
};
}
/* -------------------------------------------- */
/**
* A custom sorting function that orders/arranges the user display frames
* @return {number}
* @private
*/
static _sortUsers(a, b) {
const as = a.settings;
const bs = b.settings;
if (as.popout && bs.popout) return as.z - bs.z; // Sort popouts by z-index
if (as.popout) return -1; // Show popout feeds first
if (bs.popout) return 1;
if (a.user.isSelf) return -1; // Show local feed first
if (b.user.isSelf) return 1;
if (a.hasVideo && !b.hasVideo) return -1; // Show remote users with a camera before those without
if (b.hasVideo && !a.hasVideo) return 1;
return a.user.sort - b.user.sort; // Sort according to user order
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
// Display controls when hovering over the video container
let cvh = this._onCameraViewHover.bind(this);
html.find(".camera-view").hover(cvh, cvh);
// Handle clicks on AV control buttons
html.find(".av-control").click(this._onClickControl.bind(this));
// Handle volume changes
html.find(".webrtc-volume-slider").change(this._onVolumeChange.bind(this));
// Handle user controls.
this._refreshView(html.find(".user-controls")[0]?.dataset.user);
// Hide Global permission icons depending on the A/V mode
const mode = this.webrtc.mode;
if ( mode === AVSettings.AV_MODES.VIDEO ) html.find('[data-action="toggle-audio"]').hide();
if ( mode === AVSettings.AV_MODES.AUDIO ) html.find('[data-action="toggle-video"]').hide();
// Make each popout window draggable
for ( let popout of this.element.find(".app.camera-view-popout") ) {
let box = popout.querySelector(".camera-view");
new CameraPopoutAppWrapper(this, box.dataset.user, $(popout));
}
// Listen to the video's srcObjectSet event to set the display mode of the user.
for ( let video of this.element.find("video") ) {
const view = video.closest(".camera-view");
this._refreshView(view.dataset.user);
video.addEventListener("webrtcVideoSet", ev => {
const view = video.closest(".camera-view");
if ( view.dataset.user !== ev.detail ) return;
this._refreshView(view.dataset.user);
});
}
}
/* -------------------------------------------- */
/**
* On hover in a camera container, show/hide the controls.
* @event {Event} event The original mouseover or mouseout hover event
* @private
*/
_onCameraViewHover(event) {
this._toggleControlVisibility(event.currentTarget, event.type === "mouseenter", null);
}
/* -------------------------------------------- */
/**
* On clicking on a toggle, disable/enable the audio or video stream.
* @event {MouseEvent} event The originating click event
* @private
*/
async _onClickControl(event) {
event.preventDefault();
// Reference relevant data
const button = event.currentTarget;
const action = button.dataset.action;
const userId = button.closest(".camera-view, .user-controls")?.dataset.user;
const user = game.users.get(userId);
const settings = this.webrtc.settings;
const userSettings = settings.getUser(user.id);
// Handle different actions
switch ( action ) {
// Globally block video
case "block-video":
if ( !game.user.isGM ) return;
await user.update({"permissions.BROADCAST_VIDEO": !userSettings.canBroadcastVideo});
return this._refreshView(userId);
// Globally block audio
case "block-audio":
if ( !game.user.isGM ) return;
await user.update({"permissions.BROADCAST_AUDIO": !userSettings.canBroadcastAudio});
return this._refreshView(userId);
// Hide the user
case "hide-user":
if ( user.isSelf ) return;
await settings.set("client", `users.${user.id}.blocked`, !userSettings.blocked);
return this.render();
// Toggle video display
case "toggle-video":
if ( !user.isSelf ) return;
if ( userSettings.hidden && !userSettings.canBroadcastVideo ) {
return ui.notifications.warn("WEBRTC.WarningCannotEnableVideo", {localize: true});
}
await settings.set("client", `users.${user.id}.hidden`, !userSettings.hidden);
return this._refreshView(userId);
// Toggle audio output
case "toggle-audio":
if ( !user.isSelf ) return;
if ( userSettings.muted && !userSettings.canBroadcastAudio ) {
return ui.notifications.warn("WEBRTC.WarningCannotEnableAudio", {localize: true});
}
await settings.set("client", `users.${user.id}.muted`, !userSettings.muted);
return this._refreshView(userId);
// Toggle mute all peers
case "mute-peers":
if ( !user.isSelf ) return;
await settings.set("client", "muteAll", !settings.client.muteAll);
return this._refreshView(userId);
// Disable sending and receiving video
case "disable-video":
if ( !user.isSelf ) return;
await settings.set("client", "disableVideo", !settings.client.disableVideo);
return this._refreshView(userId);
// Configure settings
case "configure":
return this.webrtc.config.render(true);
// Toggle popout
case "toggle-popout":
await settings.set("client", `users.${user.id}.popout`, !userSettings.popout);
return this.render();
// Hide players
case "toggle-players":
await settings.set("client", "hidePlayerList", !settings.client.hidePlayerList);
return this.render();
// Minimize the dock
case "toggle-dock":
await settings.set("client", "hideDock", !settings.client.hideDock);
return this.render();
}
}
/* -------------------------------------------- */
/**
* Change volume control for a stream
* @param {Event} event The originating change event from interaction with the range input
* @private
*/
_onVolumeChange(event) {
const input = event.currentTarget;
const box = input.closest(".camera-view");
const userId = box.dataset.user;
let volume = AudioHelper.inputToVolume(input.value);
box.getElementsByTagName("video")[0].volume = volume;
this.webrtc.settings.set("client", `users.${userId}.volume`, volume);
}
/* -------------------------------------------- */
/* Internal Helpers */
/* -------------------------------------------- */
/**
* Dynamically refresh the state of a single camera view
* @param {string} userId The ID of the user whose view we want to refresh.
* @protected
*/
_refreshView(userId) {
const view = this.element[0].querySelector(`.camera-view[data-user="${userId}"]`);
const isSelf = game.user.id === userId;
const clientSettings = game.webrtc.settings.client;
const userSettings = game.webrtc.settings.getUser(userId);
const minimized = clientSettings.hideDock;
const isVertical = game.webrtc.settings.verticalDock;
// Identify permissions
const cbv = game.webrtc.canUserBroadcastVideo(userId);
const csv = game.webrtc.canUserShareVideo(userId);
const cba = game.webrtc.canUserBroadcastAudio(userId);
const csa = game.webrtc.canUserShareAudio(userId);
// Refresh video display
const video = view.querySelector("video.user-camera");
const avatar = view.querySelector("img.user-avatar");
if ( video && avatar ) {
const showVideo = csv && (isSelf || !clientSettings.disableVideo) && (!minimized || userSettings.popout);
video.style.visibility = showVideo ? "visible" : "hidden";
video.style.display = showVideo ? "block" : "none";
avatar.style.display = showVideo ? "none" : "unset";
}
// Hidden and muted status icons
view.querySelector(".status-hidden")?.classList.toggle("hidden", csv);
view.querySelector(".status-muted")?.classList.toggle("hidden", csa);
// Volume bar and video output volume
if ( video ) {
video.volume = userSettings.volume;
video.muted = isSelf || clientSettings.muteAll; // Mute your own video
}
const volBar = this.element[0].querySelector(`[data-user="${userId}"] .volume-bar`);
if ( volBar ) {
const displayBar = (userId !== game.user.id) && cba;
volBar.style.display = displayBar ? "block" : "none";
volBar.disabled = !displayBar;
}
// Control toggle states
const actions = {
"block-video": {state: !cbv, display: game.user.isGM && !isSelf},
"block-audio": {state: !cba, display: game.user.isGM && !isSelf},
"hide-user": {state: !userSettings.blocked, display: !isSelf},
"toggle-video": {state: !csv, display: isSelf && !minimized},
"toggle-audio": {state: !csa, display: isSelf},
"mute-peers": {state: clientSettings.muteAll, display: isSelf},
"disable-video": {state: clientSettings.disableVideo, display: isSelf && !minimized},
"toggle-players": {state: !clientSettings.hidePlayerList, display: isSelf && !minimized && isVertical},
"toggle-dock": {state: !clientSettings.hideDock, display: isSelf}
};
const toggles = this.element[0].querySelectorAll(`[data-user="${userId}"] .av-control.toggle`);
for ( let button of toggles ) {
const action = button.dataset.action;
if ( !(action in actions) ) continue;
const state = actions[action].state;
const displayed = actions[action].display;
button.style.display = displayed ? "block" : "none";
button.enabled = displayed;
button.children[0].classList.remove(this._getToggleIcon(action, !state));
button.children[0].classList.add(this._getToggleIcon(action, state));
button.dataset.tooltip = this._getToggleTooltip(action, state);
}
}
/* -------------------------------------------- */
/**
* Render changes needed to the PlayerList ui.
* Show/Hide players depending on option.
* @private
*/
_setPlayerListVisibility() {
const hidePlayerList = this.webrtc.settings.client.hidePlayerList;
const players = document.getElementById("players");
const top = document.getElementById("ui-top");
if ( players ) players.classList.toggle("hidden", hidePlayerList);
if ( top ) top.classList.toggle("offset", !hidePlayerList);
}
/* -------------------------------------------- */
/**
* Get the icon class that should be used for various action buttons with different toggled states.
* The returned icon should represent the visual status of the NEXT state (not the CURRENT state).
*
* @param {string} action The named av-control button action
* @param {boolean} state The CURRENT action state.
* @returns {string} The icon that represents the NEXT action state.
* @protected
*/
_getToggleIcon(action, state) {
const clientSettings = game.webrtc.settings.client;
const dockPositions = AVSettings.DOCK_POSITIONS;
const dockIcons = {
[dockPositions.TOP]: {collapse: "down", expand: "up"},
[dockPositions.RIGHT]: {collapse: "left", expand: "right"},
[dockPositions.BOTTOM]: {collapse: "up", expand: "down"},
[dockPositions.LEFT]: {collapse: "right", expand: "left"}
}[clientSettings.dockPosition];
const actionMapping = {
"block-video": ["fa-video", "fa-video-slash"], // True means "blocked"
"block-audio": ["fa-microphone", "fa-microphone-slash"], // True means "blocked"
"hide-user": ["fa-eye", "fa-eye-slash"],
"toggle-video": ["fa-camera-web", "fa-camera-web-slash"], // True means "enabled"
"toggle-audio": ["fa-microphone", "fa-microphone-slash"], // True means "enabled"
"mute-peers": ["fa-volume-up", "fa-volume-mute"], // True means "muted"
"disable-video": ["fa-video", "fa-video-slash"],
"toggle-players": ["fa-caret-square-right", "fa-caret-square-left"], // True means "displayed"
"toggle-dock": [`fa-caret-square-${dockIcons.collapse}`, `fa-caret-square-${dockIcons.expand}`]
};
const icons = actionMapping[action];
return icons ? icons[state ? 1: 0] : null;
}
/* -------------------------------------------- */
/**
* Get the text title that should be used for various action buttons with different toggled states.
* The returned title should represent the tooltip of the NEXT state (not the CURRENT state).
*
* @param {string} action The named av-control button action
* @param {boolean} state The CURRENT action state.
* @returns {string} The icon that represents the NEXT action state.
* @protected
*/
_getToggleTooltip(action, state) {
const actionMapping = {
"block-video": ["BlockUserVideo", "AllowUserVideo"], // True means "blocked"
"block-audio": ["BlockUserAudio", "AllowUserAudio"], // True means "blocked"
"hide-user": ["ShowUser", "HideUser"],
"toggle-video": ["DisableMyVideo", "EnableMyVideo"], // True means "enabled"
"toggle-audio": ["DisableMyAudio", "EnableMyAudio"], // True means "enabled"
"mute-peers": ["MutePeers", "UnmutePeers"], // True means "muted"
"disable-video": ["DisableAllVideo", "EnableVideo"],
"toggle-players": ["ShowPlayers", "HidePlayers"], // True means "displayed"
"toggle-dock": ["ExpandDock", "MinimizeDock"]
};
const labels = actionMapping[action];
return game.i18n.localize(`WEBRTC.Tooltip${labels ? labels[state ? 1 : 0] : ""}`);
}
/* -------------------------------------------- */
/**
* Show or hide UI control elements
* This replaces the use of jquery.show/hide as it simply adds a class which has display:none
* which allows us to have elements with display:flex which can be hidden then shown without
* breaking their display style.
* This will show/hide the toggle buttons, volume controls and overlay sidebars
* @param {jQuery} container The container for which to show/hide control elements
* @param {boolean} show Whether to show or hide the controls
* @param {string} selector Override selector to specify which controls to show or hide
* @private
*/
_toggleControlVisibility(container, show, selector) {
selector = selector || `.control-bar`;
container.querySelectorAll(selector).forEach(c => c.classList.toggle("hidden", !show));
}
}

View File

@@ -0,0 +1,452 @@
/**
* 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.
* @abstract
* @internal
* @category - Custom HTML Elements
* @fires change
*/
class AbstractMultiSelectElement extends HTMLElement {
constructor() {
super();
this._initializeOptions();
}
/**
* The "change" event is emitted when the values of the multi-select element are changed.
* @param {Event} event A "change" event passed to event listeners.
* @event change
*/
static onChange;
/**
* 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 {Object<string, string>}
* @protected
*/
_choices = {};
/**
* An array of identifiers which have been chosen.
* @type {Set<string>}
* @protected
*/
_chosen = new Set();
/**
* The form this custom element belongs to, if any.
* @type {HTMLFormElement|null}
*/
form = null;
/**
* The bound form data handler method
* @type {Function|null}
*/
#formDataHandler = null;
/* -------------------------------------------- */
/**
* Preserve existing <option> and <optgroup> elements which are defined in the original HTML.
* @protected
*/
_initializeOptions() {
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._chosen.add(option.value);
option.selected = false;
}
}
}
/* -------------------------------------------- */
/**
* The name of the multi-select input element.
* @type {string}
*/
get name() {
return this.getAttribute("name");
}
set name(value) {
if ( !value || (typeof value !== "string") ) {
throw new Error("The name attribute of the multi-select element must be a non-empty string");
}
this.setAttribute("name", value);
}
/* -------------------------------------------- */
/**
* The values of the multi-select input are expressed as an array of strings.
* @type {string[]}
*/
get value() {
return Array.from(this._chosen);
}
set value(values) {
if ( !Array.isArray(values) ) {
throw new Error("The value assigned to a multi-select element must be an array.");
}
if ( values.some(v => !(v in this._choices)) ) {
throw new Error("The values assigned to a multi-select element must all be valid options.");
}
this._chosen.clear();
for ( const v of values ) this._chosen.add(v);
this.dispatchEvent(new Event("change"));
}
/* -------------------------------------------- */
/**
* Activate the custom element when it is attached to the DOM.
* @inheritDoc
*/
connectedCallback() {
this.replaceChildren();
const elements = this._buildElements();
this._refresh();
this.append(...elements);
this._activateListeners();
}
/* -------------------------------------------- */
/**
* Deactivate the custom element when it is detached from the DOM.
* @inheritDoc
*/
disconnectedCallback() {
if ( this.form ) {
delete this.form[this.name];
delete this.form.elements[this.name];
this.form.removeEventListener("formdata", this.#formDataHandler);
}
this.form = this.#formDataHandler = null;
}
/* -------------------------------------------- */
/**
* Mark a choice as selected.
* @param {string} value The value to add to the chosen set
*/
select(value) {
const exists = this._chosen.has(value);
if ( !exists ) {
if ( !(value in this._choices) ) {
throw new Error(`"${value}" is not an option allowed by this multi-select element`);
}
this._chosen.add(value);
this.dispatchEvent(new Event("change"));
this._refresh();
}
}
/* -------------------------------------------- */
/**
* Mark a choice as un-selected.
* @param {string} value The value to delete from the chosen set
*/
unselect(value) {
const exists = this._chosen.has(value);
if ( exists ) {
this._chosen.delete(value);
this.dispatchEvent(new Event("change"));
this._refresh();
}
}
/* -------------------------------------------- */
/**
* 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 by reflecting changes to the _chosen set.
* @protected
*/
_refresh() {}
/* -------------------------------------------- */
/**
* Activate event listeners which add dynamic behavior to the custom element.
* @protected
*/
_activateListeners() {
this.form = this.closest("form");
if ( this.form ) {
this.form[this.name] = this.form.elements[this.name] = this;
this.#formDataHandler = this.#onFormData.bind(this);
this.form.addEventListener("formdata", this.#formDataHandler);
}
}
/* -------------------------------------------- */
/**
* Add the value of the custom element to processed FormData.
* @param {FormDataEvent} event
*/
#onFormData(event) {
for ( const value of this._chosen ) {
event.formData.append(this.name, value);
}
}
}
/* -------------------------------------------- */
/**
* Provide a multi-select workflow using a select element as the input mechanism.
* @internal
* @category - Custom HTML Elements
*
* @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>
* ```
*/
class HTMLMultiSelectElement extends AbstractMultiSelectElement {
/**
* 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 = document.createElement("select");
this.#select.insertAdjacentHTML("afterbegin", '<option value=""></option>');
this.#select.append(...this._options);
// Create a div element for display
this.#tags = document.createElement("div");
this.#tags.classList.add("tags", "chosen");
return [this.#tags, this.#select];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
// Update the displayed tags
this.#tags.innerHTML = Array.from(this._chosen).map(id => {
return `<span class="tag" data-value="${id}">${this._choices[id]} <i class="fa-solid fa-times"></i></span>`;
}).join("");
// Disable selected options
for ( const option of this.#select.querySelectorAll("option") ) {
option.disabled = this._chosen.has(option.value);
}
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
super._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();
const tag = event.target.closest(".tag");
this.unselect(tag.dataset.value);
}
}
/* -------------------------------------------- */
/**
* Provide a multi-select workflow as a grid of input checkbox elements.
* @internal
* @category - Custom HTML 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>
* ```
*/
class HTMLMultiCheckboxElement extends AbstractMultiSelectElement {
/**
* 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._chosen.has(option.value);
label.append(checkbox, option.innerText);
this.#checkboxes.push(checkbox);
return label;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
for ( const checkbox of this.#checkboxes ) {
checkbox.checked = this._chosen.has(checkbox.value);
}
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
super._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);
}
}
// Register Custom Elements
window.customElements.define("multi-select", HTMLMultiSelectElement);
window.customElements.define("multi-checkbox", HTMLMultiCheckboxElement);

View File

@@ -0,0 +1,34 @@
/**
* A Dialog subclass which allows the user to configure export options for a Folder
* @extends {Dialog}
*/
class FolderExport extends Dialog {
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find('select[name="pack"]').change(this._onPackChange.bind(this));
}
/* -------------------------------------------- */
/**
* Handle changing the selected pack by updating the dropdown of folders available.
* @param {Event} event The input change event
*/
_onPackChange(event) {
const select = this.element.find('select[name="folder"]')[0];
const pack = game.packs.get(event.target.value);
if ( !pack ) {
select.disabled = true;
return;
}
const folders = pack._formatFolderSelectOptions();
select.disabled = folders.length === 0;
select.innerHTML = HandlebarsHelpers.selectOptions(folders, {hash: {
blank: "",
nameAttr: "id",
labelAttr: "name"
}});
}
}

View File

@@ -0,0 +1,877 @@
/**
* @typedef {ApplicationOptions} FormApplicationOptions
* @property {boolean} [closeOnSubmit=true] Whether to automatically close the application when it's contained
* form is submitted.
* @property {boolean} [submitOnChange=false] Whether to automatically submit the contained HTML form when an input
* or select element is changed.
* @property {boolean} [submitOnClose=false] Whether to automatically submit the contained HTML form when the
* application window is manually closed.
* @property {boolean} [editable=true] Whether the application form is editable - if true, it's fields will
* be unlocked and the form can be submitted. If false, all form fields
* will be disabled and the form cannot be submitted.
* @property {boolean} [sheetConfig=false] Support configuration of the sheet type used for this application.
*/
/**
* An abstract pattern for defining an Application responsible for updating some object using an HTML form
*
* A few critical assumptions:
* 1) This application is used to only edit one object at a time
* 2) The template used contains one (and only one) HTML form as it's outer-most element
* 3) This abstract layer has no knowledge of what is being updated, so the implementation must define _updateObject
*
* @extends {Application}
* @abstract
* @interface
*
* @param {object} object Some object which is the target data structure to be updated by the form.
* @param {FormApplicationOptions} [options] Additional options which modify the rendering of the sheet.
*/
class FormApplication extends Application {
constructor(object={}, options={}) {
super(options);
/**
* The object target which we are using this form to modify
* @type {*}
*/
this.object = object;
/**
* A convenience reference to the form HTMLElement
* @type {HTMLElement}
*/
this.form = null;
/**
* Keep track of any FilePicker instances which are associated with this form
* The values of this Array are inner-objects with references to the FilePicker instances and other metadata
* @type {FilePicker[]}
*/
this.filepickers = [];
/**
* Keep track of any mce editors which may be active as part of this form
* The values of this object are inner-objects with references to the MCE editor and other metadata
* @type {Object<string, object>}
*/
this.editors = {};
}
/* -------------------------------------------- */
/**
* Assign the default options which are supported by the document edit sheet.
* In addition to the default options object supported by the parent Application class, the Form Application
* supports the following additional keys and values:
*
* @returns {FormApplicationOptions} The default options for this FormApplication class
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["form"],
closeOnSubmit: true,
editable: true,
sheetConfig: false,
submitOnChange: false,
submitOnClose: false
});
}
/* -------------------------------------------- */
/**
* Is the Form Application currently editable?
* @type {boolean}
*/
get isEditable() {
return this.options.editable;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* @inheritdoc
* @returns {object|Promise<object>}
*/
getData(options={}) {
return {
object: this.object,
options: this.options,
title: this.title
};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
// Identify the focused element
let focus = this.element.find(":focus");
focus = focus.length ? focus[0] : null;
// Render the application and restore focus
await super._render(force, options);
if ( focus && focus.name ) {
const input = this.form?.[focus.name];
if ( input && (input.focus instanceof Function) ) input.focus();
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
const html = await super._renderInner(...args);
this.form = html.filter((i, el) => el instanceof HTMLFormElement)[0];
if ( !this.form ) this.form = html.find("form")[0];
return html;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_activateCoreListeners(html) {
super._activateCoreListeners(html);
if ( !this.form ) return;
if ( !this.isEditable ) {
return this._disableFields(this.form);
}
this.form.onsubmit = this._onSubmit.bind(this);
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
if ( !this.isEditable ) return;
html.on("change", "input,select,textarea", this._onChangeInput.bind(this));
html.find(".editor-content[data-edit]").each((i, div) => this._activateEditor(div));
for ( let fp of html.find("button.file-picker") ) {
fp.onclick = this._activateFilePicker.bind(this);
}
if ( this._priorState <= this.constructor.RENDER_STATES.NONE ) html.find("[autofocus]")[0]?.focus();
}
/* -------------------------------------------- */
/**
* If the form is not editable, disable its input fields
* @param {HTMLElement} form The form HTML
* @protected
*/
_disableFields(form) {
const inputs = ["INPUT", "SELECT", "TEXTAREA", "BUTTON"];
for ( let i of inputs ) {
for ( let el of form.getElementsByTagName(i) ) {
if ( i === "TEXTAREA" ) el.readOnly = true;
else el.disabled = true;
}
}
}
/* -------------------------------------------- */
/**
* Handle standard form submission steps
* @param {Event} event The submit event which triggered this handler
* @param {object | null} [updateData] Additional specific data keys/values which override or extend the contents of
* the parsed form. This can be used to update other flags or data fields at the
* same time as processing a form submission to avoid multiple database operations.
* @param {boolean} [preventClose] Override the standard behavior of whether to close the form on submit
* @param {boolean} [preventRender] Prevent the application from re-rendering as a result of form submission
* @returns {Promise} A promise which resolves to the validated update data
* @protected
*/
async _onSubmit(event, {updateData=null, preventClose=false, preventRender=false}={}) {
event.preventDefault();
// Prevent double submission
const states = this.constructor.RENDER_STATES;
if ( (this._state === states.NONE) || !this.isEditable || this._submitting ) return false;
this._submitting = true;
// Process the form data
const formData = this._getSubmitData(updateData);
// Handle the form state prior to submission
let closeForm = this.options.closeOnSubmit && !preventClose;
const priorState = this._state;
if ( preventRender ) this._state = states.RENDERING;
if ( closeForm ) this._state = states.CLOSING;
// Trigger the object update
try {
await this._updateObject(event, formData);
}
catch(err) {
console.error(err);
closeForm = false;
this._state = priorState;
}
// Restore flags and optionally close the form
this._submitting = false;
if ( preventRender ) this._state = priorState;
if ( closeForm ) await this.close({submit: false, force: true});
return formData;
}
/* -------------------------------------------- */
/**
* Get an object of update data used to update the form's target object
* @param {object} updateData Additional data that should be merged with the form data
* @returns {object} The prepared update data
* @protected
*/
_getSubmitData(updateData={}) {
if ( !this.form ) throw new Error("The FormApplication subclass has no registered form element");
const fd = new FormDataExtended(this.form, {editors: this.editors});
let data = fd.object;
if ( updateData ) data = foundry.utils.flattenObject(foundry.utils.mergeObject(data, updateData));
return data;
}
/* -------------------------------------------- */
/**
* Handle changes to an input element, submitting the form if options.submitOnChange is true.
* Do not preventDefault in this handler as other interactions on the form may also be occurring.
* @param {Event} event The initial change event
* @protected
*/
async _onChangeInput(event) {
// Do not fire change listeners for form inputs inside text editors.
if ( event.currentTarget.closest(".editor") ) return;
// Handle changes to specific input types
const el = event.target;
if ( (el.type === "color") && el.dataset.edit ) this._onChangeColorPicker(event);
else if ( el.type === "range" ) this._onChangeRange(event);
// Maybe submit the form
if ( this.options.submitOnChange ) {
return this._onSubmit(event);
}
}
/* -------------------------------------------- */
/**
* Handle the change of a color picker input which enters it's chosen value into a related input field
* @param {Event} event The color picker change event
* @protected
*/
_onChangeColorPicker(event) {
const input = event.target;
const form = input.form;
form[input.dataset.edit].value = input.value;
}
/* -------------------------------------------- */
/**
* Handle changes to a range type input by propagating those changes to the sibling range-value element
* @param {Event} event The initial change event
* @protected
*/
_onChangeRange(event) {
const field = event.target.parentElement.querySelector(".range-value");
if ( field ) {
if ( field.tagName === "INPUT" ) field.value = event.target.value;
else field.innerHTML = event.target.value;
}
}
/* -------------------------------------------- */
/**
* Additional handling which should trigger when a FilePicker contained within this FormApplication is submitted.
* @param {string} selection The target path which was selected
* @param {FilePicker} filePicker The FilePicker instance which was submitted
* @protected
*/
_onSelectFile(selection, filePicker) {}
/* -------------------------------------------- */
/**
* This method is called upon form submission after form data is validated
* @param {Event} event The initial triggering submission event
* @param {object} formData The object of validated form data with which to update the object
* @returns {Promise} A Promise which resolves once the update operation has completed
* @abstract
*/
async _updateObject(event, formData) {
throw new Error("A subclass of the FormApplication must implement the _updateObject method.");
}
/* -------------------------------------------- */
/* TinyMCE Editor */
/* -------------------------------------------- */
/**
* Activate a named TinyMCE text editor
* @param {string} name The named data field which the editor modifies.
* @param {object} options Editor initialization options passed to {@link TextEditor.create}.
* @param {string} initialContent Initial text content for the editor area.
* @returns {Promise<TinyMCE.Editor|ProseMirror.EditorView>}
*/
async activateEditor(name, options={}, initialContent="") {
const editor = this.editors[name];
if ( !editor ) throw new Error(`${name} is not a registered editor name!`);
options = foundry.utils.mergeObject(editor.options, options);
if ( !options.fitToSize ) options.height = options.target.offsetHeight;
if ( editor.hasButton ) editor.button.style.display = "none";
const instance = editor.instance = editor.mce = await TextEditor.create(options, initialContent || editor.initial);
options.target.closest(".editor")?.classList.add(options.engine ?? "tinymce");
editor.changed = false;
editor.active = true;
/** @deprecated since v10 */
if ( options.engine !== "prosemirror" ) {
instance.focus();
instance.on("change", () => editor.changed = true);
}
return instance;
}
/* -------------------------------------------- */
/**
* Handle saving the content of a specific editor by name
* @param {string} name The named editor to save
* @param {boolean} [remove] Remove the editor after saving its content
* @returns {Promise<void>}
*/
async saveEditor(name, {remove=true}={}) {
const editor = this.editors[name];
if ( !editor || !editor.instance ) throw new Error(`${name} is not an active editor name!`);
editor.active = false;
const instance = editor.instance;
await this._onSubmit(new Event("submit"));
// Remove the editor
if ( remove ) {
instance.destroy();
editor.instance = editor.mce = null;
if ( editor.hasButton ) editor.button.style.display = "block";
this.render();
}
editor.changed = false;
}
/* -------------------------------------------- */
/**
* Activate an editor instance present within the form
* @param {HTMLElement} div The element which contains the editor
* @protected
*/
_activateEditor(div) {
// Get the editor content div
const name = div.dataset.edit;
const engine = div.dataset.engine || "tinymce";
const collaborate = div.dataset.collaborate === "true";
const button = div.previousElementSibling;
const hasButton = button && button.classList.contains("editor-edit");
const wrap = div.parentElement.parentElement;
const wc = div.closest(".window-content");
// Determine the preferred editor height
const heights = [wrap.offsetHeight, wc ? wc.offsetHeight : null];
if ( div.offsetHeight > 0 ) heights.push(div.offsetHeight);
const height = Math.min(...heights.filter(h => Number.isFinite(h)));
// Get initial content
const options = {
target: div,
fieldName: name,
save_onsavecallback: () => this.saveEditor(name),
height, engine, collaborate
};
if ( engine === "prosemirror" ) options.plugins = this._configureProseMirrorPlugins(name, {remove: hasButton});
/**
* Handle legacy data references.
* @deprecated since v10
*/
const isDocument = this.object instanceof foundry.abstract.Document;
const data = (name?.startsWith("data.") && isDocument) ? this.object.data : this.object;
// Define the editor configuration
const editor = this.editors[name] = {
options,
target: name,
button: button,
hasButton: hasButton,
mce: null,
instance: null,
active: !hasButton,
changed: false,
initial: foundry.utils.getProperty(data, name)
};
// Activate the editor immediately, or upon button click
const activate = () => {
editor.initial = foundry.utils.getProperty(data, name);
this.activateEditor(name, {}, editor.initial);
};
if ( hasButton ) button.onclick = activate;
else activate();
}
/* -------------------------------------------- */
/**
* Configure ProseMirror plugins for this sheet.
* @param {string} name The name of the editor.
* @param {object} [options] Additional options to configure the plugins.
* @param {boolean} [options.remove=true] Whether the editor should destroy itself on save.
* @returns {object}
* @protected
*/
_configureProseMirrorPlugins(name, {remove=true}={}) {
return {
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
destroyOnSave: remove,
onSave: () => this.saveEditor(name, {remove})
}),
keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema, {
onSave: () => this.saveEditor(name, {remove})
})
};
}
/* -------------------------------------------- */
/* FilePicker UI
/* -------------------------------------------- */
/**
* Activate a FilePicker instance present within the form
* @param {PointerEvent} event The mouse click event on a file picker activation button
* @protected
*/
_activateFilePicker(event) {
event.preventDefault();
const options = this._getFilePickerOptions(event);
const fp = new FilePicker(options);
this.filepickers.push(fp);
return fp.browse();
}
/* -------------------------------------------- */
/**
* Determine the configuration options used to initialize a FilePicker instance within this FormApplication.
* Subclasses can extend this method to customize the behavior of pickers within their form.
* @param {PointerEvent} event The initiating mouse click event which opens the picker
* @returns {object} Options passed to the FilePicker constructor
* @protected
*/
_getFilePickerOptions(event) {
const button = event.currentTarget;
const target = button.dataset.target;
const field = button.form[target] || null;
return {
field: field,
type: button.dataset.type,
current: field?.value ?? "",
button: button,
callback: this._onSelectFile.bind(this)
};
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
const states = Application.RENDER_STATES;
if ( !options.force && ![states.RENDERED, states.ERROR].includes(this._state) ) return;
// Trigger saving of the form
const submit = options.submit ?? this.options.submitOnClose;
if ( submit ) await this.submit({preventClose: true, preventRender: true});
// Close any open FilePicker instances
for ( let fp of this.filepickers ) {
fp.close();
}
this.filepickers = [];
// Close any open MCE editors
for ( let ed of Object.values(this.editors) ) {
if ( ed.mce ) ed.mce.destroy();
}
this.editors = {};
// Close the application itself
return super.close(options);
}
/* -------------------------------------------- */
/**
* Submit the contents of a Form Application, processing its content as defined by the Application
* @param {object} [options] Options passed to the _onSubmit event handler
* @returns {FormApplication} Return a self-reference for convenient method chaining
*/
async submit(options={}) {
if ( this._submitting ) return;
const submitEvent = new Event("submit");
await this._onSubmit(submitEvent, options);
return this;
}
}
/* -------------------------------------------- */
/**
* @typedef {FormApplicationOptions} DocumentSheetOptions
* @property {number} viewPermission The default permissions required to view this Document sheet.
* @property {HTMLSecretConfiguration[]} [secrets] An array of {@link HTMLSecret} configuration objects.
*/
/**
* Extend the FormApplication pattern to incorporate specific logic for viewing or editing Document instances.
* See the FormApplication documentation for more complete description of this interface.
*
* @extends {FormApplication}
* @abstract
* @interface
*/
class DocumentSheet extends FormApplication {
/**
* @param {Document} object A Document instance which should be managed by this form.
* @param {DocumentSheetOptions} [options={}] Optional configuration parameters for how the form behaves.
*/
constructor(object, options={}) {
super(object, options);
this._secrets = this._createSecretHandlers();
}
/* -------------------------------------------- */
/**
* The list of handlers for secret block functionality.
* @type {HTMLSecret[]}
* @protected
*/
_secrets = [];
/* -------------------------------------------- */
/**
* @override
* @returns {DocumentSheetOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet"],
template: `templates/sheets/${this.name.toLowerCase()}.html`,
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED,
sheetConfig: true,
secrets: []
});
}
/* -------------------------------------------- */
/**
* A semantic convenience reference to the Document instance which is the target object for this form.
* @type {ClientDocument}
*/
get document() {
return this.object;
}
/* -------------------------------------------- */
/** @inheritdoc */
get id() {
return `${this.constructor.name}-${this.document.uuid.replace(/\./g, "-")}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get isEditable() {
let editable = this.options.editable && this.document.isOwner;
if ( this.document.pack ) {
const pack = game.packs.get(this.document.pack);
if ( pack.locked ) editable = false;
}
return editable;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
const reference = this.document.name ? ` ${this.document.name}` : "";
return `${game.i18n.localize(this.document.constructor.metadata.label)}${reference}`;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
await super.close(options);
delete this.object.apps?.[this.appId];
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const data = this.document.toObject(false);
const isEditable = this.isEditable;
return {
cssClass: isEditable ? "editable" : "locked",
editable: isEditable,
document: this.document,
data: data,
limited: this.document.limited,
options: this.options,
owner: this.document.isOwner,
title: this.title
};
}
/* -------------------------------------------- */
/** @inheritdoc */
_activateCoreListeners(html) {
super._activateCoreListeners(html);
if ( this.isEditable ) html.find("img[data-edit]").on("click", this._onEditImage.bind(this));
if ( !this.document.isOwner ) return;
this._secrets.forEach(secret => secret.bind(html[0]));
}
/* -------------------------------------------- */
/** @inheritdoc */
async activateEditor(name, options={}, initialContent="") {
const editor = this.editors[name];
options.document = this.document;
if ( editor?.options.engine === "prosemirror" ) {
options.plugins = foundry.utils.mergeObject({
highlightDocumentMatches: ProseMirror.ProseMirrorHighlightMatchesPlugin.build(ProseMirror.defaultSchema)
}, options.plugins);
}
return super.activateEditor(name, options, initialContent);
}
/* -------------------------------------------- */
/** @inheritdoc */
render(force=false, options={}) {
if ( !this._canUserView(game.user) ) {
if ( !force ) return this; // If rendering is not being forced, fail silently
const err = game.i18n.format("SHEETS.DocumentSheetPrivate", {
type: game.i18n.localize(this.object.constructor.metadata.label)
});
ui.notifications.warn(err);
return this;
}
// Update editable permission
options.editable = options.editable ?? this.object.isOwner;
// Register the active Application with the referenced Documents
this.object.apps[this.appId] = this;
return super.render(force, options);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _renderOuter() {
const html = await super._renderOuter();
this._createDocumentIdLink(html);
return html;
}
/* -------------------------------------------- */
/**
* Create an ID link button in the document sheet header which displays the document ID and copies to clipboard
* @param {jQuery} html
* @protected
*/
_createDocumentIdLink(html) {
if ( !(this.object instanceof foundry.abstract.Document) || !this.object.id ) return;
const title = html.find(".window-title");
const label = game.i18n.localize(this.object.constructor.metadata.label);
const idLink = document.createElement("a");
idLink.classList.add("document-id-link");
idLink.setAttribute("alt", "Copy document id");
idLink.dataset.tooltip = `${label}: ${this.object.id}`;
idLink.dataset.tooltipDirection = "UP";
idLink.innerHTML = '<i class="fa-solid fa-passport"></i>';
idLink.addEventListener("click", event => {
event.preventDefault();
game.clipboard.copyPlainText(this.object.id);
ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "id", id: this.object.id}));
});
idLink.addEventListener("contextmenu", event => {
event.preventDefault();
game.clipboard.copyPlainText(this.object.uuid);
ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "uuid", id: this.object.uuid}));
});
title.append(idLink);
}
/* -------------------------------------------- */
/**
* Test whether a certain User has permission to view this Document Sheet.
* @param {User} user The user requesting to render the sheet
* @returns {boolean} Does the User have permission to view this sheet?
* @protected
*/
_canUserView(user) {
return this.object.testUserPermission(user, this.options.viewPermission);
}
/* -------------------------------------------- */
/**
* Create objects for managing the functionality of secret blocks within this Document's content.
* @returns {HTMLSecret[]}
* @protected
*/
_createSecretHandlers() {
if ( !this.document.isOwner || this.document.compendium?.locked ) return [];
return this.options.secrets.map(config => {
config.callbacks = {
content: this._getSecretContent.bind(this),
update: this._updateSecret.bind(this)
};
return new HTMLSecret(config);
});
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_getHeaderButtons() {
let buttons = super._getHeaderButtons();
// Compendium Import
if ( (this.document.constructor.name !== "Folder") && !this.document.isEmbedded &&
this.document.compendium && this.document.constructor.canUserCreate(game.user) ) {
buttons.unshift({
label: "Import",
class: "import",
icon: "fas fa-download",
onclick: async () => {
await this.close();
return this.document.collection.importFromCompendium(this.document.compendium, this.document.id);
}
});
}
// Sheet Configuration
if ( this.options.sheetConfig && this.isEditable && (this.document.getFlag("core", "sheetLock") !== true) ) {
buttons.unshift({
label: "Sheet",
class: "configure-sheet",
icon: "fas fa-cog",
onclick: ev => this._onConfigureSheet(ev)
});
}
return buttons;
}
/* -------------------------------------------- */
/**
* Get the HTML content that a given secret block is embedded in.
* @param {HTMLElement} secret The secret block.
* @returns {string}
* @protected
*/
_getSecretContent(secret) {
const edit = secret.closest("[data-edit]")?.dataset.edit;
if ( edit ) return foundry.utils.getProperty(this.document, edit);
}
/* -------------------------------------------- */
/**
* Update the HTML content that a given secret block is embedded in.
* @param {HTMLElement} secret The secret block.
* @param {string} content The new content.
* @returns {Promise<ClientDocument>} The updated Document.
* @protected
*/
_updateSecret(secret, content) {
const edit = secret.closest("[data-edit]")?.dataset.edit;
if ( edit ) return this.document.update({[edit]: content});
}
/* -------------------------------------------- */
/**
* Handle requests to configure the default sheet used by this Document
* @param event
* @private
*/
_onConfigureSheet(event) {
event.preventDefault();
new DocumentSheetConfig(this.document, {
top: this.position.top + 40,
left: this.position.left + ((this.position.width - DocumentSheet.defaultOptions.width) / 2)
}).render(true);
}
/* -------------------------------------------- */
/**
* Handle changing a Document's image.
* @param {MouseEvent} event The click event.
* @returns {Promise}
* @protected
*/
_onEditImage(event) {
const attr = event.currentTarget.dataset.edit;
const current = foundry.utils.getProperty(this.object, attr);
const { img } = this.document.constructor.getDefaultArtwork?.(this.document.toObject()) ?? {};
const fp = new FilePicker({
current,
type: "image",
redirectToRoot: img ? [img] : [],
callback: path => {
event.currentTarget.src = path;
if ( this.options.submitOnChange ) return this._onSubmit(event);
},
top: this.position.top + 40,
left: this.position.left + 10
});
return fp.browse();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
if ( !this.object.id ) return;
return this.object.update(formData);
}
}

View File

@@ -0,0 +1,318 @@
/**
* The Application responsible for displaying and editing a single Actor document.
* This Application is responsible for rendering an actor's attributes and allowing the actor to be edited.
* @extends {DocumentSheet}
* @category - Applications
* @param {Actor} actor The Actor instance being displayed within the sheet.
* @param {DocumentSheetOptions} [options] Additional application configuration options.
*/
class ActorSheet extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
height: 720,
width: 800,
template: "templates/sheets/actor-sheet.html",
closeOnSubmit: false,
submitOnClose: true,
submitOnChange: true,
resizable: true,
baseApplication: "ActorSheet",
dragDrop: [{dragSelector: ".item-list .item", dropSelector: null}],
secrets: [{parentSelector: ".editor"}],
token: null
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return this.actor.isToken ? `[Token] ${this.actor.name}` : this.actor.name;
}
/* -------------------------------------------- */
/**
* A convenience reference to the Actor document
* @type {Actor}
*/
get actor() {
return this.object;
}
/* -------------------------------------------- */
/**
* If this Actor Sheet represents a synthetic Token actor, reference the active Token
* @type {Token|null}
*/
get token() {
return this.object.token || this.options.token || null;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
async close(options) {
this.options.token = null;
return super.close(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
context.actor = this.object;
context.items = context.data.items;
context.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));
context.effects = context.data.effects;
return context;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getHeaderButtons() {
let buttons = super._getHeaderButtons();
const canConfigure = game.user.isGM || (this.actor.isOwner && game.user.can("TOKEN_CONFIGURE"));
if ( this.options.editable && canConfigure ) {
const closeIndex = buttons.findIndex(btn => btn.label === "Close");
buttons.splice(closeIndex, 0, {
label: this.token ? "Token" : "TOKEN.TitlePrototype",
class: "configure-token",
icon: "fas fa-user-circle",
onclick: ev => this._onConfigureToken(ev)
});
}
return buttons;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData = {}) {
const data = super._getSubmitData(updateData);
// Prevent submitting overridden values
const overrides = foundry.utils.flattenObject(this.actor.overrides);
for ( let k of Object.keys(overrides) ) {
if ( k.startsWith("system.") ) delete data[`data.${k.slice(7)}`]; // Band-aid for < v10 data
delete data[k];
}
return data;
}
/* -------------------------------------------- */
/* Event Listeners */
/* -------------------------------------------- */
/**
* Handle requests to configure the Token for the Actor
* @param {PointerEvent} event The originating click event
* @private
*/
_onConfigureToken(event) {
event.preventDefault();
const renderOptions = {
left: Math.max(this.position.left - 560 - 10, 10),
top: this.position.top
};
if ( this.token ) return this.token.sheet.render(true, renderOptions);
else new CONFIG.Token.prototypeSheetClass(this.actor.prototypeToken, renderOptions).render(true);
}
/* -------------------------------------------- */
/* Drag and Drop */
/* -------------------------------------------- */
/** @inheritdoc */
_canDragStart(selector) {
return this.isEditable;
}
/* -------------------------------------------- */
/** @inheritdoc */
_canDragDrop(selector) {
return this.isEditable;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragStart(event) {
const li = event.currentTarget;
if ( event.target.classList.contains("content-link") ) return;
// Create drag data
let dragData;
// Owned Items
if ( li.dataset.itemId ) {
const item = this.actor.items.get(li.dataset.itemId);
dragData = item.toDragData();
}
// Active Effect
if ( li.dataset.effectId ) {
const effect = this.actor.effects.get(li.dataset.effectId);
dragData = effect.toDragData();
}
if ( !dragData ) return;
// Set data transfer
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
const actor = this.actor;
const allowed = Hooks.call("dropActorSheetData", actor, this, data);
if ( allowed === false ) return;
// Handle different data types
switch ( data.type ) {
case "ActiveEffect":
return this._onDropActiveEffect(event, data);
case "Actor":
return this._onDropActor(event, data);
case "Item":
return this._onDropItem(event, data);
case "Folder":
return this._onDropFolder(event, data);
}
}
/* -------------------------------------------- */
/**
* Handle the dropping of ActiveEffect data onto an Actor Sheet
* @param {DragEvent} event The concluding DragEvent which contains drop data
* @param {object} data The data transfer extracted from the event
* @returns {Promise<ActiveEffect|boolean>} The created ActiveEffect object or false if it couldn't be created.
* @protected
*/
async _onDropActiveEffect(event, data) {
const effect = await ActiveEffect.implementation.fromDropData(data);
if ( !this.actor.isOwner || !effect ) return false;
if ( this.actor.uuid === effect.parent?.uuid ) return false;
return ActiveEffect.create(effect.toObject(), {parent: this.actor});
}
/* -------------------------------------------- */
/**
* Handle dropping of an Actor data onto another Actor sheet
* @param {DragEvent} event The concluding DragEvent which contains drop data
* @param {object} data The data transfer extracted from the event
* @returns {Promise<object|boolean>} A data object which describes the result of the drop, or false if the drop was
* not permitted.
* @protected
*/
async _onDropActor(event, data) {
if ( !this.actor.isOwner ) return false;
}
/* -------------------------------------------- */
/**
* Handle dropping of an item reference or item data onto an Actor Sheet
* @param {DragEvent} event The concluding DragEvent which contains drop data
* @param {object} data The data transfer extracted from the event
* @returns {Promise<Item[]|boolean>} The created or updated Item instances, or false if the drop was not permitted.
* @protected
*/
async _onDropItem(event, data) {
if ( !this.actor.isOwner ) return false;
const item = await Item.implementation.fromDropData(data);
const itemData = item.toObject();
// Handle item sorting within the same Actor
if ( this.actor.uuid === item.parent?.uuid ) return this._onSortItem(event, itemData);
// Create the owned item
return this._onDropItemCreate(itemData);
}
/* -------------------------------------------- */
/**
* Handle dropping of a Folder on an Actor Sheet.
* The core sheet currently supports dropping a Folder of Items to create all items as owned items.
* @param {DragEvent} event The concluding DragEvent which contains drop data
* @param {object} data The data transfer extracted from the event
* @returns {Promise<Item[]>}
* @protected
*/
async _onDropFolder(event, data) {
if ( !this.actor.isOwner ) return [];
const folder = await Folder.implementation.fromDropData(data);
if ( folder.type !== "Item" ) return [];
const droppedItemData = await Promise.all(folder.contents.map(async item => {
if ( !(document instanceof Item) ) item = await fromUuid(item.uuid);
return item.toObject();
}));
return this._onDropItemCreate(droppedItemData);
}
/* -------------------------------------------- */
/**
* Handle the final creation of dropped Item data on the Actor.
* This method is factored out to allow downstream classes the opportunity to override item creation behavior.
* @param {object[]|object} itemData The item data requested for creation
* @returns {Promise<Item[]>}
* @private
*/
async _onDropItemCreate(itemData) {
itemData = itemData instanceof Array ? itemData : [itemData];
return this.actor.createEmbeddedDocuments("Item", itemData);
}
/* -------------------------------------------- */
/**
* Handle a drop event for an existing embedded Item to sort that Item relative to its siblings
* @param {Event} event
* @param {Object} itemData
* @private
*/
_onSortItem(event, itemData) {
// Get the drag source and drop target
const items = this.actor.items;
const source = items.get(itemData._id);
const dropTarget = event.target.closest("[data-item-id]");
if ( !dropTarget ) return;
const target = items.get(dropTarget.dataset.itemId);
// Don't sort on yourself
if ( source.id === target.id ) return;
// Identify sibling items based on adjacent HTML elements
const siblings = [];
for ( let el of dropTarget.parentElement.children ) {
const siblingId = el.dataset.itemId;
if ( siblingId && (siblingId !== source.id) ) siblings.push(items.get(el.dataset.itemId));
}
// Perform the sort
const sortUpdates = SortingHelpers.performIntegerSort(source, {target, siblings});
const updateData = sortUpdates.map(u => {
const update = u.update;
update._id = u.target._id;
return update;
});
// Perform the update
return this.actor.updateEmbeddedDocuments("Item", updateData);
}
}

View File

@@ -0,0 +1,523 @@
/**
* An interface for packaging Adventure content and loading it to a compendium pack.
* // TODO - add a warning if you are building the adventure with any missing content
* // TODO - add a warning if you are building an adventure that sources content from a different package' compendium
*/
class AdventureExporter extends DocumentSheet {
constructor(document, options={}) {
super(document, options);
if ( !document.pack ) {
throw new Error("You may not export an Adventure that does not belong to a Compendium pack");
}
}
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/adventure/exporter.html",
id: "adventure-exporter",
classes: ["sheet", "adventure", "adventure-exporter"],
width: 560,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "summary"}],
dragDrop: [{ dropSelector: "form" }],
scrollY: [".tab.contents"],
submitOnClose: false,
closeOnSubmit: true
});
}
/**
* An alias for the Adventure document
* @type {Adventure}
*/
adventure = this.object;
/**
* @typedef {Object} AdventureContentTreeNode
* @property {string} id An alias for folder.id
* @property {string} name An alias for folder.name
* @property {Folder} folder The Folder at this node level
* @property {string} state The modification state of the Folder
* @property {AdventureContentTreeNode[]} children An array of child nodes
* @property {{id: string, name: string, document: ClientDocument, state: string}[]} documents An array of documents
*/
/**
* @typedef {AdventureContentTreeNode} AdventureContentTreeRoot
* @property {null} id The folder ID is null at the root level
* @property {string} documentName The Document name contained in this tree
* @property {string} collection The Document collection name of this tree
* @property {string} name The name displayed at the root level of the tree
* @property {string} icon The icon displayed at the root level of the tree
* @property {string} collapseIcon The icon which represents the current collapsed state of the tree
* @property {string} cssClass CSS classes which describe the display of the tree
* @property {number} documentCount The number of documents which are present in the tree
*/
/**
* The prepared document tree which is displayed in the form.
* @type {Object<AdventureContentTreeRoot>}
*/
contentTree = {};
/**
* A mapping which allows convenient access to content tree nodes by their folder ID
* @type {Object<AdventureContentTreeNode>}
*/
#treeNodes = {};
/**
* Track data for content which has been added to the adventure.
* @type {Object<Set<ClientDocument>>}
*/
#addedContent = Object.keys(Adventure.contentFields).reduce((obj, f) => {
obj[f] = new Set();
return obj;
}, {});
/**
* Track the IDs of content which has been removed from the adventure.
* @type {Object<Set<string>>}
*/
#removedContent = Object.keys(Adventure.contentFields).reduce((obj, f) => {
obj[f] = new Set();
return obj;
}, {});
/**
* Track which sections of the contents are collapsed.
* @type {Set<string>}
* @private
*/
#collapsedSections = new Set();
/** @override */
get isEditable() {
return game.user.isGM;
}
/* -------------------------------------------- */
/* Application Rendering */
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
this.contentTree = this.#organizeContentTree();
return {
adventure: this.adventure,
contentTree: this.contentTree
};
}
/* -------------------------------------------- */
/** @inheritdoc */
async activateEditor(name, options={}, initialContent="") {
options.plugins = {
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema),
keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema)
};
return super.activateEditor(name, options, initialContent);
}
/* -------------------------------------------- */
/** @inheritdoc */
_getHeaderButtons() {
return super._getHeaderButtons().filter(btn => btn.label !== "Import");
}
/* -------------------------------------------- */
/**
* Organize content in the adventure into a tree structure which is displayed in the UI.
* @returns {Object<AdventureContentTreeRoot>}
*/
#organizeContentTree() {
const content = {};
let remainingFolders = Array.from(this.adventure.folders).concat(Array.from(this.#addedContent.folders || []));
// Prepare each content section
for ( const [name, cls] of Object.entries(Adventure.contentFields) ) {
if ( name === "folders" ) continue;
// Partition content for the section
let documents = Array.from(this.adventure[name]).concat(Array.from(this.#addedContent[name] || []));
let folders;
[remainingFolders, folders] = remainingFolders.partition(f => f.type === cls.documentName);
if ( !(documents.length || folders.length) ) continue;
// Prepare the root node
const collapsed = this.#collapsedSections.has(cls.documentName);
const section = content[name] = {
documentName: cls.documentName,
collection: cls.collectionName,
id: null,
name: game.i18n.localize(cls.metadata.labelPlural),
icon: CONFIG[cls.documentName].sidebarIcon,
collapseIcon: collapsed ? "fa-solid fa-angle-up" : "fa-solid fa-angle-down",
cssClass: [cls.collectionName, collapsed ? "collapsed" : ""].filterJoin(" "),
documentCount: documents.length - this.#removedContent[name].size,
folder: null,
state: "root",
children: [],
documents: []
};
// Recursively populate the tree
[folders, documents] = this.#populateNode(section, folders, documents);
// Add leftover documents to the section root
for ( const d of documents ) {
const state = this.#getDocumentState(d);
section.documents.push({document: d, id: d.id, name: d.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`});
}
}
return content;
}
/* -------------------------------------------- */
/**
* Populate one node of the content tree with folders and documents
* @param {AdventureContentTreeNode }node The node being populated
* @param {Folder[]} remainingFolders Folders which have yet to be populated to a node
* @param {ClientDocument[]} remainingDocuments Documents which have yet to be populated to a node
* @returns {Array<Folder[], ClientDocument[]>} Folders and Documents which still have yet to be populated
*/
#populateNode(node, remainingFolders, remainingDocuments) {
// Allocate Documents to this node
let documents;
[remainingDocuments, documents] = remainingDocuments.partition(d => d._source.folder === node.id );
for ( const d of documents ) {
const state = this.#getDocumentState(d);
node.documents.push({document: d, id: d.id, name: d.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`});
}
// Allocate Folders to this node
let folders;
[remainingFolders, folders] = remainingFolders.partition(f => f._source.folder === node.id);
for ( const folder of folders ) {
const state = this.#getDocumentState(folder);
const child = {folder, id: folder.id, name: folder.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`,
children: [], documents: []};
[remainingFolders, remainingDocuments] = this.#populateNode(child, remainingFolders, remainingDocuments);
node.children.push(child);
this.#treeNodes[folder.id] = child;
}
return [remainingFolders, remainingDocuments];
}
/* -------------------------------------------- */
/**
* Flag the current state of each document which is displayed
* @param {ClientDocument} document The document being modified
* @returns {string} The document state
*/
#getDocumentState(document) {
const cn = document.collectionName;
if ( this.#removedContent[cn].has(document.id) ) return "remove";
if ( this.#addedContent[cn].has(document) ) return "add";
const worldCollection = game.collections.get(document.documentName);
if ( !worldCollection.has(document.id) ) return "missing";
return "update";
}
/* -------------------------------------------- */
/** @inheritDoc */
async close(options = {}) {
this.adventure.reset(); // Reset any pending changes
return super.close(options);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
html.on("click", "a.control", this.#onClickControl.bind(this));
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, adventureData) {
// Build the adventure data content
for ( const [name, cls] of Object.entries(Adventure.contentFields) ) {
const collection = game.collections.get(cls.documentName);
adventureData[name] = [];
const addDoc = id => {
if ( this.#removedContent[name].has(id) ) return;
const doc = collection.get(id);
if ( !doc ) return;
adventureData[name].push(doc.toObject());
};
for ( const d of this.adventure[name] ) addDoc(d.id);
for ( const d of this.#addedContent[name] ) addDoc(d.id);
}
const pack = game.packs.get(this.adventure.pack);
const restrictedDocuments = adventureData.actors?.length || adventureData.items?.length;
if ( restrictedDocuments && !pack?.metadata.system ) {
return ui.notifications.error("ADVENTURE.ExportPackNoSystem", {localize: true, permanent: true});
}
// Create or update the document
if ( this.adventure.id ) {
const updated = await this.adventure.update(adventureData, {diff: false, recursive: false});
pack.indexDocument(updated);
ui.notifications.info(game.i18n.format("ADVENTURE.UpdateSuccess", {name: this.adventure.name}));
} else {
await this.adventure.constructor.createDocuments([adventureData], {
pack: this.adventure.pack,
keepId: true,
keepEmbeddedIds: true
});
ui.notifications.info(game.i18n.format("ADVENTURE.CreateSuccess", {name: this.adventure.name}));
}
}
/* -------------------------------------------- */
/**
* Save editing progress so that re-renders of the form do not wipe out un-saved changes.
*/
#saveProgress() {
const formData = this._getSubmitData();
this.adventure.updateSource(formData);
}
/* -------------------------------------------- */
/**
* Handle pointer events on a control button
* @param {PointerEvent} event The originating pointer event
*/
#onClickControl(event) {
event.preventDefault();
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "clear":
return this.#onClearSection(button);
case "collapse":
return this.#onCollapseSection(button);
case "remove":
return this.#onRemoveContent(button);
}
}
/* -------------------------------------------- */
/**
* Clear all content from a particular document-type section.
* @param {HTMLAnchorElement} button The clicked control button
*/
#onClearSection(button) {
const section = button.closest(".document-type");
const documentType = section.dataset.documentType;
const cls = getDocumentClass(documentType);
this.#removeNode(this.contentTree[cls.collectionName]);
this.#saveProgress();
this.render();
}
/* -------------------------------------------- */
/**
* Toggle the collapsed or expanded state of a document-type section
* @param {HTMLAnchorElement} button The clicked control button
*/
#onCollapseSection(button) {
const section = button.closest(".document-type");
const icon = button.firstElementChild;
const documentType = section.dataset.documentType;
const isCollapsed = this.#collapsedSections.has(documentType);
if ( isCollapsed ) {
this.#collapsedSections.delete(documentType);
section.classList.remove("collapsed");
icon.classList.replace("fa-angle-up", "fa-angle-down");
} else {
this.#collapsedSections.add(documentType);
section.classList.add("collapsed");
icon.classList.replace("fa-angle-down", "fa-angle-up");
}
}
/* -------------------------------------------- */
/**
* Remove a single piece of content.
* @param {HTMLAnchorElement} button The clicked control button
*/
#onRemoveContent(button) {
const h4 = button.closest("h4");
const isFolder = h4.classList.contains("folder");
const documentName = isFolder ? "Folder" : button.closest(".document-type").dataset.documentType;
const document = this.#getDocument(documentName, h4.dataset.documentId);
if ( document ) {
this.removeContent(document);
this.#saveProgress();
this.render();
}
}
/* -------------------------------------------- */
/**
* Get the Document instance from the clicked content tag.
* @param {string} documentName The document type
* @param {string} documentId The document ID
* @returns {ClientDocument|null} The Document instance, or null
*/
#getDocument(documentName, documentId) {
const cls = getDocumentClass(documentName);
const cn = cls.collectionName;
const existing = this.adventure[cn].find(d => d.id === documentId);
if ( existing ) return existing;
const added = this.#addedContent[cn].find(d => d.id === documentId);
return added || null;
}
/* -------------------------------------------- */
/* Content Drop Handling */
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
const cls = getDocumentClass(data?.type);
if ( !cls || !(cls.collectionName in Adventure.contentFields) ) return;
const document = await cls.fromDropData(data);
if ( document.pack || document.isEmbedded ) {
return ui.notifications.error("ADVENTURE.ExportPrimaryDocumentsOnly", {localize: true});
}
const pack = game.packs.get(this.adventure.pack);
const type = data?.type === "Folder" ? document.type : data?.type;
if ( !pack?.metadata.system && CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(type) ) {
return ui.notifications.error("ADVENTURE.ExportPackNoSystem", {localize: true});
}
this.addContent(document);
this.#saveProgress();
this.render();
}
/* -------------------------------------------- */
/* Content Management Workflows */
/* -------------------------------------------- */
/**
* Stage a document for addition to the Adventure.
* This adds the document locally, the change is not yet submitted to the database.
* @param {Folder|ClientDocument} document Some document to be added to the Adventure.
*/
addContent(document) {
if ( document instanceof foundry.documents.BaseFolder ) this.#addFolder(document);
if ( document.folder ) this.#addDocument(document.folder);
this.#addDocument(document);
}
/* -------------------------------------------- */
/**
* Remove a single Document from the Adventure.
* @param {ClientDocument} document The Document being removed from the Adventure.
*/
removeContent(document) {
if ( document instanceof foundry.documents.BaseFolder ) {
const node = this.#treeNodes[document.id];
if ( !node ) return;
if ( this.#removedContent.folders.has(node.id) ) return this.#restoreNode(node);
return this.#removeNode(node);
}
else this.#removeDocument(document);
}
/* -------------------------------------------- */
/**
* Remove a single document from the content tree
* @param {AdventureContentTreeNode} node The node to remove
*/
#removeNode(node) {
for ( const child of node.children ) this.#removeNode(child);
for ( const d of node.documents ) this.#removeDocument(d.document);
if ( node.folder ) this.#removeDocument(node.folder);
}
/* -------------------------------------------- */
/**
* Restore a removed node back to the content tree
* @param {AdventureContentTreeNode} node The node to restore
*/
#restoreNode(node) {
for ( const child of node.children ) this.#restoreNode(child);
for ( const d of node.documents ) this.#removedContent[d.document.collectionName].delete(d.id);
return this.#removedContent.folders.delete(node.id);
}
/* -------------------------------------------- */
/**
* Remove a single document from the content tree
* @param {ClientDocument} document The document to remove
*/
#removeDocument(document) {
const cn = document.collectionName;
// If the Document was already removed, re-add it
if ( this.#removedContent[cn].has(document.id) ) {
this.#removedContent[cn].delete(document.id);
}
// If the content was temporarily added, remove it
else if ( this.#addedContent[cn].has(document) ) {
this.#addedContent[cn].delete(document);
}
// Otherwise, mark the content as removed
else this.#removedContent[cn].add(document.id);
}
/* -------------------------------------------- */
/**
* Add an entire folder tree including contained documents and subfolders to the Adventure.
* @param {Folder} folder The folder to add
* @private
*/
#addFolder(folder) {
this.#addDocument(folder);
for ( const doc of folder.contents ) {
this.#addDocument(doc);
}
for ( const sub of folder.getSubfolders() ) {
this.#addFolder(sub);
}
}
/* -------------------------------------------- */
/**
* Add a single document to the Adventure.
* @param {ClientDocument} document The Document to add
* @private
*/
#addDocument(document) {
const cn = document.collectionName;
// If the document was previously removed, restore it
if ( this.#removedContent[cn].has(document.id) ) {
return this.#removedContent[cn].delete(document.id);
}
// Otherwise, add documents which don't yet exist
const existing = this.adventure[cn].find(d => d.id === document.id);
if ( !existing ) this.#addedContent[cn].add(document);
}
}

View File

@@ -0,0 +1,183 @@
/**
* An interface for importing an adventure from a compendium pack.
*/
class AdventureImporter extends DocumentSheet {
/**
* An alias for the Adventure document
* @type {Adventure}
*/
adventure = this.object;
/** @override */
get isEditable() {
return game.user.isGM;
}
/* -------------------------------------------- */
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/adventure/importer.html",
id: "adventure-importer",
classes: ["sheet", "adventure", "adventure-importer"],
width: 800,
height: "auto",
submitOnClose: false,
closeOnSubmit: true
});
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
return {
adventure: this.adventure,
contents: this._getContentList(),
imported: !!game.settings.get("core", "adventureImports")?.[this.adventure.uuid]
};
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find('[value="all"]').on("change", this._onToggleImportAll.bind(this));
}
/* -------------------------------------------- */
/**
* Handle toggling the import all checkbox.
* @param {Event} event The change event.
* @protected
*/
_onToggleImportAll(event) {
const target = event.currentTarget;
const section = target.closest(".import-controls");
const checked = target.checked;
section.querySelectorAll("input").forEach(input => {
if ( input === target ) return;
if ( input.value !== "folders" ) input.disabled = checked;
if ( checked ) input.checked = true;
});
}
/* -------------------------------------------- */
/**
* Prepare a list of content types provided by this adventure.
* @returns {{icon: string, label: string, count: number}[]}
* @protected
*/
_getContentList() {
return Object.entries(Adventure.contentFields).reduce((arr, [field, cls]) => {
const count = this.adventure[field].size;
if ( !count ) return arr;
arr.push({
icon: CONFIG[cls.documentName].sidebarIcon,
label: game.i18n.localize(count > 1 ? cls.metadata.labelPlural : cls.metadata.label),
count, field
});
return arr;
}, []);
}
/* -------------------------------------------- */
/** @inheritdoc */
_getHeaderButtons() {
const buttons = super._getHeaderButtons();
buttons.findSplice(b => b.class === "import");
return buttons;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
// Backwards compatibility. If the AdventureImporter subclass defines _prepareImportData or _importContent
/** @deprecated since v11 */
const prepareImportDefined = foundry.utils.getDefiningClass(this, "_prepareImportData");
const importContentDefined = foundry.utils.getDefiningClass(this, "_importContent");
if ( (prepareImportDefined !== AdventureImporter) || (importContentDefined !== AdventureImporter) ) {
const warning = `The ${this.name} class overrides the AdventureImporter#_prepareImportData or
AdventureImporter#_importContent methods. As such a legacy import workflow will be used, but this workflow is
deprecated. Your importer should now call the new Adventure#import, Adventure#prepareImport,
or Adventure#importContent methods.`;
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
return this._importLegacy(formData);
}
// Perform the standard Adventure import workflow
return this.adventure.import(formData);
}
/* -------------------------------------------- */
/**
* Mirror Adventure#import but call AdventureImporter#_importContent and AdventureImport#_prepareImportData
* @deprecated since v11
* @ignore
*/
async _importLegacy(formData) {
// Prepare the content for import
const {toCreate, toUpdate, documentCount} = await this._prepareImportData(formData);
// Allow modules to preprocess adventure data or to intercept the import process
const allowed = Hooks.call("preImportAdventure", this.adventure, formData, toCreate, toUpdate);
if ( allowed === false ) {
return console.log(`"${this.adventure.name}" Adventure import was prevented by the "preImportAdventure" hook`);
}
// Warn the user if the import operation will overwrite existing World content
if ( !foundry.utils.isEmpty(toUpdate) ) {
const confirm = await Dialog.confirm({
title: game.i18n.localize("ADVENTURE.ImportOverwriteTitle"),
content: `<h4><strong>${game.i18n.localize("Warning")}:</strong></h4>
<p>${game.i18n.format("ADVENTURE.ImportOverwriteWarning", {name: this.adventure.name})}</p>`
});
if ( !confirm ) return;
}
// Perform the import
const {created, updated} = await this._importContent(toCreate, toUpdate, documentCount);
// Refresh the sidebar display
ui.sidebar.render();
// Allow modules to react to the import process
Hooks.callAll("importAdventure", this.adventure, formData, created, updated);
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async _prepareImportData(formData) {
foundry.utils.logCompatibilityWarning("AdventureImporter#_prepareImportData is deprecated. "
+ "Please use Adventure#prepareImport instead.", {since: 11, until: 13});
return this.adventure.prepareImport(formData);
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async _importContent(toCreate, toUpdate, documentCount) {
foundry.utils.logCompatibilityWarning("AdventureImporter#_importContent is deprecated. "
+ "Please use Adventure#importContent instead.", {since: 11, until: 13});
return this.adventure.importContent({ toCreate, toUpdate, documentCount });
}
}

View File

@@ -0,0 +1,61 @@
/**
* The Application responsible for displaying a basic sheet for any Document sub-types that do not have a sheet
* registered.
* @extends {DocumentSheet}
*/
class BaseSheet extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/sheets/base-sheet.html",
classes: ["sheet", "base-sheet"],
width: 450,
height: "auto",
resizable: true,
submitOnChange: true,
closeOnSubmit: false
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const context = await super.getData(options);
context.hasName = "name" in this.object;
context.hasImage = "img" in this.object;
context.hasDescription = "description" in this.object;
if ( context.hasDescription ) {
context.descriptionHTML = await TextEditor.enrichHTML(this.object.description, {
async: true,
secrets: this.object.isOwner,
relativeTo: this.object
});
}
return context;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
await super._render(force, options);
await this._waitForImages();
this.setPosition();
}
/* -------------------------------------------- */
/** @inheritdoc */
async activateEditor(name, options={}, initialContent="") {
options.relativeLinks = true;
options.plugins = {
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
compact: true,
destroyOnSave: false,
onSave: () => this.saveEditor(name, {remove: false})
})
};
return super.activateEditor(name, options, initialContent);
}
}

View File

@@ -0,0 +1,73 @@
/**
* A DocumentSheet application responsible for displaying and editing a single embedded Card document.
* @extends {DocumentSheet}
* @param {Card} object The {@link Card} object being configured.
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class CardConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "card-config"],
template: "templates/cards/card-config.html",
width: 480,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "details"}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
return foundry.utils.mergeObject(super.getData(options), {
data: this.document.toObject(), // Source data, not derived
types: CONFIG.Card.typeLabels
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".face-control").click(this._onFaceControl.bind(this));
}
/* -------------------------------------------- */
/**
* Handle card face control actions which modify single cards on the sheet.
* @param {PointerEvent} event The originating click event
* @returns {Promise} A Promise which resolves once the handler has completed
* @protected
*/
async _onFaceControl(event) {
const button = event.currentTarget;
const face = button.closest(".face");
const faces = this.object.toObject().faces;
// Save any pending change to the form
await this._onSubmit(event, {preventClose: true, preventRender: true});
// Handle the control action
switch ( button.dataset.action ) {
case "addFace":
faces.push({});
return this.object.update({faces});
case "deleteFace":
return Dialog.confirm({
title: game.i18n.localize("CARD.FaceDelete"),
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("CARD.FaceDeleteWarning")}</p>`,
yes: () => {
const i = Number(face.dataset.face);
faces.splice(i, 1);
return this.object.update({faces});
}
});
}
}
}

View File

@@ -0,0 +1,228 @@
/**
* A DocumentSheet application responsible for displaying and editing a single Cards stack.
*/
class CardsConfig extends DocumentSheet {
/**
* The CardsConfig sheet is constructed by providing a Cards document and sheet-level options.
* @param {Cards} object The {@link Cards} object being configured.
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
constructor(object, options) {
super(object, options);
this.options.classes.push(object.type);
}
/**
* The allowed sorting methods which can be used for this sheet
* @enum {string}
*/
static SORT_TYPES = {
STANDARD: "standard",
SHUFFLED: "shuffled"
};
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "cards-config"],
template: "templates/cards/cards-deck.html",
width: 620,
height: "auto",
closeOnSubmit: false,
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
dragDrop: [{dragSelector: "ol.cards li.card", dropSelector: "ol.cards"}],
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "cards"}],
scrollY: ["ol.cards"],
sort: this.SORT_TYPES.SHUFFLED
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
// Sort cards
const sortFn = {
standard: this.object.sortStandard,
shuffled: this.object.sortShuffled
}[options?.sort || "standard"];
const cards = this.object.cards.contents.sort((a, b) => sortFn.call(this.object, a, b));
// Return rendering context
return foundry.utils.mergeObject(super.getData(options), {
cards: cards,
types: CONFIG.Cards.typeLabels,
inCompendium: !!this.object.pack
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// Card Actions
html.find(".card-control").click(this._onCardControl.bind(this));
// Intersection Observer
const cards = html.find("ol.cards");
const entries = cards.find("li.card");
const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), {root: cards[0]});
entries.each((i, li) => observer.observe(li));
}
/* -------------------------------------------- */
/**
* Handle card control actions which modify single cards on the sheet.
* @param {PointerEvent} event The originating click event
* @returns {Promise} A Promise which resolves once the handler has completed
* @protected
*/
async _onCardControl(event) {
const button = event.currentTarget;
const li = button.closest(".card");
const card = li ? this.object.cards.get(li.dataset.cardId) : null;
const cls = getDocumentClass("Card");
// Save any pending change to the form
await this._onSubmit(event, {preventClose: true, preventRender: true});
// Handle the control action
switch ( button.dataset.action ) {
case "create":
return cls.createDialog({ faces: [{}], face: 0 }, {parent: this.object, pack: this.object.pack});
case "edit":
return card.sheet.render(true);
case "delete":
return card.deleteDialog();
case "deal":
return this.object.dealDialog();
case "draw":
return this.object.drawDialog();
case "pass":
return this.object.passDialog();
case "play":
return this.object.playDialog(card);
case "reset":
return this.object.resetDialog();
case "shuffle":
this.options.sort = this.constructor.SORT_TYPES.SHUFFLED;
return this.object.shuffle();
case "toggleSort":
this.options.sort = {standard: "shuffled", shuffled: "standard"}[this.options.sort];
return this.render();
case "nextFace":
return card.update({face: card.face === null ? 0 : card.face+1});
case "prevFace":
return card.update({face: card.face === 0 ? null : card.face-1});
}
}
/* -------------------------------------------- */
/**
* Handle lazy-loading card face images.
* See {@link SidebarTab#_onLazyLoadImage}
* @param {IntersectionObserverEntry[]} entries The entries which are now in the observer frame
* @param {IntersectionObserver} observer The intersection observer instance
* @protected
*/
_onLazyLoadImage(entries, observer) {
return ui.cards._onLazyLoadImage.call(this, entries, observer);
}
/* -------------------------------------------- */
/** @inheritdoc */
_canDragStart(selector) {
return this.isEditable;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragStart(event) {
const li = event.currentTarget;
const card = this.object.cards.get(li.dataset.cardId);
if ( !card ) return;
// Set data transfer
event.dataTransfer.setData("text/plain", JSON.stringify(card.toDragData()));
}
/* -------------------------------------------- */
/** @inheritdoc */
_canDragDrop(selector) {
return this.isEditable;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
if ( data.type !== "Card" ) return;
const card = await Card.implementation.fromDropData(data);
if ( card.parent.id === this.object.id ) return this._onSortCard(event, card);
try {
return await card.pass(this.object);
} catch(err) {
Hooks.onError("CardsConfig#_onDrop", err, {log: "error", notify: "error"});
}
}
/* -------------------------------------------- */
/**
* Handle sorting a Card relative to other siblings within this document
* @param {Event} event The drag drop event
* @param {Card} card The card being dragged
* @private
*/
_onSortCard(event, card) {
// Identify a specific card as the drop target
let target = null;
const li = event.target.closest("[data-card-id]");
if ( li ) target = this.object.cards.get(li.dataset.cardId) ?? null;
// Identify the set of siblings
const siblings = this.object.cards.filter(c => c.id !== card.id);
// Perform an integer-based sort
const updateData = SortingHelpers.performIntegerSort(card, {target, siblings}).map(u => {
return {_id: u.target.id, sort: u.update.sort};
});
return this.object.updateEmbeddedDocuments("Card", updateData);
}
}
/**
* A subclass of CardsConfig which provides a sheet representation for Cards documents with the "hand" type.
*/
class CardsHand extends CardsConfig {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/cards/cards-hand.html"
});
}
}
/**
* A subclass of CardsConfig which provides a sheet representation for Cards documents with the "pile" type.
*/
class CardsPile extends CardsConfig {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/cards/cards-pile.html"
});
}
}

View File

@@ -0,0 +1,83 @@
/**
* The Application responsible for configuring the CombatTracker and its contents.
* @extends {FormApplication}
*/
class CombatTrackerConfig extends FormApplication {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "combat-config",
title: game.i18n.localize("COMBAT.Settings"),
classes: ["sheet", "combat-sheet"],
template: "templates/sheets/combat-config.html",
width: 420
});
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const attributes = TokenDocument.implementation.getTrackedAttributes();
attributes.bar.forEach(a => a.push("value"));
const combatThemeSetting = game.settings.settings.get("core.combatTheme");
return {
settings: game.settings.get("core", Combat.CONFIG_SETTING),
attributeChoices: TokenDocument.implementation.getTrackedAttributeChoices(attributes),
combatTheme: combatThemeSetting,
selectedTheme: game.settings.get("core", "combatTheme"),
user: game.user
};
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
game.settings.set("core", "combatTheme", formData.combatTheme);
if ( !game.user.isGM ) return;
return game.settings.set("core", Combat.CONFIG_SETTING, {
resource: formData.resource,
skipDefeated: formData.skipDefeated
});
}
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find(".audio-preview").click(this.#onAudioPreview.bind(this));
}
/* -------------------------------------------- */
#audioPreviewState = 0;
/**
* Handle previewing a sound file for a Combat Tracker setting
* @param {Event} event The initial button click event
* @private
*/
#onAudioPreview(event) {
const themeName = this.form.combatTheme.value;
const theme = CONFIG.Combat.sounds[themeName];
if ( !theme || theme === "none" ) return;
const announcements = CONST.COMBAT_ANNOUNCEMENTS;
const announcement = announcements[this.#audioPreviewState++ % announcements.length];
const sounds = theme[announcement];
if ( !sounds ) return;
const src = sounds[Math.floor(Math.random() * sounds.length)];
const volume = game.settings.get("core", "globalInterfaceVolume");
game.audio.play(src, {volume});
}
/* -------------------------------------------- */
/** @override */
async _onChangeInput(event) {
if ( event.currentTarget.name === "combatTheme" ) this.#audioPreviewState = 0;
return super._onChangeInput(event);
}
}

View File

@@ -0,0 +1,35 @@
/**
* The Application responsible for configuring a single Combatant document within a parent Combat.
* @extends {DocumentSheet}
*/
class CombatantConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "combatant-config",
title: game.i18n.localize("COMBAT.CombatantConfig"),
classes: ["sheet", "combat-sheet"],
template: "templates/sheets/combatant-config.html",
width: 420
});
}
/* -------------------------------------------- */
/** @override */
get title() {
return game.i18n.localize(this.object.id ? "COMBAT.CombatantUpdate" : "COMBAT.CombatantCreate");
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
if ( this.object.id ) return this.object.update(formData);
else {
const cls = getDocumentClass("Combatant");
return cls.create(formData, {parent: game.combat});
}
}
}

View File

@@ -0,0 +1,69 @@
/**
* An Application responsible for allowing GMs to configure the default sheets that are used for the Documents in their
* world.
*/
class DefaultSheetsConfig extends PackageConfiguration {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("SETTINGS.DefaultSheetsL"),
id: "default-sheets-config",
categoryTemplate: "templates/sidebar/apps/default-sheets-config.html",
submitButton: true
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_prepareCategoryData() {
let total = 0;
const categories = [];
for ( const cls of Object.values(foundry.documents) ) {
const documentName = cls.documentName;
if ( !cls.hasTypeData ) continue;
const subTypes = game.documentTypes[documentName].filter(t => t !== CONST.BASE_DOCUMENT_TYPE);
if ( !subTypes.length ) continue;
const title = game.i18n.localize(cls.metadata.labelPlural);
categories.push({
title,
id: documentName,
count: subTypes.length,
subTypes: subTypes.map(t => {
const typeLabel = CONFIG[documentName].typeLabels?.[t];
const name = typeLabel ? game.i18n.localize(typeLabel) : t;
const {defaultClasses, defaultClass} = DocumentSheetConfig.getSheetClassesForSubType(documentName, t);
return {type: t, name, defaultClasses, defaultClass};
})
});
total += subTypes.length;
}
return {categories, total};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
const settings = Object.entries(formData).reduce((obj, [name, sheetId]) => {
const [documentName, ...rest] = name.split(".");
const subType = rest.join(".");
const cfg = CONFIG[documentName].sheetClasses?.[subType]?.[sheetId];
// Do not create an entry in the settings object if the class is already the default.
if ( cfg?.default ) return obj;
const entry = obj[documentName] ??= {};
entry[subType] = sheetId;
return obj;
}, {});
return game.settings.set("core", "sheetClasses", settings);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onResetDefaults(event) {
event.preventDefault();
await game.settings.set("core", "sheetClasses", {});
return SettingsConfig.reloadConfirm({world: true});
}
}

View File

@@ -0,0 +1,101 @@
/**
* The Application responsible for configuring a single ActiveEffect document within a parent Actor or Item.
* @extends {DocumentSheet}
*
* @param {ActiveEffect} object The target active effect being configured
* @param {DocumentSheetOptions} [options] Additional options which modify this application instance
*/
class ActiveEffectConfig extends DocumentSheet {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "active-effect-sheet"],
template: "templates/sheets/active-effect-config.html",
width: 580,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "details"}]
});
}
/* ----------------------------------------- */
/** @override */
async getData(options={}) {
const context = await super.getData(options);
context.descriptionHTML = await TextEditor.enrichHTML(this.object.description,
{async: true, secrets: this.object.isOwner});
const legacyTransfer = CONFIG.ActiveEffect.legacyTransferral;
const labels = {
transfer: {
name: game.i18n.localize(`EFFECT.Transfer${legacyTransfer ? "Legacy" : ""}`),
hint: game.i18n.localize(`EFFECT.TransferHint${legacyTransfer ? "Legacy" : ""}`)
}
};
const data = {
labels,
effect: this.object, // Backwards compatibility
data: this.object,
isActorEffect: this.object.parent.documentName === "Actor",
isItemEffect: this.object.parent.documentName === "Item",
submitText: "EFFECT.Submit",
modes: Object.entries(CONST.ACTIVE_EFFECT_MODES).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`EFFECT.MODE_${e[0]}`);
return obj;
}, {})
};
return foundry.utils.mergeObject(context, data);
}
/* ----------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find(".effect-control").click(this._onEffectControl.bind(this));
}
/* ----------------------------------------- */
/**
* Provide centralized handling of mouse clicks on control buttons.
* Delegate responsibility out to action-specific handlers depending on the button action.
* @param {MouseEvent} event The originating click event
* @private
*/
_onEffectControl(event) {
event.preventDefault();
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "add":
return this._addEffectChange();
case "delete":
button.closest(".effect-change").remove();
return this.submit({preventClose: true}).then(() => this.render());
}
}
/* ----------------------------------------- */
/**
* Handle adding a new change to the changes array.
* @private
*/
async _addEffectChange() {
const idx = this.document.changes.length;
return this.submit({preventClose: true, updateData: {
[`changes.${idx}`]: {key: "", mode: CONST.ACTIVE_EFFECT_MODES.ADD, value: ""}
}});
}
/* ----------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const fd = new FormDataExtended(this.form, {editors: this.editors});
let data = foundry.utils.expandObject(fd.object);
if ( updateData ) foundry.utils.mergeObject(data, updateData);
data.changes = Array.from(Object.values(data.changes || {}));
return data;
}
}

View File

@@ -0,0 +1,71 @@
/**
* The Application responsible for configuring a single Folder document.
* @extends {DocumentSheet}
* @param {Folder} object The {@link Folder} object to configure.
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class FolderConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "folder-edit"],
template: "templates/sidebar/folder-edit.html",
width: 360
});
}
/* -------------------------------------------- */
/** @override */
get id() {
return this.object.id ? super.id : "folder-create";
}
/* -------------------------------------------- */
/** @override */
get title() {
if ( this.object.id ) return `${game.i18n.localize("FOLDER.Update")}: ${this.object.name}`;
return game.i18n.localize("FOLDER.Create");
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
if ( !this.options.submitOnClose ) this.options.resolve?.(null);
return super.close(options);
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const folder = this.document.toObject();
const label = game.i18n.localize(Folder.implementation.metadata.label);
return {
folder: folder,
name: folder._id ? folder.name : "",
newName: game.i18n.format("DOCUMENT.New", {type: label}),
safeColor: folder.color ?? "#000000",
sortingModes: {a: "FOLDER.SortAlphabetical", m: "FOLDER.SortManual"},
submitText: game.i18n.localize(folder._id ? "FOLDER.Update" : "FOLDER.Create")
};
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
let doc = this.object;
if ( !formData.name?.trim() ) formData.name = Folder.implementation.defaultName();
if ( this.object.id ) await this.object.update(formData);
else {
this.object.updateSource(formData);
doc = await Folder.create(this.object, { pack: this.object.pack });
}
this.options.resolve?.(doc);
return doc;
}
}

View File

@@ -0,0 +1,391 @@
/**
* @typedef {object} NewFontDefinition
* @property {string} [family] The font family.
* @property {number} [weight=400] The font weight.
* @property {string} [style="normal"] The font style.
* @property {string} [src=""] The font file.
* @property {string} [preview] The text to preview the font.
*/
/**
* A class responsible for configuring custom fonts for the world.
* @extends {FormApplication}
*/
class FontConfig extends FormApplication {
/**
* An application for configuring custom world fonts.
* @param {NewFontDefinition} [object] The default settings for new font definition creation.
* @param {object} [options] Additional options to configure behaviour.
*/
constructor(object={}, options={}) {
foundry.utils.mergeObject(object, {
family: "",
weight: 400,
style: "normal",
src: "",
preview: game.i18n.localize("FONTS.FontPreview"),
type: FontConfig.FONT_TYPES.FILE
});
super(object, options);
}
/* -------------------------------------------- */
/**
* Whether fonts have been modified since opening the application.
* @type {boolean}
*/
#fontsModified = false;
/* -------------------------------------------- */
/**
* The currently selected font.
* @type {{family: string, index: number}|null}
*/
#selected = null;
/* -------------------------------------------- */
/**
* Whether the given font is currently selected.
* @param {{family: string, index: number}} selection The font selection information.
* @returns {boolean}
*/
#isSelected({family, index}) {
if ( !this.#selected ) return false;
return (family === this.#selected.family) && (index === this.#selected.index);
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("SETTINGS.FontConfigL"),
id: "font-config",
template: "templates/sidebar/apps/font-config.html",
popOut: true,
width: 600,
height: "auto",
closeOnSubmit: false,
submitOnChange: true
});
}
/* -------------------------------------------- */
/**
* Whether a font is distributed to connected clients or found on their OS.
* @enum {string}
*/
static FONT_TYPES = {
FILE: "file",
SYSTEM: "system"
};
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const definitions = game.settings.get("core", this.constructor.SETTING);
const fonts = Object.entries(definitions).flatMap(([family, definition]) => {
return this._getDataForDefinition(family, definition);
});
let selected;
if ( (this.#selected === null) && fonts.length ) {
fonts[0].selected = true;
this.#selected = {family: fonts[0].family, index: fonts[0].index};
}
if ( fonts.length ) selected = definitions[this.#selected.family].fonts[this.#selected.index];
return {
fonts, selected,
font: this.object,
family: this.#selected?.family,
weights: Object.entries(CONST.FONT_WEIGHTS).map(([k, v]) => ({value: v, label: `${k} ${v}`}))
};
}
/* -------------------------------------------- */
/**
* Template data for a given font definition.
* @param {string} family The font family.
* @param {FontFamilyDefinition} definition The font family definition.
* @returns {object[]}
* @protected
*/
_getDataForDefinition(family, definition) {
const fonts = definition.fonts.length ? definition.fonts : [{}];
return fonts.map((f, i) => {
const data = {family, index: i};
if ( this.#isSelected(data) ) data.selected = true;
data.font = this.constructor._formatFont(family, f);
return data;
});
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("[contenteditable]").on("blur", this._onSubmit.bind(this));
html.find(".control").on("click", this._onClickControl.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
foundry.utils.mergeObject(this.object, formData);
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
await super.close(options);
if ( this.#fontsModified ) return SettingsConfig.reloadConfirm({world: true});
}
/* -------------------------------------------- */
/**
* Handle application controls.
* @param {MouseEvent} event The click event.
* @protected
*/
_onClickControl(event) {
switch ( event.currentTarget.dataset.action ) {
case "add": return this._onAddFont();
case "delete": return this._onDeleteFont(event);
case "select": return this._onSelectFont(event);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
this._updateFontFields();
return super._onChangeInput(event);
}
/* -------------------------------------------- */
/**
* Update available font fields based on the font type selected.
* @protected
*/
_updateFontFields() {
const type = this.form.elements.type.value;
const isSystemFont = type === this.constructor.FONT_TYPES.SYSTEM;
["weight", "style", "src"].forEach(name => {
const input = this.form.elements[name];
if ( input ) input.closest(".form-group")?.classList.toggle("hidden", isSystemFont);
});
this.setPosition();
}
/* -------------------------------------------- */
/**
* Add a new custom font definition.
* @protected
*/
async _onAddFont() {
const {family, src, weight, style, type} = this._getSubmitData();
const definitions = game.settings.get("core", this.constructor.SETTING);
definitions[family] ??= {editor: true, fonts: []};
const definition = definitions[family];
const count = type === this.constructor.FONT_TYPES.FILE ? definition.fonts.push({urls: [src], weight, style}) : 1;
await game.settings.set("core", this.constructor.SETTING, definitions);
await this.constructor.loadFont(family, definition);
this.#selected = {family, index: count - 1};
this.#fontsModified = true;
this.render(true);
}
/* -------------------------------------------- */
/**
* Delete a font.
* @param {MouseEvent} event The click event.
* @protected
*/
async _onDeleteFont(event) {
event.preventDefault();
event.stopPropagation();
const target = event.currentTarget.closest("[data-family]");
const {family, index} = target.dataset;
const definitions = game.settings.get("core", this.constructor.SETTING);
const definition = definitions[family];
if ( !definition ) return;
this.#fontsModified = true;
definition.fonts.splice(Number(index), 1);
if ( !definition.fonts.length ) delete definitions[family];
await game.settings.set("core", this.constructor.SETTING, definitions);
if ( this.#isSelected({family, index: Number(index)}) ) this.#selected = null;
this.render(true);
}
/* -------------------------------------------- */
/**
* Select a font to preview.
* @param {MouseEvent} event The click event.
* @protected
*/
_onSelectFont(event) {
const {family, index} = event.currentTarget.dataset;
this.#selected = {family, index: Number(index)};
this.render(true);
}
/* -------------------------------------------- */
/* Font Management Methods */
/* -------------------------------------------- */
/**
* Define the setting key where this world's font information will be stored.
* @type {string}
*/
static SETTING = "fonts";
/* -------------------------------------------- */
/**
* A list of fonts that were correctly loaded and are available for use.
* @type {Set<string>}
* @private
*/
static #available = new Set();
/* -------------------------------------------- */
/**
* Get the list of fonts that successfully loaded.
* @returns {string[]}
*/
static getAvailableFonts() {
return Array.from(this.#available);
}
/* -------------------------------------------- */
/**
* Get the list of fonts formatted for display with selectOptions.
* @returns {Object<string>}
*/
static getAvailableFontChoices() {
return this.getAvailableFonts().reduce((obj, f) => {
obj[f] = f;
return obj;
}, {});
}
/* -------------------------------------------- */
/**
* Load a font definition.
* @param {string} family The font family name (case-sensitive).
* @param {FontFamilyDefinition} definition The font family definition.
* @returns {Promise<boolean>} Returns true if the font was successfully loaded.
*/
static async loadFont(family, definition) {
const font = `1rem "${family}"`;
try {
for ( const font of definition.fonts ) {
const fontFace = this._createFontFace(family, font);
await fontFace.load();
document.fonts.add(fontFace);
}
await document.fonts.load(font);
} catch(err) {
console.warn(`Font family "${family}" failed to load: `, err);
return false;
}
if ( !document.fonts.check(font) ) {
console.warn(`Font family "${family}" failed to load.`);
return false;
}
if ( definition.editor ) this.#available.add(family);
return true;
}
/* -------------------------------------------- */
/**
* Ensure that fonts have loaded and are ready for use.
* Enforce a maximum timeout in milliseconds.
* Proceed after that point even if fonts are not yet available.
* @param {number} [ms=4500] The maximum time to spend loading fonts before proceeding.
* @returns {Promise<void>}
* @internal
*/
static async _loadFonts(ms=4500) {
const allFonts = this._collectDefinitions();
const promises = [];
for ( const definitions of allFonts ) {
for ( const [family, definition] of Object.entries(definitions) ) {
promises.push(this.loadFont(family, definition));
}
}
const timeout = new Promise(resolve => setTimeout(resolve, ms));
const ready = Promise.all(promises).then(() => document.fonts.ready);
return Promise.race([ready, timeout]).then(() => console.log(`${vtt} | Fonts loaded and ready.`));
}
/* -------------------------------------------- */
/**
* Collect all the font definitions and combine them.
* @returns {Object<FontFamilyDefinition>[]}
* @protected
*/
static _collectDefinitions() {
/**
* @deprecated since v10.
*/
const legacyFamilies = CONFIG._fontFamilies.reduce((obj, f) => {
obj[f] = {editor: true, fonts: []};
return obj;
}, {});
return [CONFIG.fontDefinitions, game.settings.get("core", this.SETTING), legacyFamilies];
}
/* -------------------------------------------- */
/**
* Create FontFace object from a FontDefinition.
* @param {string} family The font family name.
* @param {FontDefinition} font The font definition.
* @returns {FontFace}
* @protected
*/
static _createFontFace(family, font) {
const urls = font.urls.map(url => `url("${url}")`).join(", ");
return new FontFace(family, urls, font);
}
/* -------------------------------------------- */
/**
* Format a font definition for display.
* @param {string} family The font family.
* @param {FontDefinition} definition The font definition.
* @returns {string} The formatted definition.
* @private
*/
static _formatFont(family, definition) {
if ( foundry.utils.isEmpty(definition) ) return family;
const {weight, style} = definition;
const byWeight = Object.fromEntries(Object.entries(CONST.FONT_WEIGHTS).map(([k, v]) => [v, k]));
return `
${family},
<span style="font-weight: ${weight}">${byWeight[weight]} ${weight}</span>,
<span style="font-style: ${style}">${style.toLowerCase()}</span>
`;
}
}

View File

@@ -0,0 +1,372 @@
/**
* A tool for fine-tuning the grid in a Scene
* @param {Scene} scene The scene whose grid is being configured.
* @param {SceneConfig} sheet The Scene Configuration sheet that spawned this dialog.
* @param {FormApplicationOptions} [options] Application configuration options.
*/
class GridConfig extends FormApplication {
constructor(scene, sheet, ...args) {
super(scene, ...args);
/**
* Track the Scene Configuration sheet reference
* @type {SceneConfig}
*/
this.sheet = sheet;
}
/**
* The counter-factual dimensions being evaluated
* @type {Object}
*/
#dimensions = {};
/**
* A copy of the Scene source which can be restored when the configuration is closed.
* @type {object}
*/
#original;
/**
* A reference to the bound key handler function
* @type {Function}
* @private
*/
#keyHandler;
/**
* A reference to the bound mousewheel handler function
* @type {Function}
* @private
*/
#wheelHandler;
/**
* Saved visibility for some layers
* @type {object}
*/
#layersOriginalVisibility;
/**
* If we should redraw when closing this window
* @type {boolean}
*/
#redrawOnClose = false;
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "grid-config",
template: "templates/scene/grid-config.html",
title: game.i18n.localize("SCENES.GridConfigTool"),
width: 480,
height: "auto",
closeOnSubmit: true
});
}
/* -------------------------------------------- */
/** @override */
async _render(force, options) {
if ( !this.rendered ) this.#original = this.object.toObject();
await super._render(force, options);
if ( !this.object.background.src ) {
ui.notifications.warn("WARNING.GridConfigNoBG", {localize: true});
}
this.#layersOriginalVisibility = {};
for ( const layer of canvas.layers ) {
this.#layersOriginalVisibility[layer.name] = layer.visible;
layer.visible = ["GridLayer", "TilesLayer"].includes(layer.name);
}
this._refresh({
background: true,
grid: {color: 0xFF0000, alpha: 1.0}
});
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const tex = canvas.primary.background.texture;
return {
gridTypes: SceneConfig._getGridTypes(),
scale: tex ? this.object.width / tex.width : 1,
scene: this.object
};
}
/* -------------------------------------------- */
/** @override */
_getSubmitData(updateData = {}) {
const formData = super._getSubmitData(updateData);
const bg = canvas.primary.background;
const tex = bg ? bg.texture : {width: this.object.width, height: this.object.height};
formData.width = tex.width * formData.scale;
formData.height = tex.height * formData.scale;
return formData;
}
/* -------------------------------------------- */
/** @override */
async close(options={}) {
document.removeEventListener("keydown", this.#keyHandler);
document.removeEventListener("wheel", this.#wheelHandler);
this.#keyHandler = this.#wheelHandler = undefined;
await this.sheet.maximize();
// Restore layers original visibility
for ( const layer of canvas.layers ) {
layer.visible = this.#layersOriginalVisibility[layer.name];
}
if ( !options.force ) await this._reset();
return super.close(options);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
this.#keyHandler ||= this._onKeyDown.bind(this);
document.addEventListener("keydown", this.#keyHandler);
this.#wheelHandler ||= this._onWheel.bind(this);
document.addEventListener("wheel", this.#wheelHandler, {passive: false});
html.find('button[name="reset"]').click(this._reset.bind(this));
}
/* -------------------------------------------- */
/**
* Handle keyboard events.
* @param {KeyboardEvent} event The original keydown event
* @private
*/
_onKeyDown(event) {
const key = event.code;
const up = ["KeyW", "ArrowUp"];
const down = ["KeyS", "ArrowDown"];
const left = ["KeyA", "ArrowLeft"];
const right = ["KeyD", "ArrowRight"];
const moveKeys = up.concat(down).concat(left).concat(right);
if ( !moveKeys.includes(key) ) return;
// Increase the Scene scale on shift + up or down
if ( event.shiftKey ) {
event.preventDefault();
event.stopPropagation();
let delta = up.includes(key) ? 1 : (down.includes(key) ? -1 : 0);
this._scaleBackgroundSize(delta);
}
// Resize grid size on ALT
else if ( event.altKey ) {
event.preventDefault();
event.stopPropagation();
let delta = up.includes(key) ? 1 : (down.includes(key) ? -1 : 0);
this._scaleGridSize(delta);
}
// Shift grid position
else if ( !game.keyboard.hasFocus ) {
event.preventDefault();
event.stopPropagation();
if ( up.includes(key) ) this._shiftBackground({deltaY: -1});
else if ( down.includes(key) ) this._shiftBackground({deltaY: 1});
else if ( left.includes(key) ) this._shiftBackground({deltaX: -1});
else if ( right.includes(key) ) this._shiftBackground({deltaX: 1});
}
}
/* -------------------------------------------- */
/**
* Handle mousewheel events.
* @param {WheelEvent} event The original wheel event
* @private
*/
_onWheel(event) {
if ( event.deltaY === 0 ) return;
const normalizedDelta = -Math.sign(event.deltaY);
const activeElement = document.activeElement;
const noShiftAndAlt = !(event.shiftKey || event.altKey);
const focus = game.keyboard.hasFocus && document.hasFocus;
// Increase/Decrease the Scene scale
if ( event.shiftKey || (!event.altKey && focus && activeElement.name === "scale") ) {
event.preventDefault();
event.stopImmediatePropagation();
this._scaleBackgroundSize(normalizedDelta);
}
// Increase/Decrease the Grid scale
else if ( event.altKey || (focus && activeElement.name === "grid.size") ) {
event.preventDefault();
event.stopImmediatePropagation();
this._scaleGridSize(normalizedDelta);
}
// If no shift or alt key are pressed
else if ( noShiftAndAlt && focus ) {
// Increase/Decrease the background x offset
if ( activeElement.name === "background.offsetX" ) {
event.preventDefault();
event.stopImmediatePropagation();
this._shiftBackground({deltaX: normalizedDelta});
}
// Increase/Decrease the background y offset
else if ( activeElement.name === "background.offsetY" ) {
event.preventDefault();
event.stopImmediatePropagation();
this._shiftBackground({deltaY: normalizedDelta});
}
}
}
/* -------------------------------------------- */
/** @override */
async _onChangeInput(event) {
event.preventDefault();
const formData = this._getSubmitData();
const {type, size} = this.object.grid;
this.object.updateSource(formData);
if ( (this.object.grid.type !== type) || (this.object.grid.size !== size) ) {
this.#redrawOnClose = true;
canvas.grid.grid.destroy(true);
await canvas.grid._draw({
type: this.object.grid.type,
dimensions: this.object.getDimensions()
});
}
this._refresh({
background: true,
grid: foundry.utils.mergeObject(this.object.grid, {color: 0xFF0000, alpha: 1.0})
});
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
this.object.updateSource(this.#original);
formData.width = Math.round(this.#dimensions.sceneWidth);
formData.height = Math.round(this.#dimensions.sceneHeight);
const delta = foundry.utils.diffObject(foundry.utils.flattenObject(this.object), formData);
if ( ["width", "height", "padding", "background.offsetX", "background.offsetY", "grid.size", "grid.type"].some(k => k in delta) ) {
const confirm = await Dialog.confirm({
title: game.i18n.localize("SCENES.DimensionChangeTitle"),
content: `<p>${game.i18n.localize("SCENES.DimensionChangeWarning")}</p>`
});
// Update only if the dialog is confirmed
if ( confirm ) return await this.object.update(formData, {fromSheet: true});
}
// We need to reset if the dialog was not confirmed OR if we don't need to update
return await this._reset();
}
/* -------------------------------------------- */
/* Previewing and Updating Functions */
/* -------------------------------------------- */
/**
* Temporarily refresh the display of the BackgroundLayer and GridLayer for the new pending dimensions
* @param {object} options Options which define how the refresh is performed
* @param {boolean} [options.background] Refresh the background display?
* @param {object} [options.grid] Refresh the grid display?
* @private
*/
_refresh({background=false, grid}) {
const bg = canvas.primary.background;
const fg = canvas.primary.foreground;
const d = this.#dimensions = this.object.getDimensions();
// Update the background and foreground sizing
if ( background && bg ) {
bg.position.set(d.sceneX, d.sceneY);
bg.width = d.sceneWidth;
bg.height = d.sceneHeight;
grid ||= {};
}
if ( background && fg ) {
fg.position.set(d.sceneX, d.sceneY);
fg.width = d.sceneWidth;
fg.height = d.sceneHeight;
}
// Update the grid layer
if ( grid ) {
const {type, color, alpha} = {...this.object.grid, ...grid};
canvas.grid.grid.draw({dimensions: d, type, color: Color.from(color).valueOf(), alpha});
canvas.stage.hitArea = d.rect;
}
}
/* -------------------------------------------- */
/**
* Reset the scene back to its original settings
* @private
*/
async _reset() {
this.object.updateSource(this.#original);
if ( this.#redrawOnClose ) {
this.#redrawOnClose = false;
await canvas.draw();
}
return this._refresh({background: true, grid: this.object.grid});
}
/* -------------------------------------------- */
/**
* Scale the background size relative to the grid size
* @param {number} delta The directional change in background size
* @private
*/
_scaleBackgroundSize(delta) {
const scale = Math.round((parseFloat(this.form.scale.value) + delta * 0.001) * 1000) / 1000;
this.form.scale.value = Math.clamped(scale, 0.25, 10.0);
this.form.scale.dispatchEvent(new Event("change", {bubbles: true}));
}
/* -------------------------------------------- */
/**
* Scale the grid size relative to the background image.
* When scaling the grid size in this way, constrain the allowed values between 50px and 300px.
* @param {number} delta The grid size in pixels
* @private
*/
_scaleGridSize(delta) {
const gridSize = this.form.elements["grid.size"];
gridSize.value = Math.clamped(gridSize.valueAsNumber + delta, 50, 300);
gridSize.dispatchEvent(new Event("change", {bubbles: true}));
}
/* -------------------------------------------- */
/**
* Shift the background image relative to the grid layer
* @param {object} position The position configuration to preview
* @param {number} position.deltaX The number of pixels to shift in the x-direction
* @param {number} position.deltaY The number of pixels to shift in the y-direction
* @private
*/
_shiftBackground({deltaX=0, deltaY=0}={}) {
const ox = this.form["background.offsetX"];
ox.value = parseInt(this.form["background.offsetX"].value) + deltaX;
this.form["background.offsetY"].value = parseInt(this.form["background.offsetY"].value) + deltaY;
ox.dispatchEvent(new Event("change", {bubbles: true}));
}
}

View File

@@ -0,0 +1,261 @@
/**
* @typedef {FormApplicationOptions} ImagePopoutOptions
* @property {string} [caption] Caption text to display below the image.
* @property {string|null} [uuid=null] The UUID of some related {@link Document}.
* @property {boolean} [showTitle] Force showing or hiding the title.
*/
/**
* An Image Popout Application which features a single image in a lightbox style frame.
* Furthermore, this application allows for sharing the display of an image with other connected players.
* @param {string} src The image URL.
* @param {ImagePopoutOptions} [options] Application configuration options.
*
* @example Creating an Image Popout
* ```js
* // Construct the Application instance
* const ip = new ImagePopout("path/to/image.jpg", {
* title: "My Featured Image",
* uuid: game.actors.getName("My Hero").uuid
* });
*
* // Display the image popout
* ip.render(true);
*
* // Share the image with other connected players
* ip.share();
* ```
*/
class ImagePopout extends FormApplication {
/**
* A cached reference to the related Document.
* @type {ClientDocument}
*/
#related;
/* -------------------------------------------- */
/**
* Whether the application should display video content.
* @type {boolean}
*/
get isVideo() {
return VideoHelper.hasVideoExtension(this.object);
}
/* -------------------------------------------- */
/**
* @override
* @returns {ImagePopoutOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/apps/image-popout.html",
classes: ["image-popout", "dark"],
resizable: true,
caption: undefined,
uuid: null
});
}
/* -------------------------------------------- */
/** @override */
get title() {
return this.isTitleVisible() ? super.title : "";
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
return {
image: this.object,
options: this.options,
title: this.title,
caption: this.options.caption,
showTitle: this.isTitleVisible(),
isVideo: this.isVideo
};
}
/* -------------------------------------------- */
/**
* Test whether the title of the image popout should be visible to the user
* @returns {boolean}
*/
isTitleVisible() {
return this.options.showTitle ?? this.#related?.testUserPermission(game.user, "LIMITED") ?? true;
}
/* -------------------------------------------- */
/**
* Provide a reference to the Document referenced by this popout, if one exists
* @returns {Promise<ClientDocument>}
*/
async getRelatedObject() {
if ( this.options.uuid && !this.#related ) this.#related = await fromUuid(this.options.uuid);
return this.#related;
}
/* -------------------------------------------- */
/** @override */
async _render(...args) {
await this.getRelatedObject();
this.position = await this.constructor.getPosition(this.object);
return super._render(...args);
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// For some reason, unless we do this, videos will not autoplay the first time the popup is opened in a session,
// even if the user has made a gesture.
if ( this.isVideo ) html.find("video")[0]?.play();
}
/* -------------------------------------------- */
/** @override */
_getHeaderButtons() {
const buttons = super._getHeaderButtons();
if ( game.user.isGM ) {
buttons.unshift({
label: "JOURNAL.ActionShow",
class: "share-image",
icon: "fas fa-eye",
onclick: () => this.shareImage()
});
}
return buttons;
}
/* -------------------------------------------- */
/* Helper Methods
/* -------------------------------------------- */
/**
* Determine the correct position and dimensions for the displayed image
* @param {string} img The image URL.
* @returns {Object} The positioning object which should be used for rendering
*/
static async getPosition(img) {
if ( !img ) return { width: 480, height: 480 };
let w;
let h;
try {
[w, h] = this.isVideo ? await this.getVideoSize(img) : await this.getImageSize(img);
} catch(err) {
return { width: 480, height: 480 };
}
const position = {};
// Compare the image aspect ratio to the screen aspect ratio
const sr = window.innerWidth / window.innerHeight;
const ar = w / h;
// The image is constrained by the screen width, display at max width
if ( ar > sr ) {
position.width = Math.min(w * 2, window.innerWidth - 80);
position.height = position.width / ar;
}
// The image is constrained by the screen height, display at max height
else {
position.height = Math.min(h * 2, window.innerHeight - 120);
position.width = position.height * ar;
}
return position;
}
/* -------------------------------------------- */
/**
* Determine the Image dimensions given a certain path
* @param {string} path The image source.
* @returns {Promise<[number, number]>}
*/
static getImageSize(path) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = function() {
resolve([this.width, this.height]);
};
img.onerror = reject;
img.src = path;
});
}
/* -------------------------------------------- */
/**
* Determine the dimensions of the given video file.
* @param {string} src The URL to the video.
* @returns {Promise<[number, number]>}
*/
static getVideoSize(src) {
return new Promise((resolve, reject) => {
const video = document.createElement("video");
video.onloadedmetadata = () => {
video.onloadedmetadata = null;
resolve([video.videoWidth, video.videoHeight]);
};
video.onerror = reject;
video.src = src;
});
}
/* -------------------------------------------- */
/**
* @typedef {object} ShareImageConfig
* @property {string} image The image URL to share.
* @property {string} title The image title.
* @property {string} [uuid] The UUID of a Document related to the image, used to determine permission to see
* the image title.
* @property {boolean} [showTitle] If this is provided, the permissions of the related Document will be ignored and
* the title will be shown based on this parameter.
* @property {string[]} [users] A list of user IDs to show the image to.
*/
/**
* Share the displayed image with other connected Users
* @param {ShareImageConfig} [options]
*/
shareImage(options={}) {
options = foundry.utils.mergeObject(this.options, options, { inplace: false });
game.socket.emit("shareImage", {
image: this.object,
title: options.title,
caption: options.caption,
uuid: options.uuid,
showTitle: options.showTitle,
users: Array.isArray(options.users) ? options.users : undefined
});
ui.notifications.info(game.i18n.format("JOURNAL.ActionShowSuccess", {
mode: "image",
title: options.title,
which: "all"
}));
}
/* -------------------------------------------- */
/**
* Handle a received request to display an image.
* @param {ShareImageConfig} config The image configuration data.
* @returns {ImagePopout}
* @internal
*/
static _handleShareImage({image, title, caption, uuid, showTitle}={}) {
const ip = new ImagePopout(image, {title, caption, uuid, showTitle});
ip.render(true);
return ip;
}
}

View File

@@ -0,0 +1,58 @@
/**
* The Application responsible for displaying and editing a single Item document.
* @param {Item} item The Item instance being displayed within the sheet.
* @param {DocumentSheetOptions} [options] Additional application configuration options.
*/
class ItemSheet extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/sheets/item-sheet.html",
width: 500,
closeOnSubmit: false,
submitOnClose: true,
submitOnChange: true,
resizable: true,
baseApplication: "ItemSheet",
id: "item",
secrets: [{parentSelector: ".editor"}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return this.item.name;
}
/* -------------------------------------------- */
/**
* A convenience reference to the Item document
* @type {Item}
*/
get item() {
return this.object;
}
/* -------------------------------------------- */
/**
* The Actor instance which owns this item. This may be null if the item is unowned.
* @type {Actor}
*/
get actor() {
return this.item.actor;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const data = super.getData(options);
data.item = data.document;
return data;
}
}

View File

@@ -0,0 +1,599 @@
/**
* The Application responsible for displaying and editing a single JournalEntryPage document.
* @extends {DocumentSheet}
* @param {JournalEntryPage} object The JournalEntryPage instance which is being edited.
* @param {DocumentSheetOptions} [options] Application options.
*/
class JournalPageSheet extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "journal-sheet", "journal-entry-page"],
viewClasses: [],
width: 600,
height: 680,
resizable: true,
closeOnSubmit: false,
submitOnClose: true,
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
includeTOC: true
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get template() {
return `templates/journal/page-${this.document.type}-${this.isEditable ? "edit" : "view"}.html`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return this.object.permission ? this.object.name : "";
}
/* -------------------------------------------- */
/**
* The table of contents for this JournalTextPageSheet.
* @type {Object<JournalEntryPageHeading>}
*/
toc = {};
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
return foundry.utils.mergeObject(super.getData(options), {
headingLevels: Object.fromEntries(Array.fromRange(3, 1).map(level => {
return [level, game.i18n.format("JOURNALENTRYPAGE.Level", {level})];
}))
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
await loadTemplates({
journalEntryPageHeader: "templates/journal/parts/page-header.html",
journalEntryPageFooter: "templates/journal/parts/page-footer.html"
});
const html = await super._renderInner(...args);
if ( this.options.includeTOC ) this.toc = JournalEntryPage.implementation.buildTOC(html.get());
return html;
}
/* -------------------------------------------- */
/* Text Secrets Management */
/* -------------------------------------------- */
/** @inheritdoc */
_getSecretContent(secret) {
return this.object.text.content;
}
/* -------------------------------------------- */
/** @inheritdoc */
_updateSecret(secret, content) {
return this.object.update({"text.content": content});
}
/* -------------------------------------------- */
/* Text Editor Integration */
/* -------------------------------------------- */
/** @inheritdoc */
async activateEditor(name, options={}, initialContent="") {
options.fitToSize = true;
options.relativeLinks = true;
const editor = await super.activateEditor(name, options, initialContent);
this.form.querySelector('[role="application"]')?.style.removeProperty("height");
return editor;
}
/* -------------------------------------------- */
/**
* Update the parent sheet if it is open when the server autosaves the contents of this editor.
* @param {string} html The updated editor contents.
*/
onAutosave(html) {
this.object.parent?.sheet?.render(false);
}
/* -------------------------------------------- */
/**
* Update the UI appropriately when receiving new steps from another client.
*/
onNewSteps() {
this.form.querySelectorAll('[data-action="save-html"]').forEach(el => el.disabled = true);
}
}
/**
* The Application responsible for displaying and editing a single JournalEntryPage text document.
* @extends {JournalPageSheet}
*/
class JournalTextPageSheet extends JournalPageSheet {
/**
* Bi-directional HTML <-> Markdown converter.
* @type {showdown.Converter}
* @protected
*/
static _converter = (() => {
Object.entries(CONST.SHOWDOWN_OPTIONS).forEach(([k, v]) => showdown.setOption(k, v));
return new showdown.Converter();
})();
/* -------------------------------------------- */
/**
* Declare the format that we edit text content in for this sheet so we can perform conversions as necessary.
* @type {number}
*/
static get format() {
return CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.classes.push("text");
options.secrets.push({parentSelector: "section"});
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const data = super.getData(options);
this._convertFormats(data);
data.editor = {
engine: "prosemirror",
collaborate: true,
content: await TextEditor.enrichHTML(data.document.text.content, {
relativeTo: this.object,
secrets: this.object.isOwner,
async: true
})
};
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
Object.values(this.editors).forEach(ed => {
if ( ed.instance ) ed.instance.destroy();
});
return super.close(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
if ( !this.#canRender(options.resync) ) return this.maximize().then(() => this.bringToTop());
return super._render(force, options);
}
/* -------------------------------------------- */
/**
* Suppress re-rendering the sheet in cases where an active editor has unsaved work.
* In such cases we rely upon collaborative editing to save changes and re-render.
* @param {boolean} [resync] Was the application instructed to re-sync?
* @returns {boolean} Should a render operation be allowed?
*/
#canRender(resync) {
if ( resync || (this._state !== Application.RENDER_STATES.RENDERED) || !this.isEditable ) return true;
return !this.isEditorDirty();
}
/* -------------------------------------------- */
/**
* Determine if any editors are dirty.
* @returns {boolean}
*/
isEditorDirty() {
for ( const editor of Object.values(this.editors) ) {
if ( editor.active && editor.instance?.isDirty() ) return true;
}
return false;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
if ( (this.constructor.format === CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML) && this.isEditorDirty() ) {
// Clear any stored markdown so it can be re-converted.
formData["text.markdown"] = "";
formData["text.format"] = CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
}
return super._updateObject(event, formData);
}
/* -------------------------------------------- */
/**
* Lazily convert text formats if we detect the document being saved in a different format.
* @param {object} renderData Render data.
* @protected
*/
_convertFormats(renderData) {
const formats = CONST.JOURNAL_ENTRY_PAGE_FORMATS;
const text = this.object.text;
if ( (this.constructor.format === formats.MARKDOWN) && text.content?.length && !text.markdown?.length ) {
// We've opened an HTML document in a markdown editor, so we need to convert the HTML to markdown for editing.
renderData.data.text.markdown = this.constructor._converter.makeMarkdown(text.content.trim());
}
}
}
/* -------------------------------------------- */
/**
* The Application responsible for displaying and editing a single JournalEntryPage image document.
* @extends {JournalPageSheet}
*/
class JournalImagePageSheet extends JournalPageSheet {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.classes.push("image");
options.height = "auto";
return options;
}
}
/* -------------------------------------------- */
/**
* The Application responsible for displaying and editing a single JournalEntryPage video document.
* @extends {JournalPageSheet}
*/
class JournalVideoPageSheet extends JournalPageSheet {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.classes.push("video");
options.height = "auto";
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
return foundry.utils.mergeObject(super.getData(options), {
flexRatio: !this.object.video.width && !this.object.video.height,
isYouTube: game.video.isYouTubeURL(this.object.src),
timestamp: this._timestampToTimeComponents(this.object.video.timestamp),
yt: {
id: `youtube-${foundry.utils.randomID()}`,
url: game.video.getYouTubeEmbedURL(this.object.src, this._getYouTubeVars())
}
});
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
if ( this.isEditable ) return;
// The below listeners are only for when the video page is being viewed, not edited.
const iframe = html.find("iframe")[0];
if ( iframe ) game.video.getYouTubePlayer(iframe.id, {
events: {
onStateChange: event => {
if ( event.data === YT.PlayerState.PLAYING ) event.target.setVolume(this.object.video.volume * 100);
}
}
}).then(player => {
if ( this.object.video.timestamp ) player.seekTo(this.object.video.timestamp, true);
});
const video = html.parent().find("video")[0];
if ( video ) {
video.addEventListener("loadedmetadata", () => {
video.volume = this.object.video.volume;
if ( this.object.video.timestamp ) video.currentTime = this.object.video.timestamp;
});
}
}
/* -------------------------------------------- */
/**
* Get the YouTube player parameters depending on whether the sheet is being viewed or edited.
* @returns {object}
* @protected
*/
_getYouTubeVars() {
const vars = {playsinline: 1, modestbranding: 1};
if ( !this.isEditable ) {
vars.controls = this.object.video.controls ? 1 : 0;
vars.autoplay = this.object.video.autoplay ? 1 : 0;
vars.loop = this.object.video.loop ? 1 : 0;
if ( this.object.video.timestamp ) vars.start = this.object.video.timestamp;
}
return vars;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const data = super._getSubmitData(updateData);
data["video.timestamp"] = this._timeComponentsToTimestamp(foundry.utils.expandObject(data).timestamp);
["h", "m", "s"].forEach(c => delete data[`timestamp.${c}`]);
return data;
}
/* -------------------------------------------- */
/**
* Convert time components to a timestamp in seconds.
* @param {{[h]: number, [m]: number, [s]: number}} components The time components.
* @returns {number} The timestamp, in seconds.
* @protected
*/
_timeComponentsToTimestamp({h=0, m=0, s=0}={}) {
return (h * 3600) + (m * 60) + s;
}
/* -------------------------------------------- */
/**
* Convert a timestamp in seconds into separate time components.
* @param {number} timestamp The timestamp, in seconds.
* @returns {{[h]: number, [m]: number, [s]: number}} The individual time components.
* @protected
*/
_timestampToTimeComponents(timestamp) {
if ( !timestamp ) return {};
const components = {};
const h = Math.floor(timestamp / 3600);
if ( h ) components.h = h;
const m = Math.floor((timestamp % 3600) / 60);
if ( m ) components.m = m;
components.s = timestamp - (h * 3600) - (m * 60);
return components;
}
}
/* -------------------------------------------- */
/**
* The Application responsible for displaying and editing a single JournalEntryPage PDF document.
* @extends {JournalPageSheet}
*/
class JournalPDFPageSheet extends JournalPageSheet {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.classes.push("pdf");
options.height = "auto";
return options;
}
/**
* Maintain a cache of PDF sizes to avoid making HEAD requests every render.
* @type {Object<number>}
* @protected
*/
static _sizes = {};
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("> button").on("click", this._onLoadPDF.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
return foundry.utils.mergeObject(super.getData(options), {
params: this._getViewerParams()
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
const html = await super._renderInner(...args);
const pdfLoader = html.closest(".load-pdf")[0];
if ( this.isEditable || !pdfLoader ) return html;
let size = this.constructor._sizes[this.object.src];
if ( size === undefined ) {
const res = await fetch(this.object.src, {method: "HEAD"}).catch(() => {});
this.constructor._sizes[this.object.src] = size = Number(res?.headers.get("content-length"));
}
if ( !isNaN(size) ) {
const mb = (size / 1024 / 1024).toFixed(2);
const span = document.createElement("span");
span.classList.add("hint");
span.textContent = ` (${mb} MB)`;
pdfLoader.querySelector("button").appendChild(span);
}
return html;
}
/* -------------------------------------------- */
/**
* Handle a request to load a PDF.
* @param {MouseEvent} event The triggering event.
* @protected
*/
_onLoadPDF(event) {
const target = event.currentTarget.parentElement;
const frame = document.createElement("iframe");
frame.src = `scripts/pdfjs/web/viewer.html?${this._getViewerParams()}`;
target.replaceWith(frame);
}
/* -------------------------------------------- */
/**
* Retrieve parameters to pass to the PDF viewer.
* @returns {URLSearchParams}
* @protected
*/
_getViewerParams() {
const params = new URLSearchParams();
if ( this.object.src ) {
const src = URL.parseSafe(this.object.src) ? this.object.src : foundry.utils.getRoute(this.object.src);
params.append("file", src);
}
return params;
}
}
/**
* A subclass of {@link JournalTextPageSheet} that implements a markdown editor for editing the text content.
* @extends {JournalTextPageSheet}
*/
class MarkdownJournalPageSheet extends JournalTextPageSheet {
/**
* Store the dirty flag for this editor.
* @type {boolean}
* @protected
*/
_isDirty = false;
/* -------------------------------------------- */
/** @inheritdoc */
static get format() {
return CONST.JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.dragDrop = [{dropSelector: "textarea"}];
options.classes.push("markdown");
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
get template() {
if ( this.isEditable ) return "templates/journal/page-markdown-edit.html";
return super.template;
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const data = await super.getData(options);
data.markdownFormat = CONST.JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("textarea").on("keypress paste", () => this._isDirty = true);
}
/* -------------------------------------------- */
/** @inheritdoc */
isEditorDirty() {
return this._isDirty;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
// Do not persist the markdown conversion if the contents have not been edited.
if ( !this.isEditorDirty() ) {
delete formData["text.markdown"];
delete formData["text.format"];
}
return super._updateObject(event, formData);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDrop(event) {
event.preventDefault();
const eventData = TextEditor.getDragEventData(event);
return this._onDropContentLink(eventData);
}
/* -------------------------------------------- */
/**
* Handle dropping a content link onto the editor.
* @param {object} eventData The parsed event data.
* @protected
*/
async _onDropContentLink(eventData) {
const link = await TextEditor.getContentLink(eventData, {relativeTo: this.object});
if ( !link ) return;
const editor = this.form.elements["text.markdown"];
const content = editor.value;
editor.value = content.substring(0, editor.selectionStart) + link + content.substring(editor.selectionStart);
this._isDirty = true;
}
}
/**
* A subclass of {@link JournalTextPageSheet} that implements a TinyMCE editor.
* @extends {JournalTextPageSheet}
*/
class JournalTextTinyMCESheet extends JournalTextPageSheet {
/** @inheritdoc */
async getData(options={}) {
const data = await super.getData(options);
data.editor.engine = "tinymce";
data.editor.collaborate = false;
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options = {}) {
return JournalPageSheet.prototype.close.call(this, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
return JournalPageSheet.prototype._render.call(this, force, options);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
/**
* A Macro configuration sheet
* @extends {DocumentSheet}
*
* @param {Macro} object The Macro Document which is being configured
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class MacroConfig extends DocumentSheet {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "macro-sheet"],
template: "templates/sheets/macro-config.html",
width: 560,
height: 480,
resizable: true
});
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const data = super.getData();
data.macroTypes = game.documentTypes.Macro.reduce((obj, t) => {
if ( t === CONST.BASE_DOCUMENT_TYPE ) return obj;
if ( (t === "script") && !game.user.can("MACRO_SCRIPT") ) return obj;
obj[t] = game.i18n.localize(CONFIG.Macro.typeLabels[t]);
return obj;
}, {});
data.macroScopes = CONST.MACRO_SCOPES;
return data;
}
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find("button.execute").click(this._onExecute.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
_disableFields(form) {
super._disableFields(form);
if ( this.object.canExecute ) form.querySelector("button.execute").disabled = false;
}
/* -------------------------------------------- */
/**
* Save and execute the macro using the button on the configuration sheet
* @param {MouseEvent} event The originating click event
* @return {Promise<void>}
* @private
*/
async _onExecute(event) {
event.preventDefault();
await this._onSubmit(event, {preventClose: true}); // Submit pending changes
this.object.execute(); // Execute the macro
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
if ( !this.object.id ) {
return Macro.create(formData);
} else {
return super._updateObject(event, formData);
}
}
}

View File

@@ -0,0 +1,40 @@
/**
* The Application responsible for configuring a single MeasuredTemplate document within a parent Scene.
* @param {MeasuredTemplate} object The {@link MeasuredTemplate} being configured.
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class MeasuredTemplateConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "template-config",
classes: ["sheet", "template-sheet"],
title: "TEMPLATE.MeasuredConfig",
template: "templates/scene/template-config.html",
width: 400
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData() {
return foundry.utils.mergeObject(super.getData(), {
templateTypes: CONFIG.MeasuredTemplate.types,
gridUnits: canvas.scene.grid.units || game.i18n.localize("GridUnits"),
submitText: `TEMPLATE.Submit${this.options.preview ? "Create" : "Update"}`
});
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
if ( this.object.id ) {
formData.id = this.object.id;
return this.object.update(formData);
}
return this.object.constructor.create(formData);
}
}

View File

@@ -0,0 +1,117 @@
/**
* A generic application for configuring permissions for various Document types
* @extends {DocumentSheet}
*/
class DocumentOwnershipConfig extends DocumentSheet {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "permission",
template: "templates/apps/ownership.html",
width: 400
});
}
/* -------------------------------------------- */
/** @override */
get title() {
return `${game.i18n.localize("OWNERSHIP.Title")}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const isFolder = this.document instanceof Folder;
const isEmbedded = this.document.isEmbedded;
const ownership = this.document.ownership;
if ( !ownership && !isFolder ) {
throw new Error(`The ${this.document.documentName} document does not contain ownership data`);
}
// User permission levels
const playerLevels = Object.entries(CONST.DOCUMENT_META_OWNERSHIP_LEVELS).map(([name, level]) => {
return {level, label: game.i18n.localize(`OWNERSHIP.${name}`)};
});
if ( !isFolder ) playerLevels.pop();
for ( let [name, level] of Object.entries(CONST.DOCUMENT_OWNERSHIP_LEVELS) ) {
if ( (level < 0) && !isEmbedded ) continue;
playerLevels.push({level, label: game.i18n.localize(`OWNERSHIP.${name}`)});
}
// Default permission levels
const defaultLevels = foundry.utils.deepClone(playerLevels);
defaultLevels.shift();
// Player users
const users = game.users.map(user => {
return {
user,
level: isFolder ? CONST.DOCUMENT_META_OWNERSHIP_LEVELS.NOCHANGE : ownership[user.id],
isAuthor: this.document.author === user
};
});
// Construct and return the data object
return {
currentDefault: ownership?.default ?? playerLevels[0],
instructions: game.i18n.localize(isFolder ? "OWNERSHIP.HintFolder" : "OWNERSHIP.HintDocument"),
defaultLevels,
playerLevels,
isFolder,
users
};
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
event.preventDefault();
if ( !game.user.isGM ) throw new Error("You do not have the ability to configure permissions.");
// Collect new ownership levels from the form data
const metaLevels = CONST.DOCUMENT_META_OWNERSHIP_LEVELS;
const isFolder = this.document instanceof Folder;
const omit = isFolder ? metaLevels.NOCHANGE : metaLevels.DEFAULT;
const ownershipLevels = {};
for ( let [user, level] of Object.entries(formData) ) {
if ( level === omit ) {
delete ownershipLevels[user];
continue;
}
ownershipLevels[user] = level;
}
// Update all documents in a Folder
if ( this.document instanceof Folder ) {
const cls = getDocumentClass(this.document.type);
const updates = this.document.contents.map(d => {
const ownership = foundry.utils.deepClone(d.ownership);
for ( let [k, v] of Object.entries(ownershipLevels) ) {
if ( v === metaLevels.DEFAULT ) delete ownership[k];
else ownership[k] = v;
}
return {_id: d.id, ownership};
});
return cls.updateDocuments(updates, {diff: false, recursive: false, noHook: true});
}
// Update a single Document
return this.document.update({ownership: ownershipLevels}, {diff: false, recursive: false, noHook: true});
}
}
/**
* @deprecated since v10
* @ignore
*/
class PermissionControl extends DocumentOwnershipConfig {
constructor(...args) {
super(...args);
foundry.utils.logCompatibilityWarning("You are constructing the PermissionControl class which has been renamed " +
"to DocumentOwnershipConfig", {since: 10, until: 12});
}
}

View File

@@ -0,0 +1,76 @@
/**
* The Application responsible for configuring a single Playlist document.
* @extends {DocumentSheet}
* @param {Playlist} object The {@link Playlist} to configure.
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class PlaylistConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.id = "playlist-config";
options.template = "templates/playlist/playlist-config.html";
options.width = 360;
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return `${game.i18n.localize("PLAYLIST.Edit")}: ${this.object.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const data = super.getData(options);
data.modes = Object.entries(CONST.PLAYLIST_MODES).reduce((obj, e) => {
const [name, value] = e;
obj[value] = game.i18n.localize(`PLAYLIST.Mode${name.titleCase()}`);
return obj;
}, {});
data.sorting = Object.entries(CONST.PLAYLIST_SORT_MODES).reduce((obj, [name, value]) => {
obj[value] = game.i18n.localize(`PLAYLIST.Sort${name.titleCase()}`);
return obj;
}, {});
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getFilePickerOptions(event) {
const options = super._getFilePickerOptions(event);
options.allowUpload = false;
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onSelectFile(selection, filePicker) {
if ( filePicker.button.dataset.target !== "importPath" ) return;
const contents = await FilePicker.browse(filePicker.activeSource, filePicker.result.target, {
extensions: Object.keys(CONST.AUDIO_FILE_EXTENSIONS).map(ext => `.${ext.toLowerCase()}`),
bucket: filePicker.result.bucket
});
const playlist = this.object;
const currentSources = new Set(playlist.sounds.map(s => s.path));
const toCreate = contents.files.reduce((arr, src) => {
if ( !AudioHelper.hasAudioExtension(src) || currentSources.has(src) ) return arr;
const soundData = { name: AudioHelper.getDefaultSoundName(src), path: src };
arr.push(soundData);
return arr;
}, []);
if ( toCreate.length ) {
ui.playlists._expanded.add(playlist.id);
return playlist.createEmbeddedDocuments("PlaylistSound", toCreate);
} else {
const warning = game.i18n.format("PLAYLIST.BulkImportWarning", {path: filePicker.target});
return ui.notifications.warn(warning);
}
}
}

View File

@@ -0,0 +1,72 @@
/**
* The Application responsible for configuring a single PlaylistSound document within a parent Playlist.
* @extends {DocumentSheet}
*
* @param {PlaylistSound} sound The PlaylistSound document being configured
* @param {DocumentSheetOptions} [options] Additional application rendering options
*/
class PlaylistSoundConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "track-config",
template: "templates/playlist/sound-config.html",
width: 360
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
if ( !this.object.id ) return `${game.i18n.localize("PLAYLIST.SoundCreate")}: ${this.object.parent.name}`;
return `${game.i18n.localize("PLAYLIST.SoundEdit")}: ${this.object.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
if ( !this.document.id ) context.data.name = "";
context.lvolume = AudioHelper.volumeToInput(this.document.volume);
return context;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find('input[name="path"]').change(this._onSourceChange.bind(this));
return html;
}
/* -------------------------------------------- */
/**
* Auto-populate the track name using the provided filename, if a name is not already set
* @param {Event} event
* @private
*/
_onSourceChange(event) {
event.preventDefault();
const field = event.target;
const form = field.form;
if ( !form.name.value ) {
form.name.value = AudioHelper.getDefaultSoundName(field.value);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
formData["volume"] = AudioHelper.inputToVolume(formData["lvolume"]);
if (this.object.id) return this.object.update(formData);
return this.object.constructor.create(formData, {parent: this.object.parent});
}
}

View File

@@ -0,0 +1,447 @@
/**
* The Application responsible for displaying and editing a single RollTable document.
* @param {RollTable} table The RollTable document being configured
* @param {DocumentSheetOptions} [options] Additional application configuration options
*/
class RollTableConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "roll-table-config"],
template: "templates/sheets/roll-table-config.html",
width: 720,
height: "auto",
closeOnSubmit: false,
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
scrollY: ["table.table-results"],
dragDrop: [{dragSelector: null, dropSelector: null}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return `${game.i18n.localize("TABLE.SheetTitle")}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const context = super.getData(options);
context.descriptionHTML = await TextEditor.enrichHTML(this.object.description, {async: true, secrets: this.object.isOwner});
const results = this.document.results.map(result => {
result = result.toObject(false);
result.isText = result.type === CONST.TABLE_RESULT_TYPES.TEXT;
result.isDocument = result.type === CONST.TABLE_RESULT_TYPES.DOCUMENT;
result.isCompendium = result.type === CONST.TABLE_RESULT_TYPES.COMPENDIUM;
result.img = result.img || CONFIG.RollTable.resultIcon;
result.text = TextEditor.decodeHTML(result.text);
return result;
});
results.sort((a, b) => a.range[0] - b.range[0]);
// Merge data and return;
return foundry.utils.mergeObject(context, {
results: results,
resultTypes: Object.entries(CONST.TABLE_RESULT_TYPES).reduce((obj, v) => {
obj[v[1]] = v[0].titleCase();
return obj;
}, {}),
documentTypes: CONST.COMPENDIUM_DOCUMENT_TYPES,
compendiumPacks: Array.from(game.packs.keys())
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// Roll the Table
const button = html.find("button.roll");
button.click(this._onRollTable.bind(this));
button[0].disabled = false;
// The below options require an editable sheet
if (!this.isEditable) return;
// Reset the Table
html.find("button.reset").click(this._onResetTable.bind(this));
// Save the sheet on checkbox change
html.find('input[type="checkbox"]').change(this._onSubmit.bind(this));
// Create a new Result
html.find("a.create-result").click(this._onCreateResult.bind(this));
// Delete a Result
html.find("a.delete-result").click(this._onDeleteResult.bind(this));
// Lock or Unlock a Result
html.find("a.lock-result").click(this._onLockResult.bind(this));
// Modify Result Type
html.find(".result-type select").change(this._onChangeResultType.bind(this));
// Re-normalize Table Entries
html.find(".normalize-results").click(this._onNormalizeResults.bind(this));
}
/* -------------------------------------------- */
/**
* Handle creating a TableResult in the RollTable document
* @param {MouseEvent} event The originating mouse event
* @param {object} [resultData] An optional object of result data to use
* @returns {Promise}
* @private
*/
async _onCreateResult(event, resultData={}) {
event.preventDefault();
// Save any pending changes
await this._onSubmit(event);
// Get existing results
const results = Array.from(this.document.results.values());
let last = results[results.length - 1];
// Get weight and range data
let weight = last ? (last.weight || 1) : 1;
let totalWeight = results.reduce((t, r) => t + r.weight, 0) || 1;
let minRoll = results.length ? Math.min(...results.map(r => r.range[0])) : 0;
let maxRoll = results.length ? Math.max(...results.map(r => r.range[1])) : 0;
// Determine new starting range
const spread = maxRoll - minRoll + 1;
const perW = Math.round(spread / totalWeight);
const range = [maxRoll + 1, maxRoll + Math.max(1, weight * perW)];
// Create the new Result
resultData = foundry.utils.mergeObject({
type: last ? last.type : CONST.TABLE_RESULT_TYPES.TEXT,
documentCollection: last ? last.documentCollection : null,
weight: weight,
range: range,
drawn: false
}, resultData);
return this.document.createEmbeddedDocuments("TableResult", [resultData]);
}
/* -------------------------------------------- */
/**
* Submit the entire form when a table result type is changed, in case there are other active changes
* @param {Event} event
* @private
*/
_onChangeResultType(event) {
event.preventDefault();
const rt = CONST.TABLE_RESULT_TYPES;
const select = event.target;
const value = parseInt(select.value);
const resultKey = select.name.replace(".type", "");
let documentCollection = "";
if ( value === rt.DOCUMENT ) documentCollection = "Actor";
else if ( value === rt.COMPENDIUM ) documentCollection = game.packs.keys().next().value;
const updateData = {[resultKey]: {documentCollection, documentId: null}};
return this._onSubmit(event, {updateData});
}
/* -------------------------------------------- */
/**
* Handle deleting a TableResult from the RollTable document
* @param {MouseEvent} event The originating click event
* @returns {Promise<TableResult>} The deleted TableResult document
* @private
*/
async _onDeleteResult(event) {
event.preventDefault();
await this._onSubmit(event);
const li = event.currentTarget.closest(".table-result");
const result = this.object.results.get(li.dataset.resultId);
return result.delete();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
const allowed = Hooks.call("dropRollTableSheetData", this.document, this, data);
if ( allowed === false ) return;
// Get the dropped document
if ( !CONST.DOCUMENT_TYPES.includes(data.type) ) return;
const cls = getDocumentClass(data.type);
const document = await cls.fromDropData(data);
if ( !document || document.isEmbedded ) return;
// Delegate to the onCreate handler
const isCompendium = !!document.compendium;
return this._onCreateResult(event, {
type: isCompendium ? CONST.TABLE_RESULT_TYPES.COMPENDIUM : CONST.TABLE_RESULT_TYPES.DOCUMENT,
documentCollection: isCompendium ? document.pack : document.documentName,
text: document.name,
documentId: document.id,
img: document.img || null
});
}
/* -------------------------------------------- */
/**
* Handle changing the actor profile image by opening a FilePicker
* @param {Event} event
* @private
*/
_onEditImage(event) {
const img = event.currentTarget;
const isHeader = img.dataset.edit === "img";
let current = this.document.img;
if ( !isHeader ) {
const li = img.closest(".table-result");
const result = this.document.results.get(li.dataset.resultId);
if (result.type !== CONST.TABLE_RESULT_TYPES.TEXT) return;
current = result.img;
}
const fp = new FilePicker({
type: "image",
current: current,
callback: path => {
img.src = path;
return this._onSubmit(event);
},
top: this.position.top + 40,
left: this.position.left + 10
});
return fp.browse();
}
/* -------------------------------------------- */
/**
* Handle a button click to re-normalize dice result ranges across all RollTable results
* @param {Event} event
* @private
*/
async _onNormalizeResults(event) {
event.preventDefault();
if ( !this.rendered || this._submitting) return false;
// Save any pending changes
await this._onSubmit(event);
// Normalize the RollTable
return this.document.normalize();
}
/* -------------------------------------------- */
/**
* Handle toggling the drawn status of the result in the table
* @param {Event} event
* @private
*/
_onLockResult(event) {
event.preventDefault();
const tableResult = event.currentTarget.closest(".table-result");
const result = this.document.results.get(tableResult.dataset.resultId);
return result.update({drawn: !result.drawn});
}
/* -------------------------------------------- */
/**
* Reset the Table to it's original composition with all options unlocked
* @param {Event} event
* @private
*/
_onResetTable(event) {
event.preventDefault();
return this.document.resetResults();
}
/* -------------------------------------------- */
/**
* Handle drawing a result from the RollTable
* @param {Event} event
* @private
*/
async _onRollTable(event) {
event.preventDefault();
await this.submit({preventClose: true, preventRender: true});
event.currentTarget.disabled = true;
let tableRoll = await this.document.roll();
const draws = this.document.getResultsForRoll(tableRoll.roll.total);
if ( draws.length ) {
if (game.settings.get("core", "animateRollTable")) await this._animateRoll(draws);
await this.document.draw(tableRoll);
}
event.currentTarget.disabled = false;
}
/* -------------------------------------------- */
/**
* Configure the update object workflow for the Roll Table configuration sheet
* Additional logic is needed here to reconstruct the results array from the editable fields on the sheet
* @param {Event} event The form submission event
* @param {Object} formData The validated FormData translated into an Object for submission
* @returns {Promise}
* @private
*/
async _updateObject(event, formData) {
// Expand the data to update the results array
const expanded = foundry.utils.expandObject(formData);
expanded.results = expanded.hasOwnProperty("results") ? Object.values(expanded.results) : [];
for (let r of expanded.results) {
r.range = [r.rangeL, r.rangeH];
switch (r.type) {
// Document results
case CONST.TABLE_RESULT_TYPES.DOCUMENT:
const collection = game.collections.get(r.documentCollection);
if (!collection) continue;
// Get the original document, if the name still matches - take no action
const original = r.documentId ? collection.get(r.documentId) : null;
if (original && (original.name === r.text)) continue;
// Otherwise, find the document by ID or name (ID preferred)
const doc = collection.find(e => (e.id === r.text) || (e.name === r.text)) || null;
r.documentId = doc?.id ?? null;
r.text = doc?.name ?? null;
r.img = doc?.img ?? null;
r.img = doc?.thumb || doc?.img || null;
break;
// Compendium results
case CONST.TABLE_RESULT_TYPES.COMPENDIUM:
const pack = game.packs.get(r.documentCollection);
if (pack) {
// Get the original entry, if the name still matches - take no action
const original = pack.index.get(r.documentId) || null;
if (original && (original.name === r.text)) continue;
// Otherwise, find the document by ID or name (ID preferred)
const doc = pack.index.find(i => (i._id === r.text) || (i.name === r.text)) || null;
r.documentId = doc?._id || null;
r.text = doc?.name || null;
r.img = doc?.thumb || doc?.img || null;
}
break;
// Plain text results
default:
r.type = 0;
r.documentCollection = null;
r.documentId = null;
}
}
// Update the object
return this.document.update(expanded, {diff: false, recursive: false});
}
/* -------------------------------------------- */
/**
* Display a roulette style animation when a Roll Table result is drawn from the sheet
* @param {TableResult[]} results An Array of drawn table results to highlight
* @returns {Promise} A Promise which resolves once the animation is complete
* @protected
*/
async _animateRoll(results) {
// Get the list of results and their indices
const tableResults = this.element[0].querySelector(".table-results > tbody");
const drawnIds = new Set(results.map(r => r.id));
const drawnItems = Array.from(tableResults.children).filter(item => drawnIds.has(item.dataset.resultId));
// Set the animation timing
const nResults = this.object.results.size;
const maxTime = 2000;
let animTime = 50;
let animOffset = Math.round(tableResults.offsetHeight / (tableResults.children[0].offsetHeight * 2));
const nLoops = Math.min(Math.ceil(maxTime/(animTime * nResults)), 4);
if ( nLoops === 1 ) animTime = maxTime / nResults;
// Animate the roulette
await this._animateRoulette(tableResults, drawnIds, nLoops, animTime, animOffset);
// Flash the results
const flashes = drawnItems.map(li => this._flashResult(li));
return Promise.all(flashes);
}
/* -------------------------------------------- */
/**
* Animate a "roulette" through the table until arriving at the final loop and a drawn result
* @param {HTMLOListElement} ol The list element being iterated
* @param {Set<string>} drawnIds The result IDs which have already been drawn
* @param {number} nLoops The number of times to loop through the animation
* @param {number} animTime The desired animation time in milliseconds
* @param {number} animOffset The desired pixel offset of the result within the list
* @returns {Promise} A Promise that resolves once the animation is complete
* @protected
*/
async _animateRoulette(ol, drawnIds, nLoops, animTime, animOffset) {
let loop = 0;
let idx = 0;
let item = null;
return new Promise(resolve => {
let animId = setInterval(() => {
if (idx === 0) loop++;
if (item) item.classList.remove("roulette");
// Scroll to the next item
item = ol.children[idx];
ol.scrollTop = (idx - animOffset) * item.offsetHeight;
// If we are on the final loop
if ( (loop === nLoops) && drawnIds.has(item.dataset.resultId) ) {
clearInterval(animId);
return resolve();
}
// Continue the roulette and cycle the index
item.classList.add("roulette");
idx = idx < ol.children.length - 1 ? idx + 1 : 0;
}, animTime);
});
}
/* -------------------------------------------- */
/**
* Display a flashing animation on the selected result to emphasize the draw
* @param {HTMLElement} item The HTML &lt;li> item of the winning result
* @returns {Promise} A Promise that resolves once the animation is complete
* @protected
*/
async _flashResult(item) {
return new Promise(resolve => {
let count = 0;
let animId = setInterval(() => {
if (count % 2) item.classList.remove("roulette");
else item.classList.add("roulette");
if (count === 7) {
clearInterval(animId);
resolve();
}
count++;
}, 50);
});
}
}

View File

@@ -0,0 +1,413 @@
/**
* The Application responsible for configuring a single Scene document.
* @extends {DocumentSheet}
* @param {Scene} object The Scene Document which is being configured
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class SceneConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "scene-config",
classes: ["sheet", "scene-sheet"],
template: "templates/scene/config.html",
width: 560,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "basic"}]
});
}
/* -------------------------------------------- */
/**
* Indicates if width / height should change together to maintain aspect ratio
* @type {boolean}
*/
linkedDimensions = true;
/* -------------------------------------------- */
/** @override */
get title() {
return `${game.i18n.localize("SCENES.ConfigTitle")}: ${this.object.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
this._resetScenePreview();
return super.close(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
render(force, options={}) {
if ( options.renderContext && (options.renderContext !== "updateScene" ) ) return this;
return super.render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
// Selectable types
context.gridTypes = this.constructor._getGridTypes();
context.weatherTypes = this._getWeatherTypes();
// Referenced documents
context.playlists = this._getDocuments(game.playlists);
context.sounds = this._getDocuments(this.object.playlist?.sounds ?? []);
context.journals = this._getDocuments(game.journal);
context.pages = this.object.journal?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
// Global illumination threshold
context.hasGlobalThreshold = context.data.globalLightThreshold !== null;
context.data.globalLightThreshold = context.data.globalLightThreshold ?? 0;
return context;
}
/* -------------------------------------------- */
/**
* Get an enumeration of the available grid types which can be applied to this Scene
* @returns {object}
* @internal
*/
static _getGridTypes() {
const labels = {
GRIDLESS: "SCENES.GridGridless",
SQUARE: "SCENES.GridSquare",
HEXODDR: "SCENES.GridHexOddR",
HEXEVENR: "SCENES.GridHexEvenR",
HEXODDQ: "SCENES.GridHexOddQ",
HEXEVENQ: "SCENES.GridHexEvenQ"
};
return Object.keys(CONST.GRID_TYPES).reduce((obj, t) => {
obj[CONST.GRID_TYPES[t]] = labels[t];
return obj;
}, {});
}
/* -------------------------------------------- */
/**
* Get the available weather effect types which can be applied to this Scene
* @returns {object}
* @private
*/
_getWeatherTypes() {
const types = {};
for ( let [k, v] of Object.entries(CONFIG.weatherEffects) ) {
types[k] = game.i18n.localize(v.label);
}
return types;
}
/* -------------------------------------------- */
/**
* Get the alphabetized Documents which can be chosen as a configuration for the Scene
* @param {WorldCollection} collection
* @returns {object[]}
* @private
*/
_getDocuments(collection) {
const documents = collection.map(doc => {
return {id: doc.id, name: doc.name};
});
documents.sort((a, b) => a.name.localeCompare(b.name));
return documents;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("button.capture-position").click(this._onCapturePosition.bind(this));
html.find("button.grid-config").click(this._onGridConfig.bind(this));
html.find("button.dimension-link").click(this._onLinkDimensions.bind(this));
html.find("select[name='playlist']").change(this._onChangePlaylist.bind(this));
html.find('select[name="journal"]').change(this._onChangeJournal.bind(this));
}
/* -------------------------------------------- */
/**
* Capture the current Scene position and zoom level as the initial view in the Scene config
* @param {Event} event The originating click event
* @private
*/
_onCapturePosition(event) {
event.preventDefault();
if ( !canvas.ready ) return;
const btn = event.currentTarget;
const form = btn.form;
form["initial.x"].value = parseInt(canvas.stage.pivot.x);
form["initial.y"].value = parseInt(canvas.stage.pivot.y);
form["initial.scale"].value = canvas.stage.scale.x;
ui.notifications.info("Captured canvas position as initial view in the Scene configuration form.");
}
/* -------------------------------------------- */
/**
* Handle click events to open the grid configuration application
* @param {Event} event The originating click event
* @private
*/
async _onGridConfig(event) {
event.preventDefault();
if ( !this.object.isView ) await this.object.view();
new GridConfig(this.object, this).render(true);
return this.minimize();
}
/* -------------------------------------------- */
/**
* Handle click events to link or unlink the scene dimensions
* @param {Event} event
* @returns {Promise<void>}
* @private
*/
async _onLinkDimensions(event) {
event.preventDefault();
this.linkedDimensions = !this.linkedDimensions;
this.element.find("button.dimension-link > i").toggleClass("fa-link-simple", this.linkedDimensions);
this.element.find("button.dimension-link > i").toggleClass("fa-link-simple-slash", !this.linkedDimensions);
this.element.find("button.resize").attr("disabled", !this.linkedDimensions);
// Update Tooltip
const tooltip = game.i18n.localize(this.linkedDimensions ? "SCENES.DimensionLinked" : "SCENES.DimensionUnlinked");
this.element.find("button.dimension-link").attr("data-tooltip", tooltip);
game.tooltip.activate(this.element.find("button.dimension-link")[0], { text: tooltip });
}
/* -------------------------------------------- */
/** @override */
async _onChangeInput(event) {
this._previewScene(event.target.name);
if ( event.target.name === "width" || event.target.name === "height" ) this._onChangeDimensions(event);
return super._onChangeInput(event);
}
/* -------------------------------------------- */
/** @override */
_onChangeColorPicker(event) {
super._onChangeColorPicker(event);
this._previewScene(event.target.dataset.edit);
}
/* -------------------------------------------- */
/** @override */
_onChangeRange(event) {
super._onChangeRange(event);
this._previewScene(event.target.name);
}
/* -------------------------------------------- */
/**
* Live update the scene as certain properties are changed.
* @param {string} changed The changed property.
* @private
*/
_previewScene(changed) {
if ( !this.object.isView || !canvas.ready ) return;
if ( ["grid.color", "grid.alpha"].includes(changed) ) canvas.grid.grid.draw({
color: this.form["grid.color"].value.replace("#", "0x"),
alpha: Number(this.form["grid.alpha"].value)
});
if ( ["darkness", "backgroundColor", "fogExploredColor", "fogUnexploredColor"].includes(changed) ) {
canvas.colorManager.initialize({
backgroundColor: this.form.backgroundColor.value,
darknessLevel: Number(this.form.darkness.value),
fogExploredColor: this.form.fogExploredColor.value,
fogUnexploredColor: this.form.fogUnexploredColor.value
});
}
}
/* -------------------------------------------- */
/**
* Reset the previewed darkness level, background color, grid alpha, and grid color back to their true values.
* @private
*/
_resetScenePreview() {
if ( !this.object.isView || !canvas.ready ) return;
const scene = canvas.scene;
let gridChanged = (this.form["grid.color"].value !== scene.grid.color)
|| (this.form["grid.alpha"].value !== scene.grid.alpha);
scene.reset();
canvas.colorManager.initialize();
if ( gridChanged ) canvas.grid.grid.draw();
}
/* -------------------------------------------- */
/**
* Handle updating the select menu of PlaylistSound options when the Playlist is changed
* @param {Event} event The initiating select change event
* @private
*/
_onChangePlaylist(event) {
event.preventDefault();
const playlist = game.playlists.get(event.target.value);
const sounds = this._getDocuments(playlist?.sounds || []);
const options = ['<option value=""></option>'].concat(sounds.map(s => {
return `<option value="${s.id}">${s.name}</option>`;
}));
const select = this.form.querySelector("select[name=\"playlistSound\"]");
select.innerHTML = options.join("");
}
/* -------------------------------------------- */
/**
* Handle updating the select menu of JournalEntryPage options when the JournalEntry is changed.
* @param {Event} event The initiating select change event.
* @protected
*/
_onChangeJournal(event) {
event.preventDefault();
const entry = game.journal.get(event.currentTarget.value);
const pages = entry?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
const options = pages.map(page => {
const selected = (entry.id === this.object.journal?.id) && (page.id === this.object.journalEntryPage);
return `<option value="${page.id}"${selected ? " selected" : ""}>${page.name}</option>`;
});
this.form.elements.journalEntryPage.innerHTML = `<option></option>${options}`;
}
/* -------------------------------------------- */
/**
* Handle updating the select menu of JournalEntryPage options when the JournalEntry is changed.
* @param event
* @private
*/
_onChangeDimensions(event) {
event.preventDefault();
if ( !this.linkedDimensions ) return;
const name = event.currentTarget.name;
const value = Number(event.currentTarget.value);
const oldValue = name === "width" ? this.object.width : this.object.height;
const scale = value / oldValue;
const otherInput = this.form.elements[name === "width" ? "height" : "width"];
otherInput.value = otherInput.value * scale;
// If new value is not a round number, display an error and revert
if ( !Number.isInteger(parseFloat(otherInput.value)) ) {
ui.notifications.error(game.i18n.localize("SCENES.InvalidDimension"));
this.form.elements[name].value = oldValue;
otherInput.value = name === "width" ? this.object.height : this.object.width;
return;
}
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
const scene = this.document;
// Toggle global illumination threshold
if ( formData.hasGlobalThreshold === false ) formData.globalLightThreshold = null;
delete formData.hasGlobalThreshold;
// SceneData.texture.src is nullable in the schema, causing an empty string to be initialised to null. We need to
// match that logic here to ensure that comparisons to the existing scene image are accurate.
if ( formData["background.src"] === "" ) formData["background.src"] = null;
if ( formData.foreground === "" ) formData.foreground = null;
if ( formData.fogOverlay === "" ) formData.fogOverlay = null;
// The same for fog colors
if ( formData.fogUnexploredColor === "" ) formData.fogUnexploredColor = null;
if ( formData.fogExploredColor === "" ) formData.fogExploredColor = null;
// Determine what type of change has occurred
const hasDefaultDims = (scene.background.src === null) && (scene.width === 4000) && (scene.height === 3000);
const hasImage = formData["background.src"] || scene.background.src;
const changedBackground =
(formData["background.src"] !== undefined) && (formData["background.src"] !== scene.background.src);
const clearedDims = (formData.width === null) || (formData.height === null);
const needsThumb = changedBackground || !scene.thumb;
const needsDims = formData["background.src"] && (clearedDims || hasDefaultDims);
const createThumbnail = hasImage && (needsThumb || needsDims);
// Update thumbnail and image dimensions
if ( createThumbnail && game.settings.get("core", "noCanvas") ) {
ui.notifications.warn("SCENES.GenerateThumbNoCanvas", {localize: true});
formData.thumb = null;
} else if ( createThumbnail ) {
let td = {};
try {
td = await scene.createThumbnail({img: formData["background.src"] ?? scene.background.src});
} catch(err) {
Hooks.onError("SceneConfig#_updateObject", err, {
msg: "Thumbnail generation for Scene failed",
notify: "error",
log: "error",
scene: scene.id
});
}
if ( needsThumb ) formData.thumb = td.thumb || null;
if ( needsDims ) {
formData.width = td.width;
formData.height = td.height;
}
}
// Warn the user if Scene dimensions are changing
const delta = foundry.utils.diffObject(scene._source, foundry.utils.expandObject(formData));
const changes = foundry.utils.flattenObject(delta);
const textureChange = ["scaleX", "scaleY", "rotation"].map(k => `background.${k}`);
if ( ["grid.size", ...textureChange].some(k => k in changes) ) {
const confirm = await Dialog.confirm({
title: game.i18n.localize("SCENES.DimensionChangeTitle"),
content: `<p>${game.i18n.localize("SCENES.DimensionChangeWarning")}</p>`
});
if ( !confirm ) return;
}
// If the canvas size has changed in a nonuniform way, ask the user if they want to reposition
let autoReposition = false;
if ( (scene.background?.src || scene.foreground?.src) && (["width", "height", "padding", "background", "grid.size"].some(x => x in changes)) ) {
autoReposition = true;
// If aspect ratio changes, prompt to replace all tokens with new dimensions and warn about distortions
let showPrompt = false;
if ( "width" in changes && "height" in changes ) {
const currentScale = this.object.width / this.object.height;
const newScale = formData.width / formData.height;
if ( currentScale !== newScale ) {
showPrompt = true;
}
}
else if ( "width" in changes || "height" in changes ) {
showPrompt = true;
}
if ( showPrompt ) {
const confirm = await Dialog.confirm({
title: game.i18n.localize("SCENES.DistortedDimensionsTitle"),
content: game.i18n.localize("SCENES.DistortedDimensionsWarning"),
defaultYes: false
});
if ( !confirm ) autoReposition = false;
}
}
// Perform the update
return scene.update(formData, {autoReposition});
}
}

View File

@@ -0,0 +1,362 @@
/**
* Document Sheet Configuration Application
*/
class DocumentSheetConfig extends FormApplication {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["form", "sheet-config"],
template: "templates/sheets/sheet-config.html",
width: 400
});
}
/**
* An array of pending sheet assignments which are submitted before other elements of the framework are ready.
* @type {object[]}
* @private
*/
static #pending = [];
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
const name = this.object.name ?? game.i18n.localize(this.object.constructor.metadata.label);
return `${name}: Sheet Configuration`;
}
/* -------------------------------------------- */
/** @inheritDoc */
getData(options={}) {
const {sheetClasses, defaultClasses, defaultClass} = this.constructor.getSheetClassesForSubType(
this.object.documentName,
this.object.type || CONST.BASE_DOCUMENT_TYPE
);
// Return data
return {
isGM: game.user.isGM,
object: this.object.toObject(),
options: this.options,
sheetClass: this.object.getFlag("core", "sheetClass") ?? "",
blankLabel: game.i18n.localize("SHEETS.DefaultSheet"),
sheetClasses, defaultClass, defaultClasses
};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
event.preventDefault();
const original = this.getData({});
const defaultSheetChanged = formData.defaultClass !== original.defaultClass;
const documentSheetChanged = formData.sheetClass !== original.sheetClass;
// Update world settings
if ( game.user.isGM && defaultSheetChanged ) {
const setting = game.settings.get("core", "sheetClasses") || {};
const type = this.object.type || CONST.BASE_DOCUMENT_TYPE;
foundry.utils.mergeObject(setting, {[`${this.object.documentName}.${type}`]: formData.defaultClass});
await game.settings.set("core", "sheetClasses", setting);
// Trigger a sheet change manually if it wouldn't be triggered by the normal ClientDocument#_onUpdate workflow.
if ( !documentSheetChanged ) return this.object._onSheetChange({ sheetOpen: true });
}
// Update the document-specific override
if ( documentSheetChanged ) return this.object.setFlag("core", "sheetClass", formData.sheetClass);
}
/* -------------------------------------------- */
/**
* Marshal information on the available sheet classes for a given document type and sub-type, and format it for
* display.
* @param {string} documentName The Document type.
* @param {string} subType The Document sub-type.
* @returns {{sheetClasses: object, defaultClasses: object, defaultClass: string}}
*/
static getSheetClassesForSubType(documentName, subType) {
const config = CONFIG[documentName];
const defaultClasses = {};
let defaultClass = null;
const sheetClasses = Object.values(config.sheetClasses[subType]).reduce((obj, cfg) => {
if ( cfg.canConfigure ) obj[cfg.id] = cfg.label;
if ( cfg.default && !defaultClass ) defaultClass = cfg.id;
if ( cfg.canConfigure && cfg.canBeDefault ) defaultClasses[cfg.id] = cfg.label;
return obj;
}, {});
return {sheetClasses, defaultClasses, defaultClass};
}
/* -------------------------------------------- */
/* Configuration Methods
/* -------------------------------------------- */
/**
* Initialize the configured Sheet preferences for Documents which support dynamic Sheet assignment
* Create the configuration structure for supported documents
* Process any pending sheet registrations
* Update the default values from settings data
*/
static initializeSheets() {
for ( let cls of Object.values(foundry.documents) ) {
const types = this._getDocumentTypes(cls);
CONFIG[cls.documentName].sheetClasses = types.reduce((obj, type) => {
obj[type] = {};
return obj;
}, {});
}
// Register any pending sheets
this.#pending.forEach(p => {
if ( p.action === "register" ) this.#registerSheet(p);
else if ( p.action === "unregister" ) this.#unregisterSheet(p);
});
this.#pending = [];
// Update default sheet preferences
const defaults = game.settings.get("core", "sheetClasses");
this.updateDefaultSheets(defaults);
}
/* -------------------------------------------- */
static _getDocumentTypes(cls, types=[]) {
if ( types.length ) return types;
return game.documentTypes[cls.documentName];
}
/* -------------------------------------------- */
/**
* Register a sheet class as a candidate which can be used to display documents of a given type
* @param {typeof ClientDocument} documentClass The Document class for which to register a new Sheet option
* @param {string} scope Provide a unique namespace scope for this sheet
* @param {typeof DocumentSheet} sheetClass A defined Application class used to render the sheet
* @param {object} [config] Additional options used for sheet registration
* @param {string|Function} [config.label] A human-readable label for the sheet name, which will be localized
* @param {string[]} [config.types] An array of document types for which this sheet should be used
* @param {boolean} [config.makeDefault=false] Whether to make this sheet the default for provided types
* @param {boolean} [config.canBeDefault=true] Whether this sheet is available to be selected as a default sheet for
* all Documents of that type.
* @param {boolean} [config.canConfigure=true] Whether this sheet appears in the sheet configuration UI for users.
*/
static registerSheet(documentClass, scope, sheetClass, {
label, types, makeDefault=false, canBeDefault=true, canConfigure=true
}={}) {
const id = `${scope}.${sheetClass.name}`;
const config = {documentClass, id, label, sheetClass, types, makeDefault, canBeDefault, canConfigure};
if ( game.ready ) this.#registerSheet(config);
else {
config.action = "register";
this.#pending.push(config);
}
}
/**
* Perform the sheet registration.
* @param {object} config Configuration for how the sheet should be registered
* @param {typeof ClientDocument} config.documentClass The Document class being registered
* @param {string} config.id The sheet ID being registered
* @param {string} config.label The human-readable sheet label
* @param {typeof DocumentSheet} config.sheetClass The sheet class definition being registered
* @param {object[]} config.types An array of types for which this sheet is added
* @param {boolean} config.makeDefault Make this sheet the default for provided types?
* @param {boolean} config.canBeDefault Whether this sheet is available to be selected as a default
* sheet for all Documents of that type.
* @param {boolean} config.canConfigure Whether the sheet appears in the sheet configuration UI for
* users.
*/
static #registerSheet({documentClass, id, label, sheetClass, types, makeDefault, canBeDefault, canConfigure}={}) {
types = this._getDocumentTypes(documentClass, types);
const classes = CONFIG[documentClass.documentName]?.sheetClasses;
const defaults = game.ready ? game.settings.get("core", "sheetClasses") : {};
if ( typeof classes !== "object" ) return;
for ( const t of types ) {
classes[t] ||= {};
const existingDefault = defaults[documentClass.documentName]?.[t];
const isDefault = existingDefault ? (existingDefault === id) : makeDefault;
if ( isDefault ) Object.values(classes[t]).forEach(s => s.default = false);
if ( label instanceof Function ) label = label();
else if ( label ) label = game.i18n.localize(label);
else label = id;
classes[t][id] = {
id, label, canBeDefault, canConfigure,
cls: sheetClass,
default: isDefault
};
}
}
/* -------------------------------------------- */
/**
* Unregister a sheet class, removing it from the list of available Applications to use for a Document type
* @param {typeof ClientDocument} documentClass The Document class for which to register a new Sheet option
* @param {string} scope Provide a unique namespace scope for this sheet
* @param {typeof DocumentSheet} sheetClass A defined DocumentSheet subclass used to render the sheet
* @param {object} [config]
* @param {object[]} [config.types] An Array of types for which this sheet should be removed
*/
static unregisterSheet(documentClass, scope, sheetClass, {types}={}) {
const id = `${scope}.${sheetClass.name}`;
const config = {documentClass, id, types};
if ( game.ready ) this.#unregisterSheet(config);
else {
config.action = "unregister";
this.#pending.push(config);
}
}
/**
* Perform the sheet de-registration.
* @param {object} config Configuration for how the sheet should be un-registered
* @param {typeof ClientDocument} config.documentClass The Document class being unregistered
* @param {string} config.id The sheet ID being unregistered
* @param {object[]} config.types An array of types for which this sheet is removed
*/
static #unregisterSheet({documentClass, id, types}={}) {
types = this._getDocumentTypes(documentClass, types);
const classes = CONFIG[documentClass.documentName]?.sheetClasses;
if ( typeof classes !== "object" ) return;
for ( let t of types ) {
delete classes[t][id];
}
}
/* -------------------------------------------- */
/**
* Update the current default Sheets using a new core world setting.
* @param {object} setting
*/
static updateDefaultSheets(setting={}) {
if ( !Object.keys(setting).length ) return;
for ( let cls of Object.values(foundry.documents) ) {
const documentName = cls.documentName;
const cfg = CONFIG[documentName];
const classes = cfg.sheetClasses;
const collection = cfg.collection?.instance ?? [];
const defaults = setting[documentName];
if ( !defaults ) continue;
// Update default preference for registered sheets
for ( let [type, sheetId] of Object.entries(defaults) ) {
const sheets = Object.values(classes[type] || {});
let requested = sheets.find(s => s.id === sheetId);
if ( requested ) sheets.forEach(s => s.default = s.id === sheetId);
}
// Close and de-register any existing sheets
for ( let document of collection ) {
for ( const [id, app] of Object.entries(document.apps) ) {
app.close();
delete document.apps[id];
}
document._sheet = null;
}
}
}
/* -------------------------------------------- */
/**
* Initialize default sheet configurations for all document types.
* @private
*/
static _registerDefaultSheets() {
const defaultSheets = {
// Documents
Actor: ActorSheet,
Adventure: AdventureImporter,
Folder: FolderConfig,
Item: ItemSheet,
JournalEntry: JournalSheet,
Macro: MacroConfig,
Playlist: PlaylistConfig,
RollTable: RollTableConfig,
Scene: SceneConfig,
User: UserConfig,
// Embedded Documents
ActiveEffect: ActiveEffectConfig,
AmbientLight: AmbientLightConfig,
AmbientSound: AmbientSoundConfig,
Card: CardConfig,
Combatant: CombatantConfig,
Drawing: DrawingConfig,
MeasuredTemplate: MeasuredTemplateConfig,
Note: NoteConfig,
PlaylistSound: PlaylistSoundConfig,
Tile: TileConfig,
Token: TokenConfig,
Wall: WallConfig
};
Object.values(foundry.documents).forEach(base => {
const type = base.documentName;
const cfg = CONFIG[type];
cfg.sheetClasses = {};
const defaultSheet = defaultSheets[type];
if ( !defaultSheet ) return;
DocumentSheetConfig.registerSheet(cfg.documentClass, "core", defaultSheet, {
makeDefault: true,
label: () => game.i18n.format("SHEETS.DefaultDocumentSheet", {document: game.i18n.localize(`DOCUMENT.${type}`)})
});
});
DocumentSheetConfig.registerSheet(Cards, "core", CardsConfig, {
label: "CARDS.CardsDeck",
types: ["deck"],
makeDefault: true
});
DocumentSheetConfig.registerSheet(Cards, "core", CardsHand, {
label: "CARDS.CardsHand",
types: ["hand"],
makeDefault: true
});
DocumentSheetConfig.registerSheet(Cards, "core", CardsPile, {
label: "CARDS.CardsPile",
types: ["pile"],
makeDefault: true
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalTextTinyMCESheet, {
types: ["text"],
label: () => game.i18n.localize("EDITOR.TinyMCE")
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalImagePageSheet, {
types: ["image"],
makeDefault: true,
label: () =>
game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {page: game.i18n.localize("JOURNALENTRYPAGE.TypeImage")})
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalVideoPageSheet, {
types: ["video"],
makeDefault: true,
label: () =>
game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {page: game.i18n.localize("JOURNALENTRYPAGE.TypeVideo")})
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalPDFPageSheet, {
types: ["pdf"],
makeDefault: true,
label: () =>
game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {page: game.i18n.localize("JOURNALENTRYPAGE.TypePDF")})
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalTextPageSheet, {
types: ["text"],
makeDefault: true,
label: () => {
return game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {
page: game.i18n.localize("JOURNALENTRYPAGE.TypeText")
});
}
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", MarkdownJournalPageSheet, {
types: ["text"],
label: () => game.i18n.localize("EDITOR.Markdown")
});
}
}

View File

@@ -0,0 +1,95 @@
/**
* The Application responsible for configuring a single User document.
* @extends {DocumentSheet}
*
* @param {User} user The User document being configured.
* @param {DocumentSheetOptions} [options] Additional rendering options which modify the behavior of the form.
*/
class UserConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "user-config"],
template: "templates/user/user-config.html",
width: 400,
height: "auto"
})
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return `${game.i18n.localize("PLAYERS.ConfigTitle")}: ${this.object.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const controlled = game.users.reduce((arr, u) => {
if ( u.character ) arr.push(u.character);
return arr;
}, []);
const actors = game.actors.filter(a => a.testUserPermission(this.object, "OBSERVER") && !controlled.includes(a.id));
return {
user: this.object,
actors: actors,
options: this.options
};
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// When a character is clicked, record it's ID in the hidden input
let input = html.find('[name="character"]');
html.find('.actor').click(ev => {
// Record the selected actor
let li = ev.currentTarget;
let actorId = li.getAttribute("data-actor-id");
input.val(actorId);
// Add context to the selection
for ( let a of html[0].getElementsByClassName("actor") ) {
a.classList.remove("context");
}
li.classList.add("context");
});
// Release the currently selected character
html.find('button[name="release"]').click(ev => {
canvas.tokens?.releaseAll();
this.object.update({character: null}).then(() => this.render(false));
});
// Support Image updates
html.find('img[data-edit="avatar"]').click(ev => this._onEditAvatar(ev));
}
/* -------------------------------------------- */
/**
* Handle changing the user avatar image by opening a FilePicker
* @private
*/
_onEditAvatar(event) {
event.preventDefault();
const fp = new FilePicker({
type: "image",
current: this.object.avatar,
callback: path => {
event.currentTarget.src = path;
return this._onSubmit(event, {preventClose: true});
},
top: this.position.top + 40,
left: this.position.left + 10
});
return fp.browse();
}
}

View File

@@ -0,0 +1,236 @@
/**
* @typedef {Option} ChatBubbleOptions
* @property {string[]} [cssClasses] An optional array of CSS classes to apply to the resulting bubble
* @property {boolean} [pan=true] Pan to the token speaker for this bubble, if allowed by the client
* @property {boolean} [requireVisible=false] Require that the token be visible in order for the bubble to be rendered
*/
/**
* The Chat Bubble Class
* This application displays a temporary message sent from a particular Token in the active Scene.
* The message is displayed on the HUD layer just above the Token.
*/
class ChatBubbles {
constructor() {
this.template = "templates/hud/chat-bubble.html";
/**
* Track active Chat Bubbles
* @type {object}
*/
this.bubbles = {};
/**
* Track which Token was most recently panned to highlight
* Use this to avoid repeat panning
* @type {Token}
* @private
*/
this._panned = null;
}
/* -------------------------------------------- */
/**
* A reference to the chat bubbles HTML container in which rendered bubbles should live
* @returns {jQuery}
*/
get container() {
return $("#chat-bubbles");
}
/* -------------------------------------------- */
/**
* Create a chat bubble message for a certain token which is synchronized for display across all connected clients.
* @param {TokenDocument} token The speaking Token Document
* @param {string} message The spoken message text
* @param {ChatBubbleOptions} [options] Options which affect the bubble appearance
* @returns {Promise<jQuery|null>} A promise which resolves with the created bubble HTML, or null
*/
async broadcast(token, message, options={}) {
if ( token instanceof Token ) token = token.document;
if ( !(token instanceof TokenDocument) || !message ) {
throw new Error("You must provide a Token instance and a message string");
}
game.socket.emit("chatBubble", {
sceneId: token.parent.id,
tokenId: token.id,
message,
options
});
return this.say(token.object, message, options);
}
/* -------------------------------------------- */
/**
* Speak a message as a particular Token, displaying it as a chat bubble
* @param {Token} token The speaking Token
* @param {string} message The spoken message text
* @param {ChatBubbleOptions} [options] Options which affect the bubble appearance
* @returns {Promise<jQuery|null>} A Promise which resolves to the created bubble HTML element, or null
*/
async say(token, message, {cssClasses, requireVisible = false, pan = true} = {}) {
// Ensure that a bubble is allowed for this token
if ( !token || !message ) return null;
let allowBubbles = game.settings.get("core", "chatBubbles");
if ( !allowBubbles ) return null;
if ( requireVisible && !token.visible ) return null;
// Clear any existing bubble for the speaker
await this._clearBubble(token);
// Create the HTML and call the chatBubble hook
const actor = ChatMessage.implementation.getSpeakerActor({scene: token.scene.id, token: token.id});
message = await TextEditor.enrichHTML(message, { async: true, rollData: actor?.getRollData() });
let html = $(await this._renderHTML({
token, message, cssClasses
}));
const allowed = Hooks.call("chatBubble", token, html, message, {cssClasses, pan});
if ( allowed === false ) return null;
// Set initial dimensions
let dimensions = this._getMessageDimensions(message);
this._setPosition(token, html, dimensions);
// Append to DOM
this.container.append(html);
// Optionally pan to the speaker
const panToSpeaker = game.settings.get("core", "chatBubblesPan") && pan && (this._panned !== token);
const promises = [];
if ( panToSpeaker ) {
const scale = Math.max(1, canvas.stage.scale.x);
promises.push(canvas.animatePan({x: token.document.x, y: token.document.y, scale, duration: 1000}));
this._panned = token;
}
// Get animation duration and settings
const duration = this._getDuration(html);
const scroll = dimensions.unconstrained - dimensions.height;
// Animate the bubble
promises.push(new Promise(resolve => {
html.fadeIn(250, () => {
if ( scroll > 0 ) {
html.find(".bubble-content").animate({top: -1 * scroll}, duration - 1000, "linear", resolve);
}
setTimeout(() => html.fadeOut(250, () => html.remove()), duration);
});
}));
// Return the chat bubble HTML after all animations have completed
await Promise.all(promises);
return html;
}
/* -------------------------------------------- */
/**
* Activate Socket event listeners which apply to the ChatBubbles UI.
* @param {Socket} socket The active web socket connection
* @internal
*/
static _activateSocketListeners(socket) {
socket.on("chatBubble", ({sceneId, tokenId, message, options}) => {
if ( !canvas.ready ) return;
const scene = game.scenes.get(sceneId);
if ( !scene?.isView ) return;
const token = scene.tokens.get(tokenId);
if ( !token ) return;
return canvas.hud.bubbles.say(token.object, message, options);
});
}
/* -------------------------------------------- */
/**
* Clear any existing chat bubble for a certain Token
* @param {Token} token
* @private
*/
async _clearBubble(token) {
let existing = $(`.chat-bubble[data-token-id="${token.id}"]`);
if ( !existing.length ) return;
return new Promise(resolve => {
existing.fadeOut(100, () => {
existing.remove();
resolve();
});
});
}
/* -------------------------------------------- */
/**
* Render the HTML template for the chat bubble
* @param {object} data Template data
* @returns {Promise<string>} The rendered HTML
* @private
*/
async _renderHTML(data) {
return renderTemplate(this.template, data);
}
/* -------------------------------------------- */
/**
* Before displaying the chat message, determine it's constrained and unconstrained dimensions
* @param {string} message The message content
* @returns {object} The rendered message dimensions
* @private
*/
_getMessageDimensions(message) {
let div = $(`<div class="chat-bubble" style="visibility:hidden">${message}</div>`);
$("body").append(div);
let dims = {
width: div[0].clientWidth + 8,
height: div[0].clientHeight
};
div.css({maxHeight: "none"});
dims.unconstrained = div[0].clientHeight;
div.remove();
return dims;
}
/* -------------------------------------------- */
/**
* Assign styling parameters to the chat bubble, toggling either a left or right display (randomly)
* @param {Token} token The speaking Token
* @param {JQuery} html Chat bubble content
* @param {Rectangle} dimensions Positioning data
* @private
*/
_setPosition(token, html, dimensions) {
let cls = Math.random() > 0.5 ? "left" : "right";
html.addClass(cls);
const pos = {
height: dimensions.height,
width: dimensions.width,
top: token.y - dimensions.height - 8
};
if ( cls === "right" ) pos.left = token.x - (dimensions.width - token.w);
else pos.left = token.x;
html.css(pos);
}
/* -------------------------------------------- */
/**
* Determine the length of time for which to display a chat bubble.
* Research suggests that average reading speed is 200 words per minute.
* Since these are short-form messages, we multiply reading speed by 1.5.
* Clamp the result between 1 second (minimum) and 20 seconds (maximum)
* @param {jQuery} html The HTML message
* @returns {number} The number of milliseconds for which to display the message
*/
_getDuration(html) {
const words = html.text().split(/\s+/).reduce((n, w) => n + Number(!!w.trim().length), 0);
const ms = (words * 60 * 1000) / 300;
return Math.clamped(1000, ms, 20000);
}
}

View File

@@ -0,0 +1,77 @@
/**
* Render the HUD container
* @type {Application}
*/
class HeadsUpDisplay extends Application {
constructor(...args) {
super(...args);
/**
* Token HUD
* @type {TokenHUD}
*/
this.token = new TokenHUD();
/**
* Tile HUD
* @type {TileHUD}
*/
this.tile = new TileHUD();
/**
* Drawing HUD
* @type {DrawingHUD}
*/
this.drawing = new DrawingHUD();
/**
* Chat Bubbles
* @type {ChatBubbles}
*/
this.bubbles = new ChatBubbles();
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.id = "hud";
options.template = "templates/hud/hud.html";
options.popOut = false;
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
if ( !canvas.ready ) return {};
return {
width: canvas.dimensions.width,
height: canvas.dimensions.height
};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
await super._render(force, options);
this.align();
}
/* -------------------------------------------- */
/**
* Align the position of the HUD layer to the current position of the canvas
*/
align() {
const hud = this.element[0];
const {x, y} = canvas.primary.getGlobalPosition();
const scale = canvas.stage.scale.x;
hud.style.left = `${x}px`;
hud.style.top = `${y}px`;
hud.style.transform = `scale(${scale})`;
}
}

View File

@@ -0,0 +1,904 @@
/**
* @typedef {Object} SceneControlTool
* @property {string} name
* @property {string} title
* @property {string} icon
* @property {boolean} visible
* @property {boolean} toggle
* @property {boolean} active
* @property {boolean} button
* @property {Function} onClick
* @property {ToolclipConfiguration} toolclip Configuration for rendering the tool's toolclip.
*/
/**
* @typedef {Object} SceneControl
* @property {string} name
* @property {string} title
* @property {string} layer
* @property {string} icon
* @property {boolean} visible
* @property {SceneControlTool[]} tools
* @property {string} activeTool
*/
/**
* @typedef {object} ToolclipConfiguration
* @property {string} src The filename of the toolclip video.
* @property {string} heading The heading string.
* @property {ToolclipConfigurationItem[]} items The items in the toolclip body.
*/
/**
* @typedef {object} ToolclipConfigurationItem
* @property {string} [paragraph] A plain paragraph of content for this item.
* @property {string} [heading] A heading for the item.
* @property {string} [content] Content for the item.
* @property {string} [reference] If the item is a single key reference, use this instead of content.
*/
/**
* Scene controls navigation menu
* @extends {Application}
*/
class SceneControls extends Application {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
width: 100,
id: "controls",
template: "templates/hud/controls.html",
popOut: false
});
}
/* -------------------------------------------- */
/**
* The Array of Scene Control buttons which are currently rendered
* @type {SceneControl[]}
*/
controls = this._getControlButtons();
/* -------------------------------------------- */
/**
* The currently active control set
* @type {string}
*/
get activeControl() {
return this.#control;
}
#control = "token";
/* -------------------------------------------- */
/**
* The currently active tool in the control palette
* @type {string}
*/
get activeTool() {
return this.#tools[this.#control];
}
/**
* Track which tool is active within each control set
* @type {Object<string, string>}
*/
#tools = {};
/* -------------------------------------------- */
/**
* Return the active control set
* @type {SceneControl|null}
*/
get control() {
if ( !this.controls ) return null;
return this.controls.find(c => c.name === this.#control) || null;
}
/* -------------------------------------------- */
/**
* Return the actively controlled tool
* @type {SceneControlTool|null}
*/
get tool() {
const control = this.control;
if ( !control ) return null;
return this.#tools[control.name] || null;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Initialize the Scene Controls by obtaining the set of control buttons and rendering the HTML
* @param {object} options Options which modify how the controls UI is initialized
* @param {string} [options.control] An optional control set to set as active
* @param {string} [options.layer] An optional layer name to target as the active control
* @param {string} [options.tool] A specific named tool to set as active for the palette
*/
initialize({control, layer, tool} = {}) {
// Determine the control set to activate
let controlSet = control ? this.controls.find(c => c.name === control) : null;
if ( !controlSet && layer ) controlSet = this.controls.find(c => c.layer === layer);
if ( !controlSet ) controlSet = this.control;
// Determine the tool to activate
tool ||= this.#tools[controlSet.name] || controlSet.activeTool;
// Activate the new control scheme
this.#control = controlSet?.name || null;
this.#tools[this.#control] = tool || null;
this.controls = this._getControlButtons();
// Render the UI
this.render(true);
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const showToolclips = game.settings.get("core", "showToolclips");
const canvasActive = !!canvas.scene;
const controls = [];
for ( const c of this.controls ) {
if ( c.visible === false ) continue;
const control = foundry.utils.deepClone(c);
control.isActive = canvasActive && (this.#control === control.name);
control.css = control.isActive ? "active" : "";
control.tools = [];
for ( const t of c.tools ) {
if ( t.visible === false ) continue;
const tool = foundry.utils.deepClone(t);
tool.isActive = canvasActive && ((this.#tools[control.name] === tool.name) || (tool.toggle && tool.active));
tool.css = [
tool.toggle ? "toggle" : null,
tool.isActive ? "active" : null
].filterJoin(" ");
tool.tooltip = showToolclips && tool.toolclip
? await renderTemplate("templates/hud/toolclip.html", tool.toolclip)
: tool.title;
control.tools.push(tool);
}
if ( control.tools.length ) controls.push(control);
}
// Return data for rendering
return {
controls,
active: canvasActive,
cssClass: canvasActive ? "" : "disabled"
};
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
html.find(".scene-control").click(this._onClickLayer.bind(this));
html.find(".control-tool").click(this._onClickTool.bind(this));
canvas.notes?.hintMapNotes();
}
/* -------------------------------------------- */
/**
* Handle click events on a Control set
* @param {Event} event A click event on a tool control
* @private
*/
_onClickLayer(event) {
event.preventDefault();
if ( !canvas.ready ) return;
const li = event.currentTarget;
const controlName = li.dataset.control;
if ( this.#control === controlName ) return;
this.#control = controlName;
const control = this.controls.find(c => c.name === controlName);
if ( control ) canvas[control.layer].activate();
}
/* -------------------------------------------- */
/**
* Handle click events on Tool controls
* @param {Event} event A click event on a tool control
* @private
*/
_onClickTool(event) {
event.preventDefault();
if ( !canvas.ready ) return;
const li = event.currentTarget;
const control = this.control;
const toolName = li.dataset.tool;
const tool = control.tools.find(t => t.name === toolName);
// Handle Toggles
if ( tool.toggle ) {
tool.active = !tool.active;
if ( tool.onClick instanceof Function ) tool.onClick(tool.active);
}
// Handle Buttons
else if ( tool.button ) {
if ( tool.onClick instanceof Function ) tool.onClick();
}
// Handle Tools
else {
this.#tools[control.name] = toolName;
if ( tool.onClick instanceof Function ) tool.onClick();
}
// Render the controls
this.render();
}
/* -------------------------------------------- */
/**
* Get the set of Control sets and tools that are rendered as the Scene Controls.
* These controls may be extended using the "getSceneControlButtons" Hook.
* @returns {SceneControl[]}
* @private
*/
_getControlButtons() {
const controls = [];
const isGM = game.user.isGM;
const clip = game.settings.get("core", "showToolclips") ? "Clip" : "";
const commonControls = {
create: { heading: "CONTROLS.CommonCreate", reference: "CONTROLS.ClickDrag" },
move: { heading: "CONTROLS.CommonMove", reference: "CONTROLS.Drag" },
edit: { heading: "CONTROLS.CommonEdit", reference: "CONTROLS.DoubleClick" },
editAlt: { heading: "CONTROLS.CommonEdit", reference: "CONTROLS.DoubleRightClick" },
sheet: { heading: "CONTROLS.CommonOpenSheet", reference: "CONTROLS.DoubleClick" },
hide: { heading: "CONTROLS.CommonHide", reference: "CONTROLS.RightClick" },
delete: { heading: "CONTROLS.CommonDelete", reference: "CONTROLS.Delete" },
rotate: { heading: "CONTROLS.CommonRotate", content: "CONTROLS.ShiftOrCtrlScroll" },
select: { heading: "CONTROLS.CommonSelect", reference: "CONTROLS.Click" },
selectAlt: { heading: "CONTROLS.CommonSelect", content: "CONTROLS.ClickOrClickDrag" },
selectMultiple: { heading: "CONTROLS.CommonSelectMultiple", reference: "CONTROLS.ShiftClick" },
hud: { heading: "CONTROLS.CommonToggleHUD", reference: "CONTROLS.RightClick" },
draw: { heading: "CONTROLS.CommonDraw", reference: "CONTROLS.ClickDrag" },
place: { heading: "CONTROLS.CommonPlace", reference: "CONTROLS.ClickDrag" },
chain: { heading: "CONTROLS.CommonChain", content: "CONTROLS.ChainCtrlClick" },
movePoint: { heading: "CONTROLS.CommonMovePoint", reference: "CONTROLS.ClickDrag" },
openClose: { heading: "CONTROLS.CommonOpenClose", reference: "CONTROLS.Click" },
openCloseSilently: { heading: "CONTROLS.CommonOpenCloseSilently", reference: "CONTROLS.AltClick" },
lock: { heading: "CONTROLS.CommonLock", reference: "CONTROLS.RightClick" },
lockSilently: { heading: "CONTROLS.CommonLockSilently", reference: "CONTROLS.AltRightClick" },
onOff: { heading: "CONTROLS.CommonOnOff", reference: "CONTROLS.RightClick" }
};
const buildItems = (...items) => items.map(item => commonControls[item]);
// Token Controls
controls.push({
name: "token",
title: "CONTROLS.GroupToken",
layer: "tokens",
icon: "fas fa-user-alt",
tools: [
{
name: "select",
title: "CONTROLS.BasicSelect",
icon: "fas fa-expand",
toolclip: {
src: "toolclips/tools/token-select.webm",
heading: "CONTROLS.BasicSelect",
items: [
{ paragraph: "CONTROLS.BasicSelectP" },
...buildItems("selectAlt", "selectMultiple", "move", "rotate", "hud", "sheet"),
...(game.user.isGM ? buildItems("editAlt", "delete") : []),
{ heading: "CONTROLS.BasicMeasureStart", reference: "CONTROLS.CtrlClickDrag" },
{ heading: "CONTROLS.BasicMeasureWaypoints", reference: "CONTROLS.CtrlClick" },
{ heading: "CONTROLS.BasicMeasureFollow", reference: "CONTROLS.Spacebar" }
]
}
},
{
name: "target",
title: "CONTROLS.TargetSelect",
icon: "fas fa-bullseye",
toolclip: {
src: "toolclips/tools/token-target.webm",
heading: "CONTROLS.TargetSelect",
items: [
{ paragraph: "CONTROLS.TargetSelectP" },
...buildItems("selectAlt", "selectMultiple")
]
}
},
{
name: "ruler",
title: "CONTROLS.BasicMeasure",
icon: "fas fa-ruler",
toolclip: {
src: "toolclips/tools/token-measure.webm",
heading: "CONTROLS.BasicMeasure",
items: [
{ heading: "CONTROLS.BasicMeasureStart", reference: "CONTROLS.ClickDrag" },
{ heading: "CONTROLS.BasicMeasureWaypoints", reference: "CONTROLS.CtrlClick" },
{ heading: "CONTROLS.BasicMeasureFollow", reference: "CONTROLS.Spacebar" }
]
}
}
],
activeTool: "select"
});
// Measurement Layer Tools
controls.push({
name: "measure",
title: "CONTROLS.GroupMeasure",
layer: "templates",
icon: "fas fa-ruler-combined",
visible: game.user.can("TEMPLATE_CREATE"),
tools: [
{
name: "circle",
title: "CONTROLS.MeasureCircle",
icon: "fa-regular fa-circle",
toolclip: {
src: "toolclips/tools/measure-circle.webm",
heading: "CONTROLS.MeasureCircle",
items: buildItems("create", "move", "edit", "hide", "delete")
}
},
{
name: "cone",
title: "CONTROLS.MeasureCone",
icon: "fa-solid fa-angle-left",
toolclip: {
src: "toolclips/tools/measure-cone.webm",
heading: "CONTROLS.MeasureCone",
items: buildItems("create", "move", "edit", "hide", "delete", "rotate")
}
},
{
name: "rect",
title: "CONTROLS.MeasureRect",
icon: "fa-regular fa-square",
toolclip: {
src: "toolclips/tools/measure-rect.webm",
heading: "CONTROLS.MeasureRect",
items: buildItems("create", "move", "edit", "hide", "delete", "rotate")
}
},
{
name: "ray",
title: "CONTROLS.MeasureRay",
icon: "fa-solid fa-arrows-alt-v",
toolclip: {
src: "toolclips/tools/measure-ray.webm",
heading: "CONTROLS.MeasureRay",
items: buildItems("create", "move", "edit", "hide", "delete", "rotate")
}
},
{
name: "clear",
title: "CONTROLS.MeasureClear",
icon: "fa-solid fa-trash",
visible: isGM,
onClick: () => canvas.templates.deleteAll(),
button: true
}
],
activeTool: "circle"
});
// Tiles Layer
controls.push({
name: "tiles",
title: "CONTROLS.GroupTile",
layer: "tiles",
icon: "fa-solid fa-cubes",
visible: isGM,
tools: [
{
name: "select",
title: "CONTROLS.TileSelect",
icon: "fa-solid fa-expand",
toolclip: {
src: "toolclips/tools/tile-select.webm",
heading: "CONTROLS.TileSelect",
items: buildItems("selectAlt", "selectMultiple", "move", "rotate", "hud", "edit", "delete")
}
},
{
name: "tile",
title: "CONTROLS.TilePlace",
icon: "fa-solid fa-cube",
toolclip: {
src: "toolclips/tools/tile-place.webm",
heading: "CONTROLS.TilePlace",
items: buildItems("create", "move", "rotate", "hud", "edit", "delete")
}
},
{
name: "browse",
title: "CONTROLS.TileBrowser",
icon: "fa-solid fa-folder",
button: true,
onClick: () => {
new FilePicker({
type: "imagevideo",
displayMode: "tiles",
tileSize: true
}).render(true);
},
toolclip: {
src: "toolclips/tools/tile-browser.webm",
heading: "CONTROLS.TileBrowser",
items: buildItems("place", "move", "rotate", "hud", "edit", "delete")
}
},
{
name: "foreground",
title: "CONTROLS.TileForeground",
icon: "fa-solid fa-home",
toggle: true,
active: false,
onClick: active => {
this.control.foreground = active;
canvas.tiles._activateSubLayer(active);
canvas.perception.update({refreshLighting: true, refreshTiles: true});
}
}
],
activeTool: "select"
});
// Drawing Tools
controls.push({
name: "drawings",
title: "CONTROLS.GroupDrawing",
layer: "drawings",
icon: "fa-solid fa-pencil-alt",
visible: game.user.can("DRAWING_CREATE"),
tools: [
{
name: "select",
title: "CONTROLS.DrawingSelect",
icon: "fa-solid fa-expand",
toolclip: {
src: "toolclips/tools/drawing-select.webm",
heading: "CONTROLS.DrawingSelect",
items: buildItems("selectAlt", "selectMultiple", "move", "hud", "edit", "delete", "rotate")
}
},
{
name: "rect",
title: "CONTROLS.DrawingRect",
icon: "fa-solid fa-square",
toolclip: {
src: "toolclips/tools/drawing-rect.webm",
heading: "CONTROLS.DrawingRect",
items: buildItems("draw", "move", "hud", "edit", "delete", "rotate")
}
},
{
name: "ellipse",
title: "CONTROLS.DrawingEllipse",
icon: "fa-solid fa-circle",
toolclip: {
src: "toolclips/tools/drawing-ellipse.webm",
heading: "CONTROLS.DrawingEllipse",
items: buildItems("draw", "move", "hud", "edit", "delete", "rotate")
}
},
{
name: "polygon",
title: "CONTROLS.DrawingPoly",
icon: "fa-solid fa-draw-polygon",
toolclip: {
src: "toolclips/tools/drawing-polygon.webm",
heading: "CONTROLS.DrawingPoly",
items: [
{ heading: "CONTROLS.CommonDraw", content: "CONTROLS.DrawingPolyP" },
...buildItems("move", "hud", "edit", "delete", "rotate")
]
}
},
{
name: "freehand",
title: "CONTROLS.DrawingFree",
icon: "fa-solid fa-signature",
toolclip: {
src: "toolclips/tools/drawing-free.webm",
heading: "CONTROLS.DrawingFree",
items: buildItems("draw", "move", "hud", "edit", "delete", "rotate")
}
},
{
name: "text",
title: "CONTROLS.DrawingText",
icon: "fa-solid fa-font",
onClick: () => {
const controlled = canvas.drawings.controlled;
if ( controlled.length === 1 ) controlled[0].enableTextEditing();
},
toolclip: {
src: "toolclips/tools/drawing-text.webm",
heading: "CONTROLS.DrawingText",
items: buildItems("draw", "move", "hud", "edit", "delete", "rotate")
}
},
{
name: "configure",
title: "CONTROLS.DrawingConfig",
icon: "fa-solid fa-cog",
onClick: () => canvas.drawings.configureDefault(),
button: true
},
{
name: "clear",
title: "CONTROLS.DrawingClear",
icon: "fa-solid fa-trash",
visible: isGM,
onClick: () => canvas.drawings.deleteAll(),
button: true
}
],
activeTool: "select"
});
// Walls Layer Tools
controls.push({
name: "walls",
title: "CONTROLS.GroupWall",
layer: "walls",
icon: "fa-solid fa-block-brick",
visible: isGM,
tools: [
{
name: "select",
title: "CONTROLS.WallSelect",
icon: "fa-solid fa-expand",
toolclip: {
src: "toolclips/tools/wall-select.webm",
heading: "CONTROLS.WallSelect",
items: [
...buildItems("selectAlt", "selectMultiple", "move"),
{ heading: "CONTROLS.CommonMoveWithoutSnapping", reference: "CONTROLS.ShiftDrag" },
{ heading: "CONTROLS.CommonEdit", content: "CONTROLS.WallSelectEdit" },
...buildItems("delete")
]
}
},
{
name: "walls",
title: "CONTROLS.WallDraw",
icon: "fa-solid fa-bars",
toolclip: {
src: "toolclips/tools/wall-basic.webm",
heading: "CONTROLS.WallBasic",
items: [
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallBasicBlocks" },
...buildItems("place", "chain", "movePoint", "edit", "delete")
]
}
},
{
name: "terrain",
title: "CONTROLS.WallTerrain",
icon: "fa-solid fa-mountain",
toolclip: {
src: "toolclips/tools/wall-terrain.webm",
heading: "CONTROLS.WallTerrain",
items: [
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallTerrainBlocks" },
...buildItems("place", "chain", "movePoint", "edit", "delete")
]
}
},
{
name: "invisible",
title: "CONTROLS.WallInvisible",
icon: "fa-solid fa-eye-slash",
toolclip: {
src: "toolclips/tools/wall-invisible.webm",
heading: "CONTROLS.WallInvisible",
items: [
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallInvisibleBlocks" },
...buildItems("place", "chain", "movePoint", "edit", "delete")
]
}
},
{
name: "ethereal",
title: "CONTROLS.WallEthereal",
icon: "fa-solid fa-mask",
toolclip: {
src: "toolclips/tools/wall-ethereal.webm",
heading: "CONTROLS.WallEthereal",
items: [
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallEtherealBlocks" },
...buildItems("place", "chain", "movePoint", "edit", "delete")
]
}
},
{
name: "doors",
title: "CONTROLS.WallDoors",
icon: "fa-solid fa-door-open",
toolclip: {
src: "toolclips/tools/wall-door.webm",
heading: "CONTROLS.WallDoors",
items: [
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.DoorBlocks" },
...buildItems("openClose", "openCloseSilently", "lock", "lockSilently", "place", "chain", "movePoint", "edit")
]
}
},
{
name: "secret",
title: "CONTROLS.WallSecret",
icon: "fa-solid fa-user-secret",
toolclip: {
src: "toolclips/tools/wall-secret-door.webm",
heading: "CONTROLS.WallSecret",
items: [
{ heading: "CONTROLS.WallSecretHidden", content: "CONTROLS.WallSecretHiddenP" },
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.DoorBlocks" },
...buildItems("openClose", "openCloseSilently", "lock", "lockSilently", "place", "chain", "movePoint", "edit")
]
}
},
{
name: "window",
title: "CONTROLS.WallWindow",
icon: "fa-solid fa-window-frame",
toolclip: {
src: "toolclips/tools/wall-window.webm",
heading: "CONTROLS.WallWindow",
items: [
{ heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallWindowBlocks" },
...buildItems("place", "chain", "movePoint", "edit", "delete")
]
}
},
{
name: "clone",
title: "CONTROLS.WallClone",
icon: "fa-regular fa-clone"
},
{
name: "snap",
title: "CONTROLS.WallSnap",
icon: "fa-solid fa-plus",
toggle: true,
active: canvas.walls?._forceSnap || false,
onClick: toggled => canvas.walls._forceSnap = toggled,
toolclip: {
src: "toolclips/tools/wall-snap.webm",
heading: "CONTROLS.WallSnap",
items: [{ heading: "CONTROLS.WallSnapH", content: "CONTROLS.WallSnapP" }]
}
},
{
name: "close-doors",
title: "CONTROLS.WallCloseDoors",
icon: "fa-regular fa-door-closed",
onClick: () => {
let updates = canvas.walls.placeables.reduce((arr, w) => {
if ( w.isDoor && (w.document.ds === CONST.WALL_DOOR_STATES.OPEN) ) {
arr.push({_id: w.id, ds: CONST.WALL_DOOR_STATES.CLOSED});
}
return arr;
}, []);
if ( !updates.length ) return;
canvas.scene.updateEmbeddedDocuments("Wall", updates, {sound: false});
ui.notifications.info(game.i18n.format("CONTROLS.WallDoorsClosed", {number: updates.length}));
}
},
{
name: "clear",
title: "CONTROLS.WallClear",
icon: "fa-solid fa-trash",
onClick: () => canvas.walls.deleteAll(),
button: true
}
],
activeTool: "walls"
});
// Lighting Layer Tools
controls.push({
name: "lighting",
title: "CONTROLS.GroupLighting",
layer: "lighting",
icon: "fa-regular fa-lightbulb",
visible: isGM,
tools: [
{
name: "light",
title: "CONTROLS.LightDraw",
icon: "fa-solid fa-lightbulb",
toolclip: {
src: "toolclips/tools/light-draw.webm",
heading: "CONTROLS.LightDraw",
items: buildItems("create", "edit", "rotate", "onOff")
}
},
{
name: "day",
title: "CONTROLS.LightDay",
icon: "fa-solid fa-sun",
onClick: () => canvas.scene.update({darkness: 0.0}, {animateDarkness: 10000}),
button: true,
toolclip: {
src: "toolclips/tools/light-day.webm",
heading: "CONTROLS.LightDay",
items: [
{ heading: "CONTROLS.MakeDayH", content: "CONTROLS.MakeDayP" },
{ heading: "CONTROLS.AutoLightToggleH", content: "CONTROLS.AutoLightToggleP" }
]
}
},
{
name: "night",
title: "CONTROLS.LightNight",
icon: "fa-solid fa-moon",
onClick: () => canvas.scene.update({darkness: 1.0}, {animateDarkness: 10000}),
button: true,
toolclip: {
src: "toolclips/tools/light-night.webm",
heading: "CONTROLS.LightNight",
items: [
{ heading: "CONTROLS.MakeNightH", content: "CONTROLS.MakeNightP" },
{ heading: "CONTROLS.AutoLightToggleH", content: "CONTROLS.AutoLightToggleP" }
]
}
},
{
name: "reset",
title: "CONTROLS.LightReset",
icon: "fa-solid fa-cloud",
onClick: () => {
new Dialog({
title: game.i18n.localize("CONTROLS.FOWResetTitle"),
content: `<p>${game.i18n.localize("CONTROLS.FOWResetDesc")}</p>`,
buttons: {
yes: {
icon: '<i class="fa-solid fa-check"></i>',
label: "Yes",
callback: () => canvas.fog.reset()
},
no: {
icon: '<i class="fa-solid fa-times"></i>',
label: "No"
}
}
}).render(true);
},
button: true,
toolclip: {
src: "toolclips/tools/light-reset.webm",
heading: "CONTROLS.LightReset",
items: [{ paragraph: "CONTROLS.LightResetP" }]
}
},
{
name: "clear",
title: "CONTROLS.LightClear",
icon: "fa-solid fa-trash",
onClick: () => canvas.lighting.deleteAll(),
button: true
}
],
activeTool: "light"
});
// Sounds Layer Tools
controls.push({
name: "sounds",
title: "CONTROLS.GroupSound",
layer: "sounds",
icon: "fa-solid fa-music",
visible: isGM,
tools: [
{
name: "sound",
title: "CONTROLS.SoundDraw",
icon: "fa-solid fa-volume-up",
toolclip: {
src: "toolclips/tools/sound-draw.webm",
heading: "CONTROLS.SoundDraw",
items: buildItems("create", "edit", "rotate", "onOff")
}
},
{
name: "preview",
title: `CONTROLS.SoundPreview${clip}`,
icon: "fa-solid fa-headphones",
toggle: true,
active: canvas.sounds?.livePreview ?? false,
onClick: toggled => {
canvas.sounds.livePreview = toggled;
canvas.sounds.refresh();
},
toolclip: {
src: "toolclips/tools/sound-preview.webm",
heading: "CONTROLS.SoundPreview",
items: [{ paragraph: "CONTROLS.SoundPreviewP" }]
}
},
{
name: "clear",
title: "CONTROLS.SoundClear",
icon: "fa-solid fa-trash",
onClick: () => canvas.sounds.deleteAll(),
button: true
}
],
activeTool: "sound"
});
// Notes Layer Tools
controls.push({
name: "notes",
title: "CONTROLS.GroupNotes",
layer: "notes",
icon: "fa-solid fa-bookmark",
tools: [
{
name: "select",
title: "CONTROLS.NoteSelect",
icon: "fa-solid fa-expand"
},
{
name: "journal",
title: "NOTE.Create",
visible: game.user.hasPermission("NOTE_CREATE"),
icon: CONFIG.JournalEntry.sidebarIcon
},
{
name: "toggle",
title: "CONTROLS.NoteToggle",
icon: "fa-solid fa-map-pin",
toggle: true,
active: game.settings.get("core", NotesLayer.TOGGLE_SETTING),
onClick: toggled => game.settings.set("core", NotesLayer.TOGGLE_SETTING, toggled)
},
{
name: "clear",
title: "CONTROLS.NoteClear",
icon: "fa-solid fa-trash",
visible: isGM,
onClick: () => canvas.notes.deleteAll(),
button: true
}
],
activeTool: "select"
});
// Pass the Scene Controls to a hook function to allow overrides or changes
Hooks.callAll("getSceneControlButtons", controls);
return controls;
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v10
* @ignore
*/
get isRuler() {
return this.activeTool === "ruler";
}
}

View File

@@ -0,0 +1,422 @@
/**
* The global action bar displayed at the bottom of the game view.
* The Hotbar is a UI element at the bottom of the screen which contains Macros as interactive buttons.
* The Hotbar supports 5 pages of global macros which can be dragged and dropped to organize as you wish.
*
* Left-clicking a Macro button triggers its effect.
* Right-clicking the button displays a context menu of Macro options.
* The number keys 1 through 0 activate numbered hotbar slots.
* Pressing the delete key while hovering over a Macro will remove it from the bar.
*
* @see {@link Macros}
* @see {@link Macro}
*/
class Hotbar extends Application {
constructor(options) {
super(options);
game.macros.apps.push(this);
/**
* The currently viewed macro page
* @type {number}
*/
this.page = 1;
/**
* The currently displayed set of macros
* @type {Macro[]}
*/
this.macros = [];
/**
* Track collapsed state
* @type {boolean}
*/
this._collapsed = false;
/**
* Track which hotbar slot is the current hover target, if any
* @type {number|null}
*/
this._hover = null;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
id: "hotbar",
template: "templates/hud/hotbar.html",
popOut: false,
dragDrop: [{ dragSelector: ".macro-icon", dropSelector: "#macro-list" }]
});
}
/* -------------------------------------------- */
/**
* Whether the hotbar is locked.
* @returns {boolean}
*/
get locked() {
return game.settings.get("core", "hotbarLock");
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
this.macros = this._getMacrosByPage(this.page);
return {
page: this.page,
macros: this.macros,
barClass: this._collapsed ? "collapsed" : "",
locked: this.locked
};
}
/* -------------------------------------------- */
/**
* Get the Array of Macro (or null) values that should be displayed on a numbered page of the bar
* @param {number} page
* @returns {Macro[]}
* @private
*/
_getMacrosByPage(page) {
const macros = game.user.getHotbarMacros(page);
for ( let [i, slot] of macros.entries() ) {
slot.key = i<9 ? i+1 : 0;
slot.icon = slot.macro ? slot.macro.img : null;
slot.cssClass = slot.macro ? "active" : "inactive";
slot.tooltip = slot.macro ? slot.macro.name : null;
}
return macros;
}
/* -------------------------------------------- */
/**
* Collapse the Hotbar, minimizing its display.
* @returns {Promise} A promise which resolves once the collapse animation completes
*/
async collapse() {
if ( this._collapsed ) return true;
const toggle = this.element.find("#bar-toggle");
const icon = toggle.children("i");
const bar = this.element.find("#action-bar");
return new Promise(resolve => {
bar.slideUp(200, () => {
bar.addClass("collapsed");
icon.removeClass("fa-caret-down").addClass("fa-caret-up");
this._collapsed = true;
resolve(true);
});
});
}
/* -------------------------------------------- */
/**
* Expand the Hotbar, displaying it normally.
* @returns {Promise} A promise which resolves once the expand animation completes
*/
async expand() {
if ( !this._collapsed ) return true;
const toggle = this.element.find("#bar-toggle");
const icon = toggle.children("i");
const bar = this.element.find("#action-bar");
return new Promise(resolve => {
bar.slideDown(200, () => {
bar.css("display", "");
bar.removeClass("collapsed");
icon.removeClass("fa-caret-up").addClass("fa-caret-down");
this._collapsed = false;
resolve(true);
});
});
}
/* -------------------------------------------- */
/**
* Change to a specific numbered page from 1 to 5
* @param {number} page The page number to change to.
*/
changePage(page) {
this.page = Math.clamped(page ?? 1, 1, 5);
this.render();
}
/* -------------------------------------------- */
/**
* Change the page of the hotbar by cycling up (positive) or down (negative)
* @param {number} direction The direction to cycle
*/
cyclePage(direction) {
direction = Number.isNumeric(direction) ? Math.sign(direction) : 1;
if ( direction > 0 ) {
this.page = this.page < 5 ? this.page+1 : 1;
} else {
this.page = this.page > 1 ? this.page-1 : 5;
}
this.render();
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
// Macro actions
html.find("#bar-toggle").click(this._onToggleBar.bind(this));
html.find("#macro-directory").click(ev => ui.macros.renderPopout(true));
html.find(".macro").click(this._onClickMacro.bind(this));
html.find(".page-control").click(this._onClickPageControl.bind(this));
// Activate context menu
this._contextMenu(html);
}
/* -------------------------------------------- */
/** @inheritdoc */
_contextMenu(html) {
ContextMenu.create(this, html, ".macro", this._getEntryContextOptions());
}
/* -------------------------------------------- */
/**
* Get the Macro entry context options
* @returns {object[]} The Macro entry context options
* @private
*/
_getEntryContextOptions() {
return [
{
name: "MACRO.Edit",
icon: '<i class="fas fa-edit"></i>',
condition: li => {
const macro = game.macros.get(li.data("macro-id"));
return macro ? macro.isOwner : false;
},
callback: li => {
const macro = game.macros.get(li.data("macro-id"));
macro.sheet.render(true);
}
},
{
name: "MACRO.Remove",
icon: '<i class="fas fa-times"></i>',
condition: li => !!li.data("macro-id"),
callback: li => game.user.assignHotbarMacro(null, Number(li.data("slot")))
},
{
name: "MACRO.Delete",
icon: '<i class="fas fa-trash"></i>',
condition: li => {
const macro = game.macros.get(li.data("macro-id"));
return macro ? macro.isOwner : false;
},
callback: li => {
const macro = game.macros.get(li.data("macro-id"));
return Dialog.confirm({
title: `${game.i18n.localize("MACRO.Delete")} ${macro.name}`,
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("MACRO.DeleteWarning")}</p>`,
yes: macro.delete.bind(macro)
});
}
}
];
}
/* -------------------------------------------- */
/**
* Handle left-click events to
* @param {MouseEvent} event The originating click event
* @protected
*/
async _onClickMacro(event) {
event.preventDefault();
const li = event.currentTarget;
// Case 1 - create a new Macro
if ( li.classList.contains("inactive") ) {
const macro = await Macro.create({name: "New Macro", type: "chat", scope: "global"});
await game.user.assignHotbarMacro(macro, Number(li.dataset.slot));
macro.sheet.render(true);
}
// Case 2 - trigger a Macro
else {
const macro = game.macros.get(li.dataset.macroId);
return macro.execute();
}
}
/* -------------------------------------------- */
/**
* Handle pagination controls
* @param {Event} event The originating click event
* @private
*/
_onClickPageControl(event) {
const action = event.currentTarget.dataset.action;
switch ( action ) {
case "page-up":
this.cyclePage(1);
break;
case "page-down":
this.cyclePage(-1);
break;
case "lock":
this._toggleHotbarLock();
break;
}
}
/* -------------------------------------------- */
/** @override */
_canDragStart(selector) {
return !this.locked;
}
/* -------------------------------------------- */
/** @override */
_onDragStart(event) {
const li = event.currentTarget.closest(".macro");
const macro = game.macros.get(li.dataset.macroId);
if ( !macro ) return false;
const dragData = foundry.utils.mergeObject(macro.toDragData(), {slot: li.dataset.slot});
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
}
/* -------------------------------------------- */
/** @override */
_canDragDrop(selector) {
return true;
}
/* -------------------------------------------- */
/** @override */
async _onDrop(event) {
event.preventDefault();
const li = event.target.closest(".macro");
const slot = Number(li.dataset.slot);
const data = TextEditor.getDragEventData(event);
if ( Hooks.call("hotbarDrop", this, data, slot) === false ) return;
// Forbid overwriting macros if the hotbar is locked.
const existingMacro = game.macros.get(game.user.hotbar[slot]);
if ( existingMacro && this.locked ) return ui.notifications.warn("MACRO.CannotOverwrite", { localize: true });
// Get the dropped document
const cls = getDocumentClass(data.type);
const doc = await cls?.fromDropData(data);
if ( !doc ) return;
// Get the Macro to add to the bar
let macro;
if ( data.type === "Macro" ) macro = game.macros.has(doc.id) ? doc : await cls.create(doc.toObject());
else if ( data.type === "RollTable" ) macro = await this._createRollTableRollMacro(doc);
else macro = await this._createDocumentSheetToggle(doc);
// Assign the macro to the hotbar
if ( !macro ) return;
return game.user.assignHotbarMacro(macro, slot, {fromSlot: data.slot});
}
/* -------------------------------------------- */
/**
* Create a Macro which rolls a RollTable when executed
* @param {Document} table The RollTable document
* @returns {Promise<Macro>} A created Macro document to add to the bar
* @private
*/
async _createRollTableRollMacro(table) {
const command = `const table = await fromUuid("${table.uuid}");\nawait table.draw({roll: true, displayChat: true});`;
return Macro.implementation.create({
name: `${game.i18n.localize("TABLE.Roll")} ${table.name}`,
type: "script",
img: table.img,
command
});
}
/* -------------------------------------------- */
/**
* Create a Macro document which can be used to toggle display of a Journal Entry.
* @param {Document} doc A Document which should be toggled
* @returns {Promise<Macro>} A created Macro document to add to the bar
* @protected
*/
async _createDocumentSheetToggle(doc) {
const name = doc.name || `${game.i18n.localize(doc.constructor.metadata.label)} ${doc.id}`;
return Macro.implementation.create({
name: `${game.i18n.localize("Display")} ${name}`,
type: CONST.MACRO_TYPES.SCRIPT,
img: "icons/svg/book.svg",
command: `await Hotbar.toggleDocumentSheet("${doc.uuid}");`
});
}
/* -------------------------------------------- */
/**
* Handle click events to toggle display of the macro bar
* @param {Event} event
* @private
*/
_onToggleBar(event) {
event.preventDefault();
if ( this._collapsed ) return this.expand();
else return this.collapse();
}
/* -------------------------------------------- */
/**
* Toggle the hotbar's lock state.
* @returns {Promise<Hotbar>}
* @protected
*/
async _toggleHotbarLock() {
await game.settings.set("core", "hotbarLock", !this.locked);
return this.render();
}
/* -------------------------------------------- */
/**
* Handle toggling a document sheet.
* @param {string} uuid The Document UUID to display
* @returns {Promise<void>|Application|*}
*/
static async toggleDocumentSheet(uuid) {
const doc = await fromUuid(uuid);
if ( !doc ) {
return ui.notifications.warn(game.i18n.format("WARNING.ObjectDoesNotExist", {
name: game.i18n.localize("Document"),
identifier: uuid
}));
}
const sheet = doc.sheet;
return sheet.rendered ? sheet.close() : sheet.render(true);
}
}

View File

@@ -0,0 +1,216 @@
/**
* An abstract base class for displaying a heads-up-display interface bound to a Placeable Object on the canvas
* @type {Application}
* @abstract
* @interface
* @param {PlaceableObject} object The {@link PlaceableObject} this HUD is bound to.
* @param {ApplicationOptions} [options] Application configuration options.
*/
class BasePlaceableHUD extends Application {
/**
* Reference a PlaceableObject this HUD is currently bound to
* @type {PlaceableObject}
*/
object = undefined;
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["placeable-hud"],
popOut: false
});
}
/* -------------------------------------------- */
/**
* Convenience access for the canvas layer which this HUD modifies
* @type {PlaceablesLayer}
*/
get layer() {
return this.object?.layer;
}
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/**
* Bind the HUD to a new PlaceableObject and display it
* @param {PlaceableObject} object A PlaceableObject instance to which the HUD should be bound
*/
bind(object) {
const states = this.constructor.RENDER_STATES;
if ( [states.CLOSING, states.RENDERING].includes(this._state) ) return;
if ( this.object ) this.clear();
// Record the new object
if ( !(object instanceof PlaceableObject) || (object.scene !== canvas.scene) ) {
throw new Error("You may only bind a HUD instance to a PlaceableObject in the currently viewed Scene.");
}
this.object = object;
// Render the HUD
this.render(true);
this.element.hide().fadeIn(200);
}
/* -------------------------------------------- */
/**
* Clear the HUD by fading out it's active HTML and recording the new display state
*/
clear() {
let states = this.constructor.RENDER_STATES;
if ( this._state <= states.NONE ) return;
this._state = states.CLOSING;
// Unbind
this.object = null;
this.element.hide();
this._element = null;
this._state = states.NONE;
}
/* -------------------------------------------- */
/** @override */
async _render(...args) {
await super._render(...args);
this.setPosition();
}
/* -------------------------------------------- */
/** @override */
getData(options = {}) {
const data = this.object.document.toObject();
return foundry.utils.mergeObject(data, {
id: this.id,
classes: this.options.classes.join(" "),
appId: this.appId,
isGM: game.user.isGM,
icons: CONFIG.controlIcons
});
}
/* -------------------------------------------- */
/** @override */
setPosition({left, top, width, height, scale} = {}) {
const position = {
width: width || this.object.width,
height: height || this.object.height,
left: left ?? this.object.x,
top: top ?? this.object.y
};
this.element.css(position);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
html.find(".control-icon").click(this._onClickControl.bind(this));
}
/* -------------------------------------------- */
/**
* Handle mouse clicks to control a HUD control button
* @param {PointerEvent} event The originating click event
* @protected
*/
_onClickControl(event) {
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "visibility":
return this._onToggleVisibility(event);
case "locked":
return this._onToggleLocked(event);
case "sort-up":
return this._onSort(event, true);
case "sort-down":
return this._onSort(event, false);
}
}
/* -------------------------------------------- */
/**
* Toggle the visible state of all controlled objects in the Layer
* @param {PointerEvent} event The originating click event
* @private
*/
async _onToggleVisibility(event) {
event.preventDefault();
// Toggle the visible state
const isHidden = this.object.document.hidden;
const updates = this.layer.controlled.map(o => {
return {_id: o.id, hidden: !isHidden};
});
// Update all objects
return canvas.scene.updateEmbeddedDocuments(this.object.document.documentName, updates);
}
/* -------------------------------------------- */
/**
* Toggle locked state of all controlled objects in the Layer
* @param {PointerEvent} event The originating click event
* @private
*/
async _onToggleLocked(event) {
event.preventDefault();
// Toggle the visible state
const isLocked = this.object.document.locked;
const updates = this.layer.controlled.map(o => {
return {_id: o.id, locked: !isLocked};
});
// Update all objects
event.currentTarget.classList.toggle("active", !isLocked);
return canvas.scene.updateEmbeddedDocuments(this.object.document.documentName, updates);
}
/* -------------------------------------------- */
/**
* Handle sorting the z-order of the object
* @param {boolean} up Move the object upwards in the vertical stack?
* @param {PointerEvent} event The originating mouse click event
* @returns {Promise}
* @protected
*/
async _onSort(event, up) {
event.preventDefault();
const siblings = this.layer.placeables;
const controlled = this.layer.controlled.filter(o => !o.document.locked);
// Determine target sort index
let z = 0;
if ( up ) {
controlled.sort((a, b) => a.document.z - b.document.z);
z = siblings.length ? Math.max(...siblings.map(o => o.document.z)) + 1 : 1;
} else {
controlled.sort((a, b) => b.document.z - a.document.z);
z = siblings.length ? Math.min(...siblings.map(o => o.document.z)) - 1 : -1;
}
// Update all controlled objects
const updates = controlled.map((o, i) => {
let d = up ? i : i * -1;
return {_id: o.id, z: z + d};
});
return canvas.scene.updateEmbeddedDocuments(this.object.document.documentName, updates);
}
}

View File

@@ -0,0 +1,82 @@
/**
* The main menu application which is toggled via the ESC key.
* @extends {Application}
*/
class MainMenu extends Application {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "menu",
template: "templates/hud/menu.html",
popOut: false
});
}
/* ----------------------------------------- */
/**
* The structure of menu items
* @returns {Object<{label: string, icon: string, enabled: boolean, onClick: Function}>}
*/
get items() {
return {
reload: {
label: "MENU.Reload",
icon: '<i class="fas fa-redo"></i>',
enabled: true,
onClick: () => window.location.reload()
},
logout: {
label: "MENU.Logout",
icon: '<i class="fas fa-user"></i>',
enabled: true,
onClick: () => game.logOut()
},
players: {
label: "MENU.Players",
icon: '<i class="fas fa-users"></i>',
enabled: game.user.isGM && !game.data.demoMode,
onClick: () => window.location.href = "./players"
},
world: {
label: "GAME.ReturnSetup",
icon: '<i class="fas fa-globe"></i>',
enabled: game.user.hasRole("GAMEMASTER") && !game.data.demoMode,
onClick: () => {
this.close();
game.shutDown();
}
}
};
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
return {
items: this.items
};
}
/* ----------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
for ( let [k, v] of Object.entries(this.items) ) {
html.find(`.menu-${k}`).click(v.onClick);
}
}
/* ----------------------------------------- */
/**
* Toggle display of the menu (or render it in the first place)
*/
toggle() {
let menu = this.element;
if ( !this.rendered ) this.render(true);
else menu.slideToggle(150);
}
}

View File

@@ -0,0 +1,310 @@
/**
* The UI element which displays the Scene documents which are currently enabled for quick navigation.
*/
class SceneNavigation extends Application {
constructor(options) {
super(options);
game.scenes.apps.push(this);
/**
* Navigation collapsed state
* @type {boolean}
*/
this._collapsed = false;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "navigation",
template: "templates/hud/navigation.html",
popOut: false,
dragDrop: [{dragSelector: ".scene"}]
});
}
/* -------------------------------------------- */
/**
* Return an Array of Scenes which are displayed in the Navigation bar
* @returns {Scene[]}
*/
get scenes() {
const scenes = game.scenes.filter(s => {
return (s.navigation && s.visible) || s.active || s.isView;
});
scenes.sort((a, b) => a.navOrder - b.navOrder);
return scenes;
}
/* -------------------------------------------- */
/* Application Rendering
/* -------------------------------------------- */
/** @inheritdoc */
render(force, context = {}) {
let {renderContext, renderData} = context;
if ( renderContext ) {
const events = ["createScene", "updateScene", "deleteScene"];
if ( !events.includes(renderContext) ) return this;
const updateKeys = ["name", "ownership", "ownership.default", "active", "navigation", "navName", "navOrder"];
if ( renderContext === "updateScene" && !updateKeys.some(k => renderData.hasOwnProperty(k)) ) return this;
}
return super.render(force, context);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
await super._render(force, options);
const loading = document.getElementById("loading");
const nav = this.element[0];
loading.style.top = `${nav.offsetTop + nav.offsetHeight}px`;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const scenes = this.scenes.map(scene => {
return {
id: scene.id,
active: scene.active,
name: TextEditor.truncateText(scene.navName || scene.name, {maxLength: 32}),
tooltip: scene.navName && game.user.isGM ? scene.name : null,
users: game.users.reduce((arr, u) => {
if ( u.active && ( u.viewedScene === scene.id) ) arr.push({letter: u.name[0], color: u.color});
return arr;
}, []),
visible: game.user.isGM || scene.isOwner || scene.active,
css: [
scene.isView ? "view" : null,
scene.active ? "active" : null,
scene.ownership.default === 0 ? "gm" : null
].filterJoin(" ")
};
});
return {collapsed: this._collapsed, scenes: scenes};
}
/* -------------------------------------------- */
/**
* A hook event that fires when the SceneNavigation menu is expanded or collapsed.
* @function collapseSceneNavigation
* @memberof hookEvents
* @param {SceneNavigation} sceneNavigation The SceneNavigation application
* @param {boolean} collapsed Whether the SceneNavigation is now collapsed or not
*/
/* -------------------------------------------- */
/**
* Expand the SceneNavigation menu, sliding it down if it is currently collapsed
*/
expand() {
if ( !this._collapsed ) return true;
const nav = this.element;
const icon = nav.find("#nav-toggle i.fas");
const ul = nav.children("#scene-list");
return new Promise(resolve => {
ul.slideDown(200, () => {
nav.removeClass("collapsed");
icon.removeClass("fa-caret-down").addClass("fa-caret-up");
this._collapsed = false;
Hooks.callAll("collapseSceneNavigation", this, this._collapsed);
return resolve(true);
});
});
}
/* -------------------------------------------- */
/**
* Collapse the SceneNavigation menu, sliding it up if it is currently expanded
* @returns {Promise<boolean>}
*/
async collapse() {
if ( this._collapsed ) return true;
const nav = this.element;
const icon = nav.find("#nav-toggle i.fas");
const ul = nav.children("#scene-list");
return new Promise(resolve => {
ul.slideUp(200, () => {
nav.addClass("collapsed");
icon.removeClass("fa-caret-up").addClass("fa-caret-down");
this._collapsed = true;
Hooks.callAll("collapseSceneNavigation", this, this._collapsed);
return resolve(true);
});
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// Click event listener
const scenes = html.find(".scene");
scenes.click(this._onClickScene.bind(this));
html.find("#nav-toggle").click(this._onToggleNav.bind(this));
// Activate Context Menu
const contextOptions = this._getContextMenuOptions();
Hooks.call("getSceneNavigationContext", html, contextOptions);
if ( contextOptions ) new ContextMenu(html, ".scene", contextOptions);
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be applied for Scenes in the menu
* @returns {object[]} The Array of context options passed to the ContextMenu instance
* @private
*/
_getContextMenuOptions() {
return [
{
name: "SCENES.Activate",
icon: '<i class="fas fa-bullseye"></i>',
condition: li => game.user.isGM && !game.scenes.get(li.data("sceneId")).active,
callback: li => {
let scene = game.scenes.get(li.data("sceneId"));
scene.activate();
}
},
{
name: "SCENES.Configure",
icon: '<i class="fas fa-cogs"></i>',
condition: game.user.isGM,
callback: li => {
let scene = game.scenes.get(li.data("sceneId"));
scene.sheet.render(true);
}
},
{
name: "SCENES.Notes",
icon: '<i class="fas fa-scroll"></i>',
condition: li => {
if ( !game.user.isGM ) return false;
const scene = game.scenes.get(li.data("sceneId"));
return !!scene.journal;
},
callback: li => {
const scene = game.scenes.get(li.data("sceneId"));
const entry = scene.journal;
if ( entry ) {
const sheet = entry.sheet;
const options = {};
if ( scene.journalEntryPage ) options.pageId = scene.journalEntryPage;
sheet.render(true, options);
}
}
},
{
name: "SCENES.Preload",
icon: '<i class="fas fa-download"></i>',
condition: game.user.isGM,
callback: li => {
let sceneId = li.attr("data-scene-id");
game.scenes.preload(sceneId, true);
}
},
{
name: "SCENES.ToggleNav",
icon: '<i class="fas fa-compass"></i>',
condition: li => {
const scene = game.scenes.get(li.data("sceneId"));
return game.user.isGM && (!scene.active);
},
callback: li => {
const scene = game.scenes.get(li.data("sceneId"));
scene.update({navigation: !scene.navigation});
}
}
];
}
/* -------------------------------------------- */
/**
* Handle left-click events on the scenes in the navigation menu
* @param {PointerEvent} event
* @private
*/
_onClickScene(event) {
event.preventDefault();
let sceneId = event.currentTarget.dataset.sceneId;
game.scenes.get(sceneId).view();
}
/* -------------------------------------------- */
/** @override */
_onDragStart(event) {
const sceneId = event.currentTarget.dataset.sceneId;
const scene = game.scenes.get(sceneId);
event.dataTransfer.setData("text/plain", JSON.stringify(scene.toDragData()));
}
/* -------------------------------------------- */
/** @override */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
if ( data.type !== "Scene" ) return;
// Identify the document, the drop target, and the set of siblings
const scene = await Scene.implementation.fromDropData(data);
const dropTarget = event.target.closest(".scene") || null;
const sibling = dropTarget ? game.scenes.get(dropTarget.dataset.sceneId) : null;
if ( sibling && (sibling.id === scene.id) ) return;
const siblings = this.scenes.filter(s => s.id !== scene.id);
// Update the navigation sorting for each Scene
return scene.sortRelative({
target: sibling,
siblings: siblings,
sortKey: "navOrder"
});
}
/* -------------------------------------------- */
/**
* Handle navigation menu toggle click events
* @param {Event} event
* @private
*/
_onToggleNav(event) {
event.preventDefault();
if ( this._collapsed ) return this.expand();
else return this.collapse();
}
/* -------------------------------------------- */
/**
* Display progress of some major operation like loading Scene textures.
* @param {object} options Options for how the progress bar is displayed
* @param {string} options.label A text label to display
* @param {number} options.pct A percentage of progress between 0 and 100
*/
static displayProgressBar({label, pct} = {}) {
const loader = document.getElementById("loading");
pct = Math.clamped(pct, 0, 100);
loader.querySelector("#context").textContent = label;
loader.querySelector("#loading-bar").style.width = `${pct}%`;
loader.querySelector("#progress").textContent = `${pct}%`;
loader.style.display = "block";
if ( (pct === 100) && !loader.hidden ) $(loader).fadeOut(2000);
}
}

View File

@@ -0,0 +1,18 @@
/**
* Pause notification in the HUD
* @extends {Application}
*/
class Pause extends Application {
static get defaultOptions() {
const options = super.defaultOptions;
options.id = "pause";
options.template = "templates/hud/pause.html";
options.popOut = false;
return options;
}
/** @override */
getData(options={}) {
return { paused: game.paused };
}
}

View File

@@ -0,0 +1,287 @@
/**
* The UI element which displays the list of Users who are currently playing within the active World.
* @extends {Application}
*/
class PlayerList extends Application {
constructor(options) {
super(options);
game.users.apps.push(this);
/**
* An internal toggle for whether to show offline players or hide them
* @type {boolean}
* @private
*/
this._showOffline = false;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "players",
template: "templates/user/players.html",
popOut: false
});
}
/* -------------------------------------------- */
/* Application Rendering */
/* -------------------------------------------- */
/**
* Whether the players list is in a configuration where it is hidden.
* @returns {boolean}
*/
get isHidden() {
if ( game.webrtc.mode === AVSettings.AV_MODES.DISABLED ) return false;
const { client, verticalDock } = game.webrtc.settings;
return verticalDock && client.hidePlayerList && !client.hideDock && !ui.webrtc.hidden;
}
/* -------------------------------------------- */
/** @override */
render(force, context={}) {
this._positionInDOM();
const { renderContext, renderData } = context;
if ( renderContext ) {
const events = ["createUser", "updateUser", "deleteUser"];
if ( !events.includes(renderContext) ) return this;
const updateKeys = ["name", "ownership", "ownership.default", "active", "navigation"];
if ( renderContext === "updateUser" && !updateKeys.some(k => renderData.hasOwnProperty(k)) ) return this;
}
return super.render(force, context);
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
// Process user data by adding extra characteristics
const users = game.users.filter(u => this._showOffline || u.active).map(user => {
const u = user.toObject(false);
u.active = user.active;
u.isGM = user.isGM;
u.isSelf = user.isSelf;
u.charname = user.character?.name.split(" ")[0] || "";
u.color = u.active ? u.color : "#333333";
u.border = u.active ? user.border : "#000000";
u.displayName = this._getDisplayName(u);
return u;
}).sort((a, b) => {
if ( (b.role >= CONST.USER_ROLES.ASSISTANT) && (b.role > a.role) ) return 1;
return a.name.localeCompare(b.name);
});
// Return the data for rendering
return {
users,
hide: this.isHidden,
showOffline: this._showOffline
};
}
/* -------------------------------------------- */
/**
* Prepare a displayed name string for the User which includes their name, pronouns, character, or GM tag.
* @returns {string}
* @protected
*/
_getDisplayName(user) {
const displayNamePart = [user.name];
if ( user.pronouns ) displayNamePart.push(`(${user.pronouns})`);
if ( user.isGM ) displayNamePart.push(`[${game.i18n.localize("USER.GM")}]`);
else if ( user.charname ) displayNamePart.push(`[${user.charname}]`);
return displayNamePart.join(" ");
}
/* -------------------------------------------- */
/**
* Position this Application in the main DOM appropriately.
* @protected
*/
_positionInDOM() {
document.body.classList.toggle("players-hidden", this.isHidden);
if ( (game.webrtc.mode === AVSettings.AV_MODES.DISABLED) || this.isHidden || !this.element.length ) return;
const element = this.element[0];
const cameraViews = ui.webrtc.element[0];
const uiTop = document.getElementById("ui-top");
const uiLeft = document.getElementById("ui-left");
const { client, verticalDock } = game.webrtc.settings;
const inDock = verticalDock && !client.hideDock && !ui.webrtc.hidden;
if ( inDock && !cameraViews?.contains(element) ) {
cameraViews.appendChild(element);
uiTop.classList.remove("offset");
} else if ( !inDock && !uiLeft.contains(element) ) {
uiLeft.appendChild(element);
uiTop.classList.add("offset");
}
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
// Toggle online/offline
html.find("h3").click(this._onToggleOfflinePlayers.bind(this));
// Context menu
const contextOptions = this._getUserContextOptions();
Hooks.call("getUserContextOptions", html, contextOptions);
new ContextMenu(html, ".player", contextOptions);
}
/* -------------------------------------------- */
/**
* Return the default context options available for the Players application
* @returns {object[]}
* @private
*/
_getUserContextOptions() {
return [
{
name: game.i18n.localize("PLAYERS.ConfigTitle"),
icon: '<i class="fas fa-male"></i>',
condition: li => game.user.isGM || (li[0].dataset.userId === game.user.id),
callback: li => {
const user = game.users.get(li[0].dataset.userId);
user?.sheet.render(true);
}
},
{
name: game.i18n.localize("PLAYERS.ViewAvatar"),
icon: '<i class="fas fa-image"></i>',
condition: li => {
const user = game.users.get(li[0].dataset.userId);
return user.avatar !== CONST.DEFAULT_TOKEN;
},
callback: li => {
let user = game.users.get(li.data("user-id"));
new ImagePopout(user.avatar, {
title: user.name,
uuid: user.uuid
}).render(true);
}
},
{
name: game.i18n.localize("PLAYERS.PullToScene"),
icon: '<i class="fas fa-directions"></i>',
condition: li => game.user.isGM && (li[0].dataset.userId !== game.user.id),
callback: li => game.socket.emit("pullToScene", canvas.scene.id, li.data("user-id"))
},
{
name: game.i18n.localize("PLAYERS.Kick"),
icon: '<i class="fas fa-door-open"></i>',
condition: li => {
const user = game.users.get(li[0].dataset.userId);
return game.user.isGM && user.active && !user.isSelf;
},
callback: li => {
const user = game.users.get(li[0].dataset.userId);
return this.#kickUser(user);
}
},
{
name: game.i18n.localize("PLAYERS.Ban"),
icon: '<i class="fas fa-ban"></i>',
condition: li => {
const user = game.users.get(li[0].dataset.userId);
return game.user.isGM && !user.isSelf && (user.role !== CONST.USER_ROLES.NONE);
},
callback: li => {
const user = game.users.get(li[0].dataset.userId);
return this.#banUser(user);
}
},
{
name: game.i18n.localize("PLAYERS.UnBan"),
icon: '<i class="fas fa-ban"></i>',
condition: li => {
const user = game.users.get(li[0].dataset.userId);
return game.user.isGM && !user.isSelf && (user.role === CONST.USER_ROLES.NONE);
},
callback: li => {
const user = game.users.get(li[0].dataset.userId);
return this.#unbanUser(user);
}
},
{
name: game.i18n.localize("WEBRTC.TooltipShowUser"),
icon: '<i class="fas fa-eye"></i>',
condition: li => {
const userId = li.data("userId");
return game.webrtc.settings.client.users[userId]?.blocked;
},
callback: async li => {
const userId = li.data("userId");
await game.webrtc.settings.set("client", `users.${userId}.blocked`, false);
ui.webrtc.render();
}
}
];
}
/* -------------------------------------------- */
/**
* Toggle display of the Players hud setting for whether to display offline players
* @param {Event} event The originating click event
* @private
*/
_onToggleOfflinePlayers(event) {
event.preventDefault();
this._showOffline = !this._showOffline;
this.render();
}
/* -------------------------------------------- */
/**
* Temporarily remove a User from the World by banning and then un-banning them.
* @param {User} user The User to kick
* @returns {Promise<void>}
*/
async #kickUser(user) {
const role = user.role;
await user.update({role: CONST.USER_ROLES.NONE});
await user.update({role}, {diff: false});
ui.notifications.info(`${user.name} has been <strong>kicked</strong> from the World.`);
}
/* -------------------------------------------- */
/**
* Ban a User by changing their role to "NONE".
* @param {User} user The User to ban
* @returns {Promise<void>}
*/
async #banUser(user) {
if ( user.role === CONST.USER_ROLES.NONE ) return;
await user.update({role: CONST.USER_ROLES.NONE});
ui.notifications.info(`${user.name} has been <strong>banned</strong> from the World.`);
}
/* -------------------------------------------- */
/**
* Unban a User by changing their role to "PLAYER".
* @param {User} user The User to unban
* @returns {Promise<void>}
*/
async #unbanUser(user) {
if ( user.role !== CONST.USER_ROLES.NONE ) return;
await user.update({role: CONST.USER_ROLES.PLAYER});
ui.notifications.info(`${user.name} has been <strong>unbanned</strong> from the World.`);
}
}

View File

@@ -0,0 +1,364 @@
/**
* A helper class which assists with localization and string translation
* @param {string} serverLanguage The default language configuration setting for the server
*/
class Localization {
constructor(serverLanguage) {
// Obtain the default language from application settings
const [defaultLanguage, defaultModule] = (serverLanguage || "en.core").split(".");
/**
* The target language for localization
* @type {string}
*/
this.lang = defaultLanguage;
/**
* The package authorized to provide default language configurations
* @type {string}
*/
this.defaultModule = defaultModule;
/**
* The translation dictionary for the target language
* @type {Object}
*/
this.translations = {};
/**
* Fallback translations if the target keys are not found
* @type {Object}
*/
this._fallback = {};
}
/* -------------------------------------------- */
/**
* Cached store of Intl.ListFormat instances.
* @type {Object<Intl.ListFormat>}
*/
#formatters = {};
/* -------------------------------------------- */
/**
* Initialize the Localization module
* Discover available language translations and apply the current language setting
* @returns {Promise<void>} A Promise which resolves once languages are initialized
*/
async initialize() {
const clientLanguage = await game.settings.get("core", "language") || this.lang;
// Discover which modules available to the client
this._discoverSupportedLanguages();
// Activate the configured language
if ( clientLanguage !== this.lang ) this.defaultModule = "core";
await this.setLanguage(clientLanguage || this.lang);
// Define type labels
if ( game.system ) {
for ( let [documentName, types] of Object.entries(game.documentTypes) ) {
const config = CONFIG[documentName];
config.typeLabels = config.typeLabels || {};
for ( let t of types ) {
if ( config.typeLabels[t] ) continue;
const key = `TYPES.${documentName}.${t}`;
config.typeLabels[t] = key;
/** @deprecated since v11 */
const legacyKey = `${documentName.toUpperCase()}.Type${t.titleCase()}`;
if ( !this.has(key) && this.has(legacyKey) ) {
foundry.utils.logCompatibilityWarning(
`You are using the '${legacyKey}' localization key which has been deprecated. `
+ `Please define a '${key}' key instead.`,
{since: 11, until: 13}
);
config.typeLabels[t] = legacyKey;
}
}
}
}
Hooks.callAll("i18nInit");
}
/* -------------------------------------------- */
/**
* Set a language as the active translation source for the session
* @param {string} lang A language string in CONFIG.supportedLanguages
* @returns {Promise<void>} A Promise which resolves once the translations for the requested language are ready
*/
async setLanguage(lang) {
if ( !Object.keys(CONFIG.supportedLanguages).includes(lang) ) {
console.error(`Cannot set language ${lang}, as it is not in the supported set. Falling back to English`);
lang = "en";
}
this.lang = lang;
document.documentElement.setAttribute("lang", this.lang);
// Load translations and English fallback strings
this.translations = await this._getTranslations(lang);
if ( lang !== "en" ) this._fallback = await this._getTranslations("en");
}
/* -------------------------------------------- */
/**
* Discover the available supported languages from the set of packages which are provided
* @returns {object} The resulting configuration of supported languages
* @private
*/
_discoverSupportedLanguages() {
const sl = CONFIG.supportedLanguages;
// Define packages
const packages = Array.from(game.modules.values());
if ( game.world ) packages.push(game.world);
if ( game.system ) packages.push(game.system);
if ( game.worlds ) packages.push(...game.worlds.values());
if ( game.systems ) packages.push(...game.systems.values());
// Registration function
const register = pkg => {
if ( !pkg.languages.size ) return;
for ( let l of pkg.languages ) {
if ( !sl.hasOwnProperty(l.lang) ) sl[l.lang] = l.name;
}
};
// Register core translation languages first
for ( let m of game.modules ) {
if ( m.coreTranslation ) register(m);
}
// Discover and register languages
for ( let p of packages ) {
if ( p.coreTranslation ) continue;
register(p);
}
return sl;
}
/* -------------------------------------------- */
/**
* Prepare the dictionary of translation strings for the requested language
* @param {string} lang The language for which to load translations
* @returns {Promise<object>} The retrieved translations object
* @private
*/
async _getTranslations(lang) {
const translations = {};
const promises = [];
// Include core supported translations
if ( CONST.CORE_SUPPORTED_LANGUAGES.includes(lang) ) {
promises.push(this._loadTranslationFile(`lang/${lang}.json`));
}
// Game system translations
if ( game.system ) {
this._filterLanguagePaths(game.system, lang).forEach(path => {
promises.push(this._loadTranslationFile(path));
});
}
// Module translations
for ( let module of game.modules.values() ) {
if ( !module.active && (module.id !== this.defaultModule) ) continue;
this._filterLanguagePaths(module, lang).forEach(path => {
promises.push(this._loadTranslationFile(path));
});
}
// Game world translations
if ( game.world ) {
this._filterLanguagePaths(game.world, lang).forEach(path => {
promises.push(this._loadTranslationFile(path));
});
}
// Merge translations in load order and return the prepared dictionary
await Promise.all(promises);
for ( let p of promises ) {
let json = await p;
foundry.utils.mergeObject(translations, json, {inplace: true});
}
return translations;
}
/* -------------------------------------------- */
/**
* Reduce the languages array provided by a package to an array of file paths of translations to load
* @param {object} pkg The package data
* @param {string} lang The target language to filter on
* @returns {string[]} An array of translation file paths
* @private
*/
_filterLanguagePaths(pkg, lang) {
return pkg.languages.reduce((arr, l) => {
if ( l.lang !== lang ) return arr;
let checkSystem = !l.system || (game.system && (l.system === game.system.id));
let checkModule = !l.module || game.modules.get(l.module)?.active;
if (checkSystem && checkModule) arr.push(l.path);
return arr;
}, []);
}
/* -------------------------------------------- */
/**
* Load a single translation file and return its contents as processed JSON
* @param {string} src The translation file path to load
* @returns {Promise<object>} The loaded translation dictionary
* @private
*/
async _loadTranslationFile(src) {
// Load the referenced translation file
let err;
const resp = await fetch(src).catch(e => {
err = e;
return {};
});
if ( resp.status !== 200 ) {
const msg = `Unable to load requested localization file ${src}`;
console.error(`${vtt} | ${msg}`);
if ( err ) Hooks.onError("Localization#_loadTranslationFile", err, {msg, src});
return {};
}
// Parse and expand the provided translation object
let json;
try {
json = await resp.json();
console.log(`${vtt} | Loaded localization file ${src}`);
json = foundry.utils.expandObject(json);
} catch(err) {
Hooks.onError("Localization#_loadTranslationFile", err, {
msg: `Unable to parse localization file ${src}`,
log: "error",
src
});
json = {};
}
return json;
}
/* -------------------------------------------- */
/* Localization API */
/* -------------------------------------------- */
/**
* Return whether a certain string has a known translation defined.
* @param {string} stringId The string key being translated
* @param {boolean} [fallback] Allow fallback translations to count?
* @returns {boolean}
*/
has(stringId, fallback=true) {
let v = foundry.utils.getProperty(this.translations, stringId);
if ( typeof v === "string" ) return true;
if ( !fallback ) return false;
v = foundry.utils.getProperty(this._fallback, stringId);
return typeof v === "string";
}
/* -------------------------------------------- */
/**
* Localize a string by drawing a translation from the available translations dictionary, if available
* If a translation is not available, the original string is returned
* @param {string} stringId The string ID to translate
* @returns {string} The translated string
*
* @example Localizing a simple string in JavaScript
* ```js
* {
* "MYMODULE.MYSTRING": "Hello, this is my module!"
* }
* game.i18n.localize("MYMODULE.MYSTRING"); // Hello, this is my module!
* ```
*
* @example Localizing a simple string in Handlebars
* ```hbs
* {{localize "MYMODULE.MYSTRING"}} <!-- Hello, this is my module! -->
* ```
*/
localize(stringId) {
let v = foundry.utils.getProperty(this.translations, stringId);
if ( typeof v === "string" ) return v;
v = foundry.utils.getProperty(this._fallback, stringId);
return typeof v === "string" ? v : stringId;
}
/* -------------------------------------------- */
/**
* Localize a string including variable formatting for input arguments.
* Provide a string ID which defines the localized template.
* Variables can be included in the template enclosed in braces and will be substituted using those named keys.
*
* @param {string} stringId The string ID to translate
* @param {object} data Provided input data
* @returns {string} The translated and formatted string
*
* @example Localizing a formatted string in JavaScript
* ```js
* {
* "MYMODULE.GREETING": "Hello {name}, this is my module!"
* }
* game.i18n.format("MYMODULE.GREETING" {name: "Andrew"}); // Hello Andrew, this is my module!
* ```
*
* @example Localizing a formatted string in Handlebars
* ```hbs
* {{localize "MYMODULE.GREETING" name="Andrew"}} <!-- Hello, this is my module! -->
* ```
*/
format(stringId, data={}) {
let str = this.localize(stringId);
const fmt = /{[^}]+}/g;
str = str.replace(fmt, k => {
return data[k.slice(1, -1)];
});
return str;
}
/* -------------------------------------------- */
/**
* Retrieve list formatter configured to the world's language setting.
* @see [Intl.ListFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat/ListFormat)
* @param {object} [options]
* @param {ListFormatStyle} [options.style=long] The list formatter style, either "long", "short", or "narrow".
* @param {ListFormatType} [options.type=conjunction] The list formatter type, either "conjunction", "disjunction",
* or "unit".
* @returns {Intl.ListFormat}
*/
getListFormatter({style="long", type="conjunction"}={}) {
const key = `${style}${type}`;
this.#formatters[key] ??= new Intl.ListFormat(this.lang, {style, type});
return this.#formatters[key];
}
/* -------------------------------------------- */
/**
* Sort an array of objects by a given key in a localization-aware manner.
* @param {object[]} objects The objects to sort, this array will be mutated.
* @param {string} key The key to sort the objects by. This can be provided in dot-notation.
* @returns {object[]}
*/
sortObjects(objects, key) {
const collator = new Intl.Collator(this.lang);
objects.sort((a, b) => {
return collator.compare(foundry.utils.getProperty(a, key), foundry.utils.getProperty(b, key));
});
return objects;
}
}

View File

@@ -0,0 +1,128 @@
/**
* @typedef {FormApplicationOptions} DrawingConfigOptions
* @property {boolean} [configureDefault=false] Configure the default drawing settings, instead of a specific Drawing
*/
/**
* The Application responsible for configuring a single Drawing document within a parent Scene.
* @extends {DocumentSheet}
*
* @param {Drawing} drawing The Drawing object being configured
* @param {DrawingConfigOptions} options Additional application rendering options
*/
class DrawingConfig extends DocumentSheet {
/**
* @override
* @returns {DrawingConfigOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "drawing-config",
template: "templates/scene/drawing-config.html",
width: 480,
height: "auto",
configureDefault: false,
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "position"}]
});
}
/* -------------------------------------------- */
/** @override */
get title() {
if ( this.options.configureDefault ) return game.i18n.localize("DRAWING.ConfigDefaultTitle");
return super.title;
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
// Submit text
let submit;
if ( this.options.configureDefault ) submit = "DRAWING.SubmitDefault";
else submit = this.document.id ? "DRAWING.SubmitUpdate" : "DRAWING.SubmitCreate";
// Rendering context
return {
author: this.document.author?.name || "",
isDefault: this.options.configureDefault,
fillTypes: Object.entries(CONST.DRAWING_FILL_TYPES).reduce((obj, v) => {
obj[v[1]] = `DRAWING.FillType${v[0].titleCase()}`;
return obj;
}, {}),
scaledBezierFactor: this.document.bezierFactor * 2,
fontFamilies: FontConfig.getAvailableFontChoices(),
object: this.document.toObject(),
options: this.options,
submitText: submit
};
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
if ( !this.object.isOwner ) throw new Error("You do not have the ability to configure this Drawing object.");
// Un-scale the bezier factor
formData.bezierFactor /= 2;
// Configure the default Drawing settings
if ( this.options.configureDefault ) {
formData = foundry.utils.expandObject(formData);
const defaults = DrawingDocument.cleanData(formData, {partial: true});
return game.settings.set("core", DrawingsLayer.DEFAULT_CONFIG_SETTING, defaults);
}
// Rescale dimensions if needed
const shape = this.object.shape;
const w = formData["shape.width"];
const h = formData["shape.height"];
if ( shape && ((w !== shape.width) || (h !== shape.height)) ) {
const dx = w - shape.width;
const dy = h - shape.height;
formData = foundry.utils.expandObject(formData);
foundry.utils.mergeObject(formData, Drawing.rescaleDimensions(this.object, dx, dy));
}
// Create or update a Drawing
if ( this.object.id ) return this.object.update(formData);
return this.object.constructor.create(formData);
}
/* -------------------------------------------- */
/** @override */
async close(options) {
await super.close(options);
if ( this.preview ) {
this.preview.removeChildren();
this.preview = null;
}
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find('button[name="reset"]').click(this._onResetDefaults.bind(this));
}
/* -------------------------------------------- */
/**
* Reset the user Drawing configuration settings to their default values
* @param {PointerEvent} event The originating mouse-click event
* @protected
*/
_onResetDefaults(event) {
event.preventDefault();
this.object = DrawingDocument.fromSource({});
this.render();
}
}

View File

@@ -0,0 +1,43 @@
/**
* An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Drawing objects.
* @extends {BasePlaceableHUD}
* @param {Drawing} object The {@link Drawing} this HUD is bound to.
* @param {ApplicationOptions} [options] Application configuration options.
*/
class DrawingHUD extends BasePlaceableHUD {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "drawing-hud",
template: "templates/hud/drawing-hud.html"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const d = this.object.document;
return foundry.utils.mergeObject(super.getData(options), {
lockedClass: d.locked ? "active" : "",
visibilityClass: d.hidden ? "active" : ""
});
}
/* -------------------------------------------- */
/** @inheritdoc */
setPosition(options) {
let {x, y, width, height} = this.object.hitArea;
const c = 70;
const p = 10;
const position = {
width: width + (c * 2) + (p * 2),
height: height + (p * 2),
left: x + this.object.x - c - p,
top: y + this.object.y - p
};
this.element.css(position);
}
}

View File

@@ -0,0 +1,173 @@
/**
* The Application responsible for configuring a single AmbientLight document within a parent Scene.
* @param {AmbientLight} light The AmbientLight object for which settings are being configured
* @param {DocumentSheetOptions} [options] Additional application configuration options
*/
class AmbientLightConfig extends DocumentSheet {
/**
* Maintain a copy of the original to show a real-time preview of changes.
* @type {AmbientLightDocument}
*/
preview;
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "ambient-light-config",
classes: ["sheet", "ambient-light-config"],
title: "LIGHT.ConfigTitle",
template: "templates/scene/ambient-light-config.html",
width: 480,
height: "auto",
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "basic"}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
const states = Application.RENDER_STATES;
if ( force && [states.CLOSED, states.NONE].includes(this._state) && this.document.object ) {
if ( !this.preview ) {
const clone = this.document.object.clone();
this.preview = clone.document;
}
await this.preview.object.draw();
this.document.object.renderable = false;
this.preview.object.layer.preview.addChild(this.preview.object);
this._previewChanges();
}
return super._render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
delete context.document; // Replaced below
return foundry.utils.mergeObject(context, {
data: this.preview.toObject(false),
document: this.preview,
isAdvanced: this._tabs[0].active === "advanced",
colorationTechniques: AdaptiveLightingShader.SHADER_TECHNIQUES,
lightAnimations: CONFIG.Canvas.lightAnimations,
gridUnits: canvas.scene.grid.units || game.i18n.localize("GridUnits"),
submitText: game.i18n.localize(this.options.preview ? "LIGHT.Create" : "LIGHT.Update")
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
const states = Application.RENDER_STATES;
if ( options.force || [states.RENDERED, states.ERROR].includes(this._state) ) {
this._resetPreview();
}
await super.close(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
html.find('button[type="reset"]').click(this._onResetForm.bind(this));
return super.activateListeners(html);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
await super._onChangeInput(event);
const previewData = this._getSubmitData();
this._previewChanges(previewData);
}
/* -------------------------------------------- */
/**
* Reset the values of advanced attributes to their default state.
* @param {PointerEvent} event The originating click event
* @private
*/
_onResetForm(event) {
event.preventDefault();
const defaults = AmbientLightDocument.cleanData();
const keys = ["walls", "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);
this.render();
}
/* -------------------------------------------- */
/**
* 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.object?.destroyed === false ) {
this.preview.object.renderFlags.set({refresh: true});
this.preview.object.updateSource();
}
}
/* -------------------------------------------- */
/**
* Restore the true data for the AmbientLight document when the form is submitted or closed.
* @protected
*/
_resetPreview() {
if ( !this.preview ) return;
if ( this.preview.object?.destroyed === false ) {
this.preview.object.destroy({children: true});
}
this.preview = null;
if ( this.document.object?.destroyed === false ) {
this.document.object.renderable = true;
this.document.object.renderFlags.set({refresh: true});
this.document.object.updateSource();
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_onChangeTab(event, tabs, active) {
super._onChangeTab(event, tabs, active);
this.element.find('button[type="reset"]').toggleClass("hidden", active !== "advanced");
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const formData = super._getSubmitData(updateData);
if ( formData["config.color"] === "" ) formData["config.color"] = null;
return formData;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
this._resetPreview();
if ( this.object.id ) return this.object.update(formData);
return this.object.constructor.create(formData, {parent: canvas.scene});
}
}

View File

@@ -0,0 +1,123 @@
/**
* The Application responsible for configuring a single Note document within a parent Scene.
* @param {NoteDocument} note The Note object for which settings are being configured
* @param {DocumentSheetOptions} [options] Additional Application configuration options
*/
class NoteConfig extends DocumentSheet {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("NOTE.ConfigTitle"),
template: "templates/scene/note-config.html",
width: 480
});
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const data = super.getData(options);
if ( !this.object.id ) data.data.global = !canvas.scene.tokenVision;
const entry = game.journal.get(this.object.entryId);
const pages = entry?.pages.contents.sort((a, b) => a.sort - b.sort);
const icons = Object.entries(CONFIG.JournalEntry.noteIcons).map(([label, src]) => {
return {label, src};
}).sort((a, b) => a.label.localeCompare(b.label));
icons.unshift({label: game.i18n.localize("NOTE.Custom"), src: ""});
const customIcon = !Object.values(CONFIG.JournalEntry.noteIcons).includes(this.document.texture.src);
const icon = {
selected: customIcon ? "" : this.document.texture.src,
custom: customIcon ? this.document.texture.src : ""
};
return foundry.utils.mergeObject(data, {
icon, icons,
label: this.object.label,
entry: entry || {},
pages: pages || [],
entries: game.journal.filter(e => e.isOwner).sort((a, b) => a.name.localeCompare(b.name)),
fontFamilies: FontConfig.getAvailableFontChoices(),
textAnchors: Object.entries(CONST.TEXT_ANCHOR_POINTS).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`JOURNAL.Anchor${e[0].titleCase()}`);
return obj;
}, {}),
submitText: game.i18n.localize(this.id ? "NOTE.Update" : "NOTE.Create")
});
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
this._updateCustomIcon();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
this._updateCustomIcon();
if ( event.currentTarget.name === "entryId" ) this._updatePageList();
return super._onChangeInput(event);
}
/* -------------------------------------------- */
/**
* Update disabled state of the custom icon field.
* @protected
*/
_updateCustomIcon() {
const selected = this.form?.querySelector('[name="icon.selected"]');
const custom = this.form?.querySelector('[name="icon.custom"]');
if ( custom ) {
custom.disabled = selected.value.length;
this.form.querySelector('[data-target="icon.custom"]').disabled = selected.value.length;
}
}
/* -------------------------------------------- */
/**
* Update the list of pages.
* @protected
*/
_updatePageList() {
const entryId = this.form.elements.entryId?.value;
const pages = game.journal.get(entryId)?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
const options = pages.map(page => {
const selected = (entryId === this.object.entryId) && (page.id === this.object.pageId);
return `<option value="${page.id}"${selected ? " selected" : ""}>${page.name}</option>`;
});
this.form.elements.pageId.innerHTML = `<option></option>${options}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const data = super._getSubmitData(updateData);
data["texture.src"] = data["icon.selected"] || data["icon.custom"];
delete data["icon.selected"];
delete data["icon.custom"];
return data;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
if ( this.object.id ) return this.object.update(formData);
else return this.object.constructor.create(formData, {parent: canvas.scene});
}
/* -------------------------------------------- */
/** @override */
async close(options) {
if ( !this.object.id ) canvas.notes.clearPreviewContainer();
return super.close(options);
}
}

View File

@@ -0,0 +1,52 @@
/**
* The Application responsible for configuring a single AmbientSound document within a parent Scene.
* @extends {DocumentSheet}
*
* @param {AmbientSound} sound The sound object being configured
* @param {DocumentSheetOptions} [options] Additional application rendering options
*/
class AmbientSoundConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: "SOUND.ConfigTitle",
template: "templates/scene/sound-config.html",
width: 480
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
if ( this.object.id ) return super.title;
else return game.i18n.localize("SOUND.Create");
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const data = super.getData(options);
data.submitText = game.i18n.localize(this.object.id ? "SOUND.Update" : "SOUND.Create");
data.gridUnits = canvas.scene.grid.units;
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
if ( this.object.id ) return this.object.update(formData);
return this.object.constructor.create(formData, {parent: canvas.scene});
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options) {
if ( !this.object.id ) canvas.sounds.clearPreviewContainer();
await super.close(options);
}
}

View File

@@ -0,0 +1,89 @@
/**
* The Application responsible for configuring a single Tile document within a parent Scene.
* @param {Tile} tile The Tile object being configured
* @param {DocumentSheetOptions} [options] Additional application rendering options
*/
class TileConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "tile-config",
title: game.i18n.localize("TILE.ConfigTitle"),
template: "templates/scene/tile-config.html",
width: 420,
height: "auto",
submitOnChange: true,
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "basic"}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
// If the config was closed without saving, reset the initial display of the Tile
if ( !options.force ) {
this.document.reset();
if ( this.document.object?.destroyed === false ) {
this.document.object.refresh();
}
}
// Remove the preview tile and close
const layer = this.object.layer;
layer.clearPreviewContainer();
return super.close(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const data = super.getData(options);
data.submitText = game.i18n.localize(this.object.id ? "TILE.SubmitUpdate" : "TILE.SubmitCreate");
data.occlusionModes = Object.entries(CONST.OCCLUSION_MODES).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`TILE.OcclusionMode${e[0].titleCase()}`);
return obj;
}, {});
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
// Handle form element updates
const el = event.target;
if ( (el.type === "color") && el.dataset.edit ) this._onChangeColorPicker(event);
else if ( el.type === "range" ) this._onChangeRange(event);
// Update preview object
const fdo = new FormDataExtended(this.form).object;
// To allow a preview without glitches
fdo.width = Math.abs(fdo.width);
fdo.height = Math.abs(fdo.height);
// Handle tint exception
let tint = fdo["texture.tint"];
if ( !foundry.data.validators.isColorString(tint) ) fdo["texture.tint"] = null;
// Update preview object
foundry.utils.mergeObject(this.document, foundry.utils.expandObject(fdo));
this.document.object.refresh();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
if ( this.document.id ) return this.document.update(formData);
else return this.document.constructor.create(formData, {
parent: this.document.parent,
pack: this.document.pack
});
}
}

View File

@@ -0,0 +1,119 @@
/**
* An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Tile objects.
* @extends {BasePlaceableHUD}
*/
class TileHUD extends BasePlaceableHUD {
/**
* @inheritdoc
* @type {Tile}
*/
object = undefined;
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "tile-hud",
template: "templates/hud/tile-hud.html"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const d = this.object.document;
const isVideo = this.object.isVideo;
const src = this.object.sourceElement;
const isPlaying = isVideo && !src.paused && !src.ended;
return foundry.utils.mergeObject(super.getData(options), {
isVideo: isVideo,
lockedClass: d.locked ? "active" : "",
visibilityClass: d.hidden ? "active" : "",
overheadClass: d.overhead ? "active" : "",
underfootClass: !d.overhead ? "active" : "",
videoIcon: isPlaying ? "fas fa-pause" : "fas fa-play",
videoTitle: game.i18n.localize(isPlaying ? "HUD.TilePause" : "HUD.TilePlay")
});
}
/* -------------------------------------------- */
/** @inheritdoc */
setPosition(options) {
let {x, y, width, height} = this.object.hitArea;
const c = 70;
const p = -10;
const position = {
width: width + (c * 2) + (p * 2),
height: height + (p * 2),
left: x + this.object.x - c - p,
top: y + this.object.y - p
};
this.element.css(position);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickControl(event) {
super._onClickControl(event);
if ( event.defaultPrevented ) return;
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "overhead":
return this._onToggleOverhead(event, true);
case "underfoot":
return this._onToggleOverhead(event, false);
case "video":
return this._onControlVideo(event);
}
}
/* -------------------------------------------- */
/**
* Handle toggling the overhead state of the Tile.
* @param {PointerEvent} event The triggering click event
* @param {boolean} overhead Should the Tile be overhead?
* @private
*/
async _onToggleOverhead(event, overhead) {
await canvas.scene.updateEmbeddedDocuments("Tile", this.layer.controlled.map(o => {
return {_id: o.id, overhead: overhead};
}));
return this.render();
}
/* -------------------------------------------- */
/**
* Control video playback by toggling play or paused state for a video Tile.
* @param {object} event
* @private
*/
_onControlVideo(event) {
const src = this.object.sourceElement;
const icon = event.currentTarget.children[0];
const isPlaying = !src.paused && !src.ended;
// Intercepting state change if the source is not looping and not playing
if ( !src.loop && !isPlaying ) {
const self = this;
src.onpause = () => {
if ( self.object?.sourceElement ) {
icon.classList.replace("fa-pause", "fa-play");
self.render();
}
src.onpause = null;
};
}
return this.object.document.update({"video.autoplay": false}, {
diff: false,
playVideo: !isPlaying,
offset: src.ended ? 0 : null
});
}
}

View File

@@ -0,0 +1,605 @@
/**
* The Application responsible for configuring a single Token document within a parent Scene.
* @param {TokenDocument|Actor} object The {@link TokenDocument} being configured or an {@link Actor} for whom
* to configure the {@link PrototypeToken}
* @param {FormApplicationOptions} [options] Application configuration options.
*/
class TokenConfig extends DocumentSheet {
constructor(object, options) {
super(object, options);
/**
* The placed Token object in the Scene
* @type {TokenDocument}
*/
this.token = this.object;
/**
* A reference to the Actor which the token depicts
* @type {Actor}
*/
this.actor = this.object.actor;
// Configure options
if ( this.isPrototype ) this.options.sheetConfig = false;
}
/**
* Maintain a copy of the original to show a real-time preview of changes.
* @type {TokenDocument|PrototypeToken}
*/
preview;
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["sheet", "token-sheet"],
template: "templates/scene/token-config.html",
width: 480,
height: "auto",
tabs: [
{navSelector: '.tabs[data-group="main"]', contentSelector: "form", initial: "character"},
{navSelector: '.tabs[data-group="light"]', contentSelector: '.tab[data-tab="light"]', initial: "basic"},
{navSelector: '.tabs[data-group="vision"]', contentSelector: '.tab[data-tab="vision"]', initial: "basic"}
],
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER,
sheetConfig: true
});
}
/* -------------------------------------------- */
/**
* A convenience accessor to test whether we are configuring the prototype Token for an Actor.
* @type {boolean}
*/
get isPrototype() {
return this.object instanceof foundry.data.PrototypeToken;
}
/* -------------------------------------------- */
/** @inheritDoc */
get id() {
if ( this.isPrototype ) return `${this.constructor.name}-${this.actor.uuid}`;
else return super.id;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
if ( this.isPrototype ) return `${game.i18n.localize("TOKEN.TitlePrototype")}: ${this.actor.name}`;
return `${game.i18n.localize("TOKEN.Title")}: ${this.token.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
render(force=false, options={}) {
if ( this.isPrototype ) this.object.actor.apps[this.appId] = this;
return super.render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options={}) {
await this._handleTokenPreview(force, options);
return super._render(force, options);
}
/* -------------------------------------------- */
/**
* Handle preview with a token.
* @param {boolean} force
* @param {object} options
* @returns {Promise<void>}
* @protected
*/
async _handleTokenPreview(force, options={}) {
const states = Application.RENDER_STATES;
if ( force && [states.CLOSED, states.NONE].includes(this._state) ) {
if ( this.isPrototype ) {
this.preview = this.object.clone();
return;
}
if ( !this.document.object ) {
this.preview = null;
return;
}
if ( !this.preview ) {
const clone = this.document.object.clone();
Object.defineProperty(clone, "sourceId", {
get() { return `${this.document.documentName}.${this.document.id}`; },
configurable: true,
enumerable: false
});
this.preview = clone.document;
clone.control({releaseOthers: true});
}
await this.preview.object.draw();
this.document.object.renderable = false;
this.preview.object.layer.preview.addChild(this.preview.object);
this._previewChanges();
}
}
/* -------------------------------------------- */
/** @inheritDoc */
_canUserView(user) {
const canView = super._canUserView(user);
return canView && game.user.can("TOKEN_CONFIGURE");
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const alternateImages = await this._getAlternateTokenImages();
const attributeSource = this.actor?.system instanceof foundry.abstract.DataModel
? this.actor?.type
: this.actor?.system;
const attributes = TokenDocument.implementation.getTrackedAttributes(attributeSource);
const canBrowseFiles = game.user.hasPermission("FILES_BROWSE");
const gridUnits = (this.isPrototype || !canvas.ready) ? game.system.gridUnits : canvas.scene.grid.units;
// Prepare Token data
const doc = this.preview ?? this.document;
const token = doc.toObject();
const basicDetection = token.detectionModes.find(m => m.id === DetectionMode.BASIC_MODE_ID) ? null
: doc.detectionModes.find(m => m.id === DetectionMode.BASIC_MODE_ID);
// Return rendering context
return {
cssClasses: [this.isPrototype ? "prototype" : null].filter(c => !!c).join(" "),
isPrototype: this.isPrototype,
hasAlternates: !foundry.utils.isEmpty(alternateImages),
alternateImages: alternateImages,
object: token,
options: this.options,
gridUnits: gridUnits || game.i18n.localize("GridUnits"),
barAttributes: TokenDocument.implementation.getTrackedAttributeChoices(attributes),
bar1: doc.getBarAttribute?.("bar1"),
bar2: doc.getBarAttribute?.("bar2"),
colorationTechniques: AdaptiveLightingShader.SHADER_TECHNIQUES,
visionModes: Object.values(CONFIG.Canvas.visionModes).filter(f => f.tokenConfig),
detectionModes: Object.values(CONFIG.Canvas.detectionModes).filter(f => f.tokenConfig),
basicDetection,
displayModes: Object.entries(CONST.TOKEN_DISPLAY_MODES).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`TOKEN.DISPLAY_${e[0]}`);
return obj;
}, {}),
actors: game.actors.reduce((actors, a) => {
if ( !a.isOwner ) return actors;
actors.push({_id: a.id, name: a.name});
return actors;
}, []).sort((a, b) => a.name.localeCompare(b.name)),
dispositions: Object.entries(CONST.TOKEN_DISPOSITIONS).reduce((obj, e) => {
obj[e[1]] = game.i18n.localize(`TOKEN.DISPOSITION.${e[0]}`);
return obj;
}, {}),
lightAnimations: Object.entries(CONFIG.Canvas.lightAnimations).reduce((obj, e) => {
obj[e[0]] = game.i18n.localize(e[1].label);
return obj;
}, {"": game.i18n.localize("None")}),
isGM: game.user.isGM,
randomImgEnabled: this.isPrototype && (canBrowseFiles || doc.randomImg),
scale: Math.abs(doc.texture.scaleX),
mirrorX: doc.texture.scaleX < 0,
mirrorY: doc.texture.scaleY < 0
};
}
/* --------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
await loadTemplates([
"templates/scene/parts/token-lighting.html",
"templates/scene/parts/token-vision.html",
"templates/scene/parts/token-resources.html"
]);
return super._renderInner(...args);
}
/* -------------------------------------------- */
/**
* Get an Object of image paths and filenames to display in the Token sheet
* @returns {Promise<object>}
* @private
*/
async _getAlternateTokenImages() {
if ( !this.actor?.prototypeToken.randomImg ) return {};
const alternates = await this.actor.getTokenImages();
return alternates.reduce((obj, img) => {
obj[img] = img.split("/").pop();
return obj;
}, {});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".action-button").click(this._onClickActionButton.bind(this));
html.find(".bar-attribute").change(this._onBarChange.bind(this));
html.find(".alternate-images").change(ev => ev.target.form["texture.src"].value = ev.target.value);
html.find("button.assign-token").click(this._onAssignToken.bind(this));
this._disableEditImage();
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
const states = Application.RENDER_STATES;
if ( options.force || [states.RENDERED, states.ERROR].includes(this._state) ) {
this._resetPreview();
}
await super.close(options);
if ( this.isPrototype ) delete this.object.actor.apps?.[this.appId];
}
/* -------------------------------------------- */
/** @inheritDoc */
_getSubmitData(updateData={}) {
const formData = super._getSubmitData(updateData);
// Mirror token scale
if ( "scale" in formData ) {
formData["texture.scaleX"] = formData.scale * (formData.mirrorX ? -1 : 1);
formData["texture.scaleY"] = formData.scale * (formData.mirrorY ? -1 : 1);
}
["scale", "mirrorX", "mirrorY"].forEach(k => delete formData[k]);
// Clear detection modes array
if ( !("detectionModes.0.id" in formData) ) formData.detectionModes = [];
// Treat "None" as null for bar attributes
formData["bar1.attribute"] ||= null;
formData["bar2.attribute"] ||= null;
return formData;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _onChangeInput(event) {
await super._onChangeInput(event);
// Disable image editing for wildcards
this._disableEditImage();
// Pre-populate vision mode defaults
const element = event.target;
if ( element.name === "sight.visionMode" ) {
const visionDefaults = CONFIG.Canvas.visionModes[element.value]?.vision?.defaults || {};
const update = fieldName => {
const field = this.form.querySelector(`[name="sight.${fieldName}"]`);
if ( fieldName in visionDefaults ) {
field.valueAsNumber = visionDefaults[fieldName];
field.nextElementSibling.innerText = visionDefaults[fieldName];
}
};
for ( const fieldName of ["attenuation", "brightness", "saturation", "contrast"] ) update(fieldName);
}
// Preview token changes
const previewData = this._getSubmitData();
this._previewChanges(previewData);
}
/* -------------------------------------------- */
/**
* Mimic changes to the Token document as if they were true document updates.
* @param {object} [change] The change to preview.
* @protected
*/
_previewChanges(change) {
if ( !this.preview ) return;
if ( change ) {
change = {...change};
delete change.actorId;
delete change.actorLink;
this.preview.updateSource(change);
}
if ( !this.isPrototype && (this.preview.object?.destroyed === false) ) {
this.preview.object.renderFlags.set({refresh: true});
this.preview.object.updateSource();
canvas.perception.update({initializeVision: true});
}
}
/* -------------------------------------------- */
/**
* Reset the temporary preview of the Token when the form is submitted or closed.
* @protected
*/
_resetPreview() {
if ( !this.preview ) return;
if ( this.isPrototype ) return this.preview = null;
if ( this.preview.object?.destroyed === false ) {
this.preview.object.destroy({children: true});
}
this.preview.baseActor?._unregisterDependentToken(this.preview);
this.preview = null;
if ( this.document.object?.destroyed === false ) {
this.document.object.renderable = true;
this.document.object.renderFlags.set({refresh: true});
this.document.object.control();
this.document.object.updateSource();
}
canvas.perception.update({initializeVision: true});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
this._resetPreview();
return this.token.update(formData);
}
/* -------------------------------------------- */
/**
* Handle Token assignment requests to update the default prototype Token
* @param {MouseEvent} event The left-click event on the assign token button
* @private
*/
async _onAssignToken(event) {
event.preventDefault();
// Get controlled Token data
let tokens = canvas.ready ? canvas.tokens.controlled : [];
if ( tokens.length !== 1 ) {
ui.notifications.warn("TOKEN.AssignWarn", {localize: true});
return;
}
const token = tokens.pop().document.toObject();
token.tokenId = token.x = token.y = null;
token.randomImg = this.form.elements.randomImg.checked;
if ( token.randomImg ) delete token.texture.src;
// Update the prototype token for the actor using the existing Token instance
await this.actor.update({prototypeToken: token}, {diff: false, recursive: false, noHook: true});
ui.notifications.info(game.i18n.format("TOKEN.AssignSuccess", {name: this.actor.name}));
}
/* -------------------------------------------- */
/**
* Handle changing the attribute bar in the drop-down selector to update the default current and max value
* @param {Event} event The select input change event
* @private
*/
async _onBarChange(event) {
const form = event.target.form;
const doc = this.preview ?? this.document;
const attr = doc.getBarAttribute("", {alternative: event.target.value});
const bar = event.target.name.split(".").shift();
form.querySelector(`input.${bar}-value`).value = attr !== null ? attr.value : "";
form.querySelector(`input.${bar}-max`).value = ((attr !== null) && (attr.type === "bar")) ? attr.max : "";
}
/* -------------------------------------------- */
/**
* Handle click events on a token configuration sheet action button
* @param {PointerEvent} event The originating click event
* @protected
*/
_onClickActionButton(event) {
event.preventDefault();
const button = event.currentTarget;
const action = button.dataset.action;
game.tooltip.deactivate();
// Get pending changes to modes
const modes = Object.values(foundry.utils.expandObject(this._getSubmitData())?.detectionModes || {});
// Manipulate the array
switch ( action ) {
case "addDetectionMode":
this._onAddDetectionMode(modes);
break;
case "removeDetectionMode":
const idx = button.closest(".detection-mode").dataset.index;
this._onRemoveDetectionMode(Number(idx), modes);
break;
}
this._previewChanges({detectionModes: modes});
this.render();
}
/* -------------------------------------------- */
/**
* Handle adding a detection mode.
* @param {object[]} modes The existing detection modes.
* @protected
*/
_onAddDetectionMode(modes) {
modes.push({id: "", range: 0, enabled: true});
}
/* -------------------------------------------- */
/**
* Handle removing a detection mode.
* @param {number} index The index of the detection mode to remove.
* @param {object[]} modes The existing detection modes.
* @protected
*/
_onRemoveDetectionMode(index, modes) {
modes.splice(index, 1);
}
/* -------------------------------------------- */
/**
* Disable the user's ability to edit the token image field if wildcard images are enabled and that user does not have
* file browser permissions.
* @private
*/
_disableEditImage() {
const img = this.form.querySelector('[name="texture.src"]');
const randomImg = this.form.querySelector('[name="randomImg"]');
if ( randomImg ) img.disabled = !game.user.hasPermission("FILES_BROWSE") && randomImg.checked;
}
}
/**
* A sheet that alters the values of the default Token configuration used when new Token documents are created.
* @extends {TokenConfig}
*/
class DefaultTokenConfig extends TokenConfig {
constructor(object, options) {
const setting = game.settings.get("core", DefaultTokenConfig.SETTING);
const cls = getDocumentClass("Token");
object = new cls({name: "Default Token", ...setting}, {actor: null, strict: false});
super(object, options);
}
/**
* The named world setting that stores the default Token configuration
* @type {string}
*/
static SETTING = "defaultToken";
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/scene/default-token-config.html",
sheetConfig: false
});
}
/* --------------------------------------------- */
/** @inheritdoc */
get id() {
return "default-token-config";
}
/* --------------------------------------------- */
/** @inheritdoc */
get title() {
return game.i18n.localize("SETTINGS.DefaultTokenN");
}
/* -------------------------------------------- */
/** @override */
get isEditable() {
return game.user.can("SETTINGS_MODIFY");
}
/* -------------------------------------------- */
/** @override */
_canUserView(user) {
return user.can("SETTINGS_MODIFY");
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const context = await super.getData(options);
return Object.assign(context, {
object: this.token.toObject(false),
isDefault: true,
barAttributes: TokenDocument.implementation.getTrackedAttributeChoices(),
bar1: this.token.bar1,
bar2: this.token.bar2
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData = {}) {
const formData = foundry.utils.expandObject(super._getSubmitData(updateData));
formData.light.color = formData.light.color || undefined;
formData.bar1.attribute = formData.bar1.attribute || null;
formData.bar2.attribute = formData.bar2.attribute || null;
return formData;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
// Validate the default data
try {
this.object.updateSource(formData);
formData = foundry.utils.filterObject(this.token.toObject(), formData);
} catch(err) {
Hooks.onError("DefaultTokenConfig#_updateObject", err, {notify: "error"});
}
// Diff the form data against normal defaults
const defaults = foundry.documents.BaseToken.cleanData();
const delta = foundry.utils.diffObject(defaults, formData);
return game.settings.set("core", DefaultTokenConfig.SETTING, delta);
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find('button[data-action="reset"]').click(this.reset.bind(this));
}
/* -------------------------------------------- */
/**
* Reset the form to default values
* @returns {Promise<void>}
*/
async reset() {
const cls = getDocumentClass("Token");
this.object = new cls({}, {actor: null, strict: false});
this.token = this.object;
this.render();
}
/* --------------------------------------------- */
/** @inheritdoc */
async _onBarChange() {}
/* -------------------------------------------- */
/** @inheritdoc */
_onAddDetectionMode(modes) {
super._onAddDetectionMode(modes);
this.document.updateSource({ detectionModes: modes });
}
/* -------------------------------------------- */
/** @inheritdoc */
_onRemoveDetectionMode(index, modes) {
super._onRemoveDetectionMode(index, modes);
this.document.updateSource({ detectionModes: modes });
}
}

View File

@@ -0,0 +1,323 @@
/**
* An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Token objects.
* This interface provides controls for visibility, attribute bars, elevation, status effects, and more.
* @type {BasePlaceableHUD}
*/
class TokenHUD extends BasePlaceableHUD {
/**
* Track whether the status effects control palette is currently expanded or hidden
* @type {boolean}
* @private
*/
_statusEffects = false;
/**
* Track whether a control icon is hovered or not
* @type {boolean}
*/
#hoverControlIcon = false;
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "token-hud",
template: "templates/hud/token-hud.html"
});
}
/* -------------------------------------------- */
/** @override */
bind(object) {
this._statusEffects = false;
return super.bind(object);
}
/* -------------------------------------------- */
/**
* Refresh the currently active state of all status effect icons in the Token HUD selector.
*/
refreshStatusIcons() {
const effects = this.element.find(".status-effects")[0];
const statuses = this._getStatusEffectChoices();
for ( let img of effects.children ) {
const status = statuses[img.getAttribute("src")] || {};
img.classList.toggle("overlay", !!status.isOverlay);
img.classList.toggle("active", !!status.isActive);
}
}
/* -------------------------------------------- */
/** @override */
setPosition(_position) {
const td = this.object.document;
const ratio = canvas.dimensions.size / 100;
const position = {
width: td.width * 100,
height: td.height * 100,
left: this.object.x,
top: this.object.y
};
if ( ratio !== 1 ) position.transform = `scale(${ratio})`;
this.element.css(position);
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
let data = super.getData(options);
const bar1 = this.object.document.getBarAttribute("bar1");
const bar2 = this.object.document.getBarAttribute("bar2");
data = foundry.utils.mergeObject(data, {
canConfigure: game.user.can("TOKEN_CONFIGURE"),
canToggleCombat: ui.combat !== null,
displayBar1: bar1 && (bar1.type !== "none"),
bar1Data: bar1,
displayBar2: bar2 && (bar2.type !== "none"),
bar2Data: bar2,
visibilityClass: data.hidden ? "active" : "",
effectsClass: this._statusEffects ? "active" : "",
combatClass: this.object.inCombat ? "active" : "",
targetClass: this.object.targeted.has(game.user) ? "active" : ""
});
data.statusEffects = this._getStatusEffectChoices(data);
return data;
}
/* -------------------------------------------- */
/**
* Get an array of icon paths which represent valid status effect choices
* @private
*/
_getStatusEffectChoices() {
const token = this.object;
const doc = token.document;
// Get statuses which are active for the token actor
const actor = token.actor || null;
const statuses = actor ? actor.effects.reduce((obj, effect) => {
for ( const id of effect.statuses ) {
obj[id] = {id, overlay: !!effect.getFlag("core", "overlay")};
}
return obj;
}, {}) : {};
// Prepare the list of effects from the configured defaults and any additional effects present on the Token
const tokenEffects = foundry.utils.deepClone(doc.effects) || [];
if ( doc.overlayEffect ) tokenEffects.push(doc.overlayEffect);
return CONFIG.statusEffects.concat(tokenEffects).reduce((obj, e) => {
const src = e.icon ?? e;
if ( src in obj ) return obj;
const status = statuses[e.id] || {};
const isActive = !!status.id || doc.effects.includes(src);
const isOverlay = !!status.overlay || doc.overlayEffect === src;
/** @deprecated since v11 */
const label = e.name ?? e.label;
obj[src] = {
id: e.id ?? "",
title: label ? game.i18n.localize(label) : null,
src,
isActive,
isOverlay,
cssClass: [
isActive ? "active" : null,
isOverlay ? "overlay" : null
].filterJoin(" ")
};
return obj;
}, {});
}
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
// Attribute Bars
html.find(".attribute input")
.click(this._onAttributeClick)
.keydown(this._onAttributeKeydown.bind(this))
.focusout(this._onAttributeUpdate.bind(this));
// Control icons hover detection
html.find(".control-icon")
.mouseleave(() => this.#hoverControlIcon = false)
.mouseenter(() => this.#hoverControlIcon = true);
// Status Effects Controls
this._toggleStatusEffects(this._statusEffects);
html.find(".status-effects")
.on("click", ".effect-control", this._onToggleEffect.bind(this))
.on("contextmenu", ".effect-control", event => this._onToggleEffect(event, {overlay: true}));
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickControl(event) {
super._onClickControl(event);
if ( event.defaultPrevented ) return;
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "config":
return this._onTokenConfig(event);
case "combat":
return this._onToggleCombat(event);
case "target":
return this._onToggleTarget(event);
case "effects":
return this._onToggleStatusEffects(event);
}
}
/* -------------------------------------------- */
/**
* Handle initial click to focus an attribute update field
* @private
*/
_onAttributeClick(event) {
event.currentTarget.select();
}
/* -------------------------------------------- */
/**
* Force field handling on an Enter keypress even if the value of the field did not change.
* This is important to suppose use cases with negative number values.
* @param {KeyboardEvent} event The originating keydown event
* @private
*/
_onAttributeKeydown(event) {
if ( (event.code === "Enter") || (event.code === "NumpadEnter") ) event.currentTarget.blur();
}
/* -------------------------------------------- */
/**
* Handle attribute bar update
* @private
*/
_onAttributeUpdate(event) {
event.preventDefault();
if ( !this.object ) return;
// Acquire string input
const input = event.currentTarget;
let strVal = input.value.trim();
let isDelta = strVal.startsWith("+") || strVal.startsWith("-");
if (strVal.startsWith("=")) strVal = strVal.slice(1);
let value = Number(strVal);
// For attribute bar values, update the associated Actor
const bar = input.dataset.bar;
const actor = this.object?.actor;
if ( bar && actor ) {
const attr = this.object.document.getBarAttribute(bar);
if ( isDelta || (attr.attribute !== value) ) {
actor.modifyTokenAttribute(attr.attribute, value, isDelta, attr.type === "bar");
}
}
// Otherwise update the Token directly
else {
const current = foundry.utils.getProperty(this.object.document, input.name);
this.object.document.update({[input.name]: isDelta ? current + value : value});
}
// Clear the HUD
if ( !this.#hoverControlIcon ) this.clear();
}
/* -------------------------------------------- */
/**
* Toggle Token combat state
* @private
*/
async _onToggleCombat(event) {
event.preventDefault();
return this.object.toggleCombat();
}
/* -------------------------------------------- */
/**
* Handle Token configuration button click
* @private
*/
_onTokenConfig(event) {
event.preventDefault();
this.object.sheet.render(true);
}
/* -------------------------------------------- */
/**
* Handle left-click events to toggle the displayed state of the status effect selection palette
* @param {MouseEvent }event
* @private
*/
_onToggleStatusEffects(event) {
event.preventDefault();
this._toggleStatusEffects(!this._statusEffects);
}
/* -------------------------------------------- */
/**
* Assign css selectors for the active state of the status effects selection palette
* @param {boolean} active Should the effects menu be active?
* @private
*/
_toggleStatusEffects(active) {
this._statusEffects = active;
const button = this.element.find('.control-icon[data-action="effects"]')[0];
button.classList.toggle("active", active);
const palette = button.querySelector(".status-effects");
palette.classList.toggle("active", active);
}
/* -------------------------------------------- */
/**
* Handle toggling a token status effect icon
* @param {PointerEvent} event The click event to toggle the effect
* @param {object} [options] Options which modify the toggle
* @param {boolean} [options.overlay] Toggle the overlay effect?
* @private
*/
_onToggleEffect(event, {overlay=false}={}) {
event.preventDefault();
event.stopPropagation();
let img = event.currentTarget;
const effect = ( img.dataset.statusId && this.object.actor ) ?
CONFIG.statusEffects.find(e => e.id === img.dataset.statusId) :
img.getAttribute("src");
return this.object.toggleEffect(effect, {overlay});
}
/* -------------------------------------------- */
/**
* Handle toggling the target state for this Token
* @param {PointerEvent} event The click event to toggle the target
* @private
*/
_onToggleTarget(event) {
event.preventDefault();
const btn = event.currentTarget;
const token = this.object;
const targeted = !token.isTargeted;
token.setTarget(targeted, {releaseOthers: false});
btn.classList.toggle("active", targeted);
}
}

View File

@@ -0,0 +1,190 @@
/**
* The Application responsible for configuring a single Wall document within a parent Scene.
* @param {Wall} object The Wall object for which settings are being configured
* @param {FormApplicationOptions} [options] Additional options which configure the rendering of the configuration
* sheet.
*/
class WallConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.classes.push("wall-config");
options.template = "templates/scene/wall-config.html";
options.width = 400;
options.height = "auto";
return options;
}
/**
* An array of Wall ids that should all be edited when changes to this config form are submitted
* @type {string[]}
*/
editTargets = [];
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
if ( this.editTargets.length > 1 ) return game.i18n.localize("WALLS.TitleMany");
return super.title;
}
/* -------------------------------------------- */
/** @inheritdoc */
render(force, options) {
if ( options?.walls instanceof Array ) {
this.editTargets = options.walls.map(w => w.id);
}
return super.render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
context.source = this.document.toObject();
context.p0 = {x: this.object.c[0], y: this.object.c[1]};
context.p1 = {x: this.object.c[2], y: this.object.c[3]};
context.gridUnits = canvas.scene.grid.units || game.i18n.localize("GridUnits");
context.moveTypes = Object.keys(CONST.WALL_MOVEMENT_TYPES).reduce((obj, key) => {
let k = CONST.WALL_MOVEMENT_TYPES[key];
obj[k] = game.i18n.localize(`WALLS.SenseTypes.${key}`);
return obj;
}, {});
context.senseTypes = Object.keys(CONST.WALL_SENSE_TYPES).reduce((obj, key) => {
let k = CONST.WALL_SENSE_TYPES[key];
obj[k] = game.i18n.localize(`WALLS.SenseTypes.${key}`);
return obj;
}, {});
context.dirTypes = Object.keys(CONST.WALL_DIRECTIONS).reduce((obj, key) => {
let k = CONST.WALL_DIRECTIONS[key];
obj[k] = game.i18n.localize(`WALLS.Directions.${key}`);
return obj;
}, {});
context.doorTypes = Object.keys(CONST.WALL_DOOR_TYPES).reduce((obj, key) => {
let k = CONST.WALL_DOOR_TYPES[key];
obj[k] = game.i18n.localize(`WALLS.DoorTypes.${key}`);
return obj;
}, {});
context.doorStates = Object.keys(CONST.WALL_DOOR_STATES).reduce((obj, key) => {
let k = CONST.WALL_DOOR_STATES[key];
obj[k] = game.i18n.localize(`WALLS.DoorStates.${key}`);
return obj;
}, {});
context.doorSounds = CONFIG.Wall.doorSounds;
context.isDoor = this.object.isDoor;
return context;
}
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
html.find(".audio-preview").click(this.#onAudioPreview.bind(this));
this.#enableDoorOptions(this.document.door > CONST.WALL_DOOR_TYPES.NONE);
this.#toggleThresholdInputVisibility();
return super.activateListeners(html);
}
/* -------------------------------------------- */
#audioPreviewState = 0;
/**
* Handle previewing a sound file for a Wall setting
* @param {Event} event The initial button click event
*/
#onAudioPreview(event) {
const doorSoundName = this.form.doorSound.value;
const doorSound = CONFIG.Wall.doorSounds[doorSoundName];
if ( !doorSound ) return;
const interactions = CONST.WALL_DOOR_INTERACTIONS;
const interaction = interactions[this.#audioPreviewState++ % interactions.length];
let sounds = doorSound[interaction];
if ( !sounds ) return;
if ( !Array.isArray(sounds) ) sounds = [sounds];
const src = sounds[Math.floor(Math.random() * sounds.length)];
const volume = game.settings.get("core", "globalInterfaceVolume");
game.audio.play(src, {volume});
}
/* -------------------------------------------- */
/** @inheritDoc */
async _onChangeInput(event) {
if ( event.currentTarget.name === "door" ) {
this.#enableDoorOptions(Number(event.currentTarget.value) > CONST.WALL_DOOR_TYPES.NONE);
}
else if ( event.currentTarget.name === "doorSound" ) {
this.#audioPreviewState = 0;
}
else if ( ["light", "sight", "sound"].includes(event.currentTarget.name) ) {
this.#toggleThresholdInputVisibility();
}
return super._onChangeInput(event);
}
/* -------------------------------------------- */
/**
* Toggle the disabled attribute of the door state select.
* @param {boolean} isDoor
*/
#enableDoorOptions(isDoor) {
const doorOptions = this.form.querySelector(".door-options");
doorOptions.disabled = !isDoor;
doorOptions.classList.toggle("hidden", !isDoor);
this.setPosition({height: "auto"});
}
/* -------------------------------------------- */
/**
* Toggle visibility of proximity input fields.
*/
#toggleThresholdInputVisibility() {
const form = this.form;
const showTypes = [CONST.WALL_SENSE_TYPES.PROXIMITY, CONST.WALL_SENSE_TYPES.DISTANCE];
for ( const sense of ["light", "sight", "sound"] ) {
const select = form[sense];
const input = select.parentElement.querySelector(".proximity");
input.classList.toggle("hidden", !showTypes.includes(Number(select.value)));
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const thresholdTypes = [CONST.WALL_SENSE_TYPES.PROXIMITY, CONST.WALL_SENSE_TYPES.DISTANCE];
const formData = super._getSubmitData(updateData);
for ( const sense of ["light", "sight", "sound"] ) {
if ( !thresholdTypes.includes(formData[sense]) ) formData[`threshold.${sense}`] = null;
}
return formData;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
// Update multiple walls
if ( this.editTargets.length > 1 ) {
const updateData = canvas.scene.walls.reduce((arr, w) => {
if ( this.editTargets.includes(w.id) ) {
arr.push(foundry.utils.mergeObject(w.toJSON(), formData));
}
return arr;
}, []);
return canvas.scene.updateEmbeddedDocuments("Wall", updateData, {sound: false});
}
// Update single wall
if ( !this.object.id ) return;
return this.object.update(formData, {sound: false});
}
}

View File

@@ -0,0 +1,55 @@
/**
* A simple application which supports popping a ChatMessage out to a separate UI window.
* @extends {Application}
* @param {ChatMessage} object The {@link ChatMessage} object that is being popped out.
* @param {ApplicationOptions} [options] Application configuration options.
*/
class ChatPopout extends Application {
constructor(message, options) {
super(options);
/**
* The displayed Chat Message document
* @type {ChatMessage}
*/
this.message = message;
// Register the application
this.message.apps[this.appId] = this;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
width: 300,
height: "auto",
classes: ["chat-popout"]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get id() {
return `chat-popout-${this.message.id}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
let title = this.message.flavor ?? this.message.speaker.alias;
return TextEditor.previewHTML(title, 32);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(data, options) {
const html = await this.message.getHTML();
html.find(".message-delete").remove();
return html;
}
}

View File

@@ -0,0 +1,181 @@
/**
* The Application responsible for displaying and editing the client and world settings for this world.
* This form renders the settings defined via the game.settings.register API which have config = true
*/
class SettingsConfig extends PackageConfiguration {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("SETTINGS.Title"),
id: "client-settings",
categoryTemplate: "templates/sidebar/apps/settings-config-category.html",
submitButton: true
});
}
/* -------------------------------------------- */
/** @inheritDoc */
_prepareCategoryData() {
const gs = game.settings;
const canConfigure = game.user.can("SETTINGS_MODIFY");
let categories = new Map();
let total = 0;
const getCategory = category => {
let cat = categories.get(category.id);
if ( !cat ) {
cat = {
id: category.id,
title: category.title,
menus: [],
settings: [],
count: 0
};
categories.set(category.id, cat);
}
return cat;
};
// Classify all menus
for ( let menu of gs.menus.values() ) {
if ( menu.restricted && !canConfigure ) continue;
const category = getCategory(this._categorizeEntry(menu.namespace));
category.menus.push(menu);
total++;
}
// Classify all settings
for ( let setting of gs.settings.values() ) {
if ( !setting.config || (!canConfigure && (setting.scope !== "client")) ) continue;
// Update setting data
const s = foundry.utils.deepClone(setting);
s.id = `${s.namespace}.${s.key}`;
s.name = game.i18n.localize(s.name);
s.hint = game.i18n.localize(s.hint);
s.value = game.settings.get(s.namespace, s.key);
s.type = setting.type instanceof Function ? setting.type.name : "String";
s.isCheckbox = setting.type === Boolean;
s.isSelect = s.choices !== undefined;
s.isRange = (setting.type === Number) && s.range;
s.isNumber = setting.type === Number;
s.filePickerType = s.filePicker === true ? "any" : s.filePicker;
const category = getCategory(this._categorizeEntry(setting.namespace));
category.settings.push(s);
total++;
}
// Sort categories by priority and assign Counts
for ( let category of categories.values() ) {
category.count = category.menus.length + category.settings.length;
}
categories = Array.from(categories.values()).sort(this._sortCategories.bind(this));
return {categories, total, user: game.user, canConfigure};
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find(".submenu button").click(this._onClickSubmenu.bind(this));
html.find('[name="core.fontSize"]').change(this._previewFontScaling.bind(this));
}
/* -------------------------------------------- */
/**
* Handle activating the button to configure User Role permissions
* @param {Event} event The initial button click event
* @private
*/
_onClickSubmenu(event) {
event.preventDefault();
const menu = game.settings.menus.get(event.currentTarget.dataset.key);
if ( !menu ) return ui.notifications.error("No submenu found for the provided key");
const app = new menu.type();
return app.render(true);
}
/* -------------------------------------------- */
/**
* Preview font scaling as the setting is changed.
* @param {Event} event The triggering event.
* @private
*/
_previewFontScaling(event) {
const scale = Number(event.currentTarget.value);
game.scaleFonts(scale);
this.setPosition();
}
/* --------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
game.scaleFonts();
return super.close(options);
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
let requiresClientReload = false;
let requiresWorldReload = false;
for ( let [k, v] of Object.entries(foundry.utils.flattenObject(formData)) ) {
let s = game.settings.settings.get(k);
let current = game.settings.get(s.namespace, s.key);
if ( v === current ) continue;
requiresClientReload ||= (s.scope === "client") && s.requiresReload;
requiresWorldReload ||= (s.scope === "world") && s.requiresReload;
await game.settings.set(s.namespace, s.key, v);
}
if ( requiresClientReload || requiresWorldReload ) this.constructor.reloadConfirm({world: requiresWorldReload});
}
/* -------------------------------------------- */
/**
* Handle button click to reset default settings
* @param {Event} event The initial button click event
* @private
*/
_onResetDefaults(event) {
event.preventDefault();
const form = this.element.find("form")[0];
for ( let [k, v] of game.settings.settings.entries() ) {
if ( !v.config ) continue;
const input = form[k];
if ( !input ) continue;
if ( input.type === "checkbox" ) input.checked = v.default;
else input.value = v.default;
$(input).change();
}
ui.notifications.info("SETTINGS.ResetInfo", {localize: true});
}
/* -------------------------------------------- */
/**
* Confirm if the user wishes to reload the application.
* @param {object} [options] Additional options to configure the prompt.
* @param {boolean} [options.world=false] Whether to reload all connected clients as well.
* @returns {Promise<void>}
*/
static async reloadConfirm({world=false}={}) {
const reload = await Dialog.confirm({
title: game.i18n.localize("SETTINGS.ReloadPromptTitle"),
content: `<p>${game.i18n.localize("SETTINGS.ReloadPromptBody")}</p>`
});
if ( !reload ) return;
if ( world && game.user.isGM ) game.socket.emit("reload");
foundry.utils.debouncedReload();
}
}

View File

@@ -0,0 +1,265 @@
/**
* An interface for displaying the content of a CompendiumCollection.
* @param {CompendiumCollection} collection The {@link CompendiumCollection} object represented by this interface.
* @param {ApplicationOptions} [options] Application configuration options.
*/
class Compendium extends DocumentDirectory {
constructor(...args) {
if ( args[0] instanceof Collection ) {
foundry.utils.logCompatibilityWarning("Compendium constructor should now be passed a CompendiumCollection "
+ "instance via {collection: compendiumCollection}", {
since: 11,
until: 13
});
args[1] ||= {};
args[1].collection = args.shift();
}
super(...args);
}
/* -------------------------------------------- */
/** @override */
get entryType() {
return this.metadata.type;
}
/* -------------------------------------------- */
/** @override */
static entryPartial = "templates/sidebar/partials/compendium-index-partial.html";
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/apps/compendium.html",
width: 350,
height: window.innerHeight - 100,
top: 70,
left: 120,
popOut: true
});
}
/* -------------------------------------------- */
/** @inheritDoc */
get id() {
return `compendium-${this.collection.collection}`;
}
/* ----------------------------------------- */
/** @inheritdoc */
get title() {
return [this.collection.title, this.collection.locked ? "[Locked]" : null].filterJoin(" ");
}
/* -------------------------------------------- */
/** @inheritdoc */
get tabName() {
return "Compendium";
}
/* -------------------------------------------- */
/** @override */
get canCreateEntry() {
const cls = getDocumentClass(this.collection.documentName);
const isOwner = this.collection.testUserPermission(game.user, "OWNER");
return !this.collection.locked && isOwner && cls.canUserCreate(game.user);
}
/* -------------------------------------------- */
/** @override */
get canCreateFolder() {
return this.canCreateEntry;
}
/* ----------------------------------------- */
/**
* A convenience redirection back to the metadata object of the associated CompendiumCollection
* @returns {object}
*/
get metadata() {
return this.collection.metadata;
}
/* -------------------------------------------- */
/** @override */
initialize() {
this.collection.initializeTree();
}
/* ----------------------------------------- */
/* Rendering */
/* ----------------------------------------- */
/** @inheritDoc */
render(force, options) {
if ( !this.collection.visible ) {
if ( force ) ui.notifications.warn("COMPENDIUM.CannotViewWarning", {localize: true});
return this;
}
return super.render(force, options);
}
/* ----------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
const context = await super.getData(options);
return foundry.utils.mergeObject(context, {
collection: this.collection,
index: this.collection.index,
name: game.i18n.localize(this.metadata.label),
footerButtons: []
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
_entryAlreadyExists(document) {
return (document.pack === this.collection.collection) && this.collection.index.has(document.id);
}
/* -------------------------------------------- */
/** @override */
async _createDroppedEntry(document, folderId) {
document = document.clone({folder: folderId || null}, {keepId: true});
return this.collection.importDocument(document);
}
/* -------------------------------------------- */
/** @override */
_getEntryDragData(entryId) {
return {
type: this.collection.documentName,
uuid: this.collection.getUuid(entryId)
};
}
/* -------------------------------------------- */
/** @override */
_onCreateEntry(event) {
// If this is an Adventure, use the Adventure Exporter application
if ( this.collection.documentName === "Adventure" ) {
const adventure = new Adventure({name: "New Adventure"}, {pack: this.collection.collection});
return new AdventureExporter(adventure).render(true);
}
return super._onCreateEntry(event);
}
/* -------------------------------------------- */
/**
* Handle clicks on a footer button
* @param {PointerEvent} event The originating pointer event
* @private
*/
_onClickFooterButton(event) {
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "createAdventure":
const adventure = new Adventure({name: "New Adventure"}, {pack: this.collection.collection});
return new AdventureExporter(adventure).render(true);
}
}
/* -------------------------------------------- */
/** @override */
_getDocumentDragData(documentId) {
return {
type: this.collection.documentName,
uuid: this.collection.getUuid(documentId)
};
}
/* -------------------------------------------- */
/** @override */
_getFolderDragData(folderId) {
const folder = this.collection.folders.get(folderId);
if ( !folder ) return null;
return {
type: "Folder",
uuid: folder.uuid
};
}
/* -------------------------------------------- */
/** @override */
_getFolderContextOptions() {
const toRemove = ["OWNERSHIP.Configure", "FOLDER.Export"];
return super._getFolderContextOptions().filter(o => !toRemove.includes(o.name));
}
/* -------------------------------------------- */
/** @override */
_getEntryContextOptions() {
const isAdventure = this.collection.documentName === "Adventure";
return [
{
name: "COMPENDIUM.ImportEntry",
icon: '<i class="fas fa-download"></i>',
condition: () => !isAdventure && this.collection.documentClass.canUserCreate(game.user),
callback: li => {
const collection = game.collections.get(this.collection.documentName);
const id = li.data("document-id");
return collection.importFromCompendium(this.collection, id, {}, {renderSheet: true});
}
},
{
name: "ADVENTURE.ExportEdit",
icon: '<i class="fa-solid fa-edit"></i>',
condition: () => isAdventure && game.user.isGM && !this.collection.locked,
callback: async li => {
const id = li.data("document-id");
const document = await this.collection.getDocument(id);
return new AdventureExporter(document.clone({}, {keepId: true})).render(true);
}
},
{
name: "SCENES.GenerateThumb",
icon: '<i class="fas fa-image"></i>',
condition: () => !this.collection.locked && (this.collection.documentName === "Scene"),
callback: async li => {
const scene = await this.collection.getDocument(li.data("document-id"));
scene.createThumbnail().then(data => {
scene.update({thumb: data.thumb}, {diff: false});
ui.notifications.info(game.i18n.format("SCENES.GenerateThumbSuccess", {name: scene.name}));
}).catch(err => ui.notifications.error(err.message));
}
},
{
name: "COMPENDIUM.DeleteEntry",
icon: '<i class="fas fa-trash"></i>',
condition: () => game.user.isGM && !this.collection.locked,
callback: async li => {
const id = li.data("document-id");
const document = await this.collection.getDocument(id);
return Dialog.confirm({
title: `${game.i18n.localize("COMPENDIUM.DeleteEntry")} ${document.name}`,
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("COMPENDIUM.DeleteEntryWarning")}</p>`,
yes: () => document.delete()
});
}
}
];
}
}

View File

@@ -0,0 +1,79 @@
/**
* Game Invitation Links Reference
* @extends {Application}
*/
class InvitationLinks extends Application {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "invitation-links",
template: "templates/sidebar/apps/invitation-links.html",
title: game.i18n.localize("INVITATIONS.Title"),
width: 400
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
let addresses = game.data.addresses;
// Check for IPv6 detection, and don't display connectivity info if so
if ( addresses.remote === undefined ) return addresses;
// Otherwise, handle remote connection test
if ( addresses.remoteIsAccessible == null ) {
addresses.remoteClass = "unknown-connection";
addresses.remoteTitle = game.i18n.localize("INVITATIONS.UnknownConnection");
addresses.failedCheck = true;
} else if ( addresses.remoteIsAccessible ) {
addresses.remoteClass = "connection";
addresses.remoteTitle = game.i18n.localize("INVITATIONS.OpenConnection");
addresses.canConnect = true;
} else {
addresses.remoteClass = "no-connection";
addresses.remoteTitle = game.i18n.localize("INVITATIONS.ClosedConnection");
addresses.canConnect = false;
}
return addresses;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".invite-link").click(ev => {
ev.preventDefault();
ev.target.select();
game.clipboard.copyPlainText(ev.currentTarget.value);
ui.notifications.info("INVITATIONS.Copied", {localize: true});
});
html.find(".refresh").click(ev => {
ev.preventDefault();
const icon = ev.currentTarget;
icon.className = "fas fa-sync fa-pulse";
let me = this;
setTimeout(function(){
game.socket.emit("refreshAddresses", addresses => {
game.data.addresses = addresses;
me.render(true);
});
}, 250)
});
html.find(".show-hide").click(ev => {
ev.preventDefault();
const icon = ev.currentTarget;
const showLink = icon.classList.contains("show-link");
if ( showLink ) {
icon.classList.replace("fa-eye", "fa-eye-slash");
icon.classList.replace("show-link", "hide-link");
}
else {
icon.classList.replace("fa-eye-slash", "fa-eye");
icon.classList.replace("hide-link", "show-link");
}
icon.closest("form").querySelector('#remote-link').type = showLink ? "text" : "password";
});
}
}

View File

@@ -0,0 +1,543 @@
/**
* Allows for viewing and editing of Keybinding Actions
*/
class KeybindingsConfig extends PackageConfiguration {
/**
* Categories present in the app. Within each category is an array of package data
* @type {{categories: object[], total: number}}
* @protected
*/
#cachedData;
/**
* A Map of pending Edits. The Keys are bindingIds
* @type {Map<string, KeybindingActionBinding[]>}
* @private
*/
#pendingEdits = new Map();
/* -------------------------------------------- */
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("SETTINGS.Keybindings"),
id: "keybindings",
categoryTemplate: "templates/sidebar/apps/keybindings-config-category.html",
scrollY: [".scrollable"]
});
}
/* -------------------------------------------- */
/** @inheritDoc */
static get categoryOrder() {
const categories = super.categoryOrder;
categories.splice(2, 0, "core-mouse");
return categories;
}
/* -------------------------------------------- */
/** @inheritDoc */
_categorizeEntry(namespace) {
const category = super._categorizeEntry(namespace);
if ( namespace === "core" ) category.title = game.i18n.localize("KEYBINDINGS.CoreKeybindings");
return category;
}
/* -------------------------------------------- */
/** @inheritDoc */
_prepareCategoryData() {
if ( this.#cachedData ) return this.#cachedData;
// Classify all Actions
let categories = new Map();
let totalActions = 0;
const ctrlString = KeyboardManager.CONTROL_KEY_STRING;
for ( let [actionId, action] of game.keybindings.actions ) {
if ( action.restricted && !game.user.isGM ) continue;
totalActions++;
// Determine what category the action belongs to
let category = this._categorizeEntry(action.namespace);
// Carry over bindings for future rendering
const actionData = foundry.utils.deepClone(action);
actionData.category = category.title;
actionData.id = actionId;
actionData.name = game.i18n.localize(action.name);
actionData.hint = game.i18n.localize(action.hint);
actionData.cssClass = action.restricted ? "gm" : "";
actionData.notes = [
action.restricted ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
action.reservedModifiers.length > 0 ? game.i18n.format("KEYBINDINGS.ReservedModifiers", {
modifiers: action.reservedModifiers.map(m => m === "Control" ? ctrlString : m.titleCase()).join(", ")
}) : "",
game.i18n.localize(action.hint)
].filterJoin("<br>");
actionData.uneditable = action.uneditable;
// Prepare binding-level data
actionData.bindings = (game.keybindings.bindings.get(actionId) ?? []).map((b, i) => {
const uneditable = action.uneditable.includes(b);
const binding = foundry.utils.deepClone(b);
binding.id = `${actionId}.binding.${i}`;
binding.display = KeybindingsConfig._humanizeBinding(binding);
binding.cssClasses = uneditable ? "uneditable" : "";
binding.isEditable = !uneditable;
binding.isFirst = i === 0;
const conflicts = this._detectConflictingActions(actionId, action, binding);
binding.conflicts = game.i18n.format("KEYBINDINGS.Conflict", {
conflicts: conflicts.map(action => game.i18n.localize(action.name)).join(", ")
});
binding.hasConflicts = conflicts.length > 0;
return binding;
});
actionData.noBindings = actionData.bindings.length === 0;
// Register a category the first time it is seen, otherwise add to it
if ( !categories.has(category.id) ) {
categories.set(category.id, {
id: category.id,
title: category.title,
actions: [actionData],
count: 0
});
} else categories.get(category.id).actions.push(actionData);
}
// Add Mouse Controls
totalActions += this._addMouseControlsReference(categories);
// Sort Actions by priority and assign Counts
for ( let category of categories.values() ) {
category.actions = category.actions.sort(ClientKeybindings._compareActions);
category.count = category.actions.length;
}
categories = Array.from(categories.values()).sort(this._sortCategories.bind(this));
return this.#cachedData = {categories, total: totalActions};
}
/* -------------------------------------------- */
/**
* Add faux-keybind actions that represent the possible Mouse Controls
* @param {Map} categories The current Map of Categories to add to
* @returns {number} The number of Actions added
* @private
*/
_addMouseControlsReference(categories) {
let coreMouseCategory = game.i18n.localize("KEYBINDINGS.CoreMouse");
const defineMouseAction = (id, name, keys, gmOnly=false) => {
return {
category: coreMouseCategory,
id: id,
name: game.i18n.localize(name),
notes: gmOnly ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
bindings: [
{
display: keys.map(k => game.i18n.localize(k)).join(" + "),
cssClasses: "uneditable",
isEditable: false,
hasConflicts: false,
isFirst: false
}
]
};
};
const actions = [
["canvas-select", "CONTROLS.CanvasSelect", ["CONTROLS.LeftClick"]],
["canvas-select-many", "CONTROLS.CanvasSelectMany", ["Shift", "CONTROLS.LeftClick"]],
["canvas-drag", "CONTROLS.CanvasLeftDrag", ["CONTROLS.LeftClick", "CONTROLS.Drag"]],
["canvas-select-cancel", "CONTROLS.CanvasSelectCancel", ["CONTROLS.RightClick"]],
["canvas-pan-mouse", "CONTROLS.CanvasPan", ["CONTROLS.RightClick", "CONTROLS.Drag"]],
["canvas-zoom", "CONTROLS.CanvasSelectCancel", ["CONTROLS.MouseWheel"]],
["ruler-measure", "CONTROLS.RulerMeasure", [KeyboardManager.CONTROL_KEY_STRING, "CONTROLS.LeftDrag"]],
["ruler-measure-waypoint", "CONTROLS.RulerWaypoint", [KeyboardManager.CONTROL_KEY_STRING, "CONTROLS.LeftClick"]],
["object-sheet", "CONTROLS.ObjectSheet", [`${game.i18n.localize("CONTROLS.Double")} ${game.i18n.localize("CONTROLS.LeftClick")}`]],
["object-hud", "CONTROLS.ObjectHUD", ["CONTROLS.RightClick"]],
["object-config", "CONTROLS.ObjectConfig", [`${game.i18n.localize("CONTROLS.Double")} ${game.i18n.localize("CONTROLS.RightClick")}`]],
["object-drag", "CONTROLS.ObjectDrag", ["CONTROLS.LeftClick", "CONTROLS.Drag"]],
["object-no-snap", "CONTROLS.ObjectNoSnap", ["CONTROLS.Drag", "Shift", "CONTROLS.Drop"]],
["object-drag-cancel", "CONTROLS.ObjectDragCancel", [`${game.i18n.localize("CONTROLS.RightClick")} ${game.i18n.localize("CONTROLS.During")} ${game.i18n.localize("CONTROLS.Drag")}`]],
["object-rotate-slow", "CONTROLS.ObjectRotateSlow", [KeyboardManager.CONTROL_KEY_STRING, "CONTROLS.MouseWheel"]],
["object-rotate-fast", "CONTROLS.ObjectRotateFast", ["Shift", "CONTROLS.MouseWheel"]],
["place-hidden-token", "CONTROLS.TokenPlaceHidden", ["Alt", "CONTROLS.Drop"], true],
["token-target-mouse", "CONTROLS.TokenTarget", [`${game.i18n.localize("CONTROLS.Double")} ${game.i18n.localize("CONTROLS.RightClick")}`]],
["canvas-ping", "CONTROLS.CanvasPing", ["CONTROLS.LongPress"]],
["canvas-ping-alert", "CONTROLS.CanvasPingAlert", ["Alt", "CONTROLS.LongPress"]],
["canvas-ping-pull", "CONTROLS.CanvasPingPull", ["Shift", "CONTROLS.LongPress"], true],
["tooltip-lock", "CONTROLS.TooltipLock", ["CONTROLS.MiddleClick"]],
["tooltip-dismiss", "CONTROLS.TooltipDismiss", ["CONTROLS.RightClick"]]
];
let coreMouseCategoryData = {
id: "core-mouse",
title: coreMouseCategory,
actions: actions.map(a => defineMouseAction(...a)),
count: 0
};
coreMouseCategoryData.count = coreMouseCategoryData.actions.length;
categories.set("core-mouse", coreMouseCategoryData);
return coreMouseCategoryData.count;
}
/* -------------------------------------------- */
/**
* Given an Binding and its parent Action, detects other Actions that might conflict with that binding
* @param {string} actionId The namespaced Action ID the Binding belongs to
* @param {KeybindingActionConfig} action The Action config
* @param {KeybindingActionBinding} binding The Binding
* @returns {KeybindingAction[]}
* @private
*/
_detectConflictingActions(actionId, action, binding) {
// Uneditable Core bindings are never wrong, they can never conflict with something
if ( actionId.startsWith("core.") && action.uneditable.includes(binding) ) return [];
// Build fake context
/** @type KeyboardEventContext */
const context = KeyboardManager.getKeyboardEventContext({
code: binding.key,
shiftKey: binding.modifiers.includes(KeyboardManager.MODIFIER_KEYS.SHIFT),
ctrlKey: binding.modifiers.includes(KeyboardManager.MODIFIER_KEYS.CONTROL),
altKey: binding.modifiers.includes(KeyboardManager.MODIFIER_KEYS.ALT),
repeat: false
});
// Return matching keybinding actions (excluding this one)
let matching = KeyboardManager._getMatchingActions(context);
return matching.filter(a => a.action !== actionId);
}
/* -------------------------------------------- */
/**
* Transforms a Binding into a human-readable string representation
* @param {KeybindingActionBinding} binding The Binding
* @returns {string} A human readable string
* @private
*/
static _humanizeBinding(binding) {
const stringParts = binding.modifiers.reduce((parts, part) => {
if ( KeyboardManager.MODIFIER_CODES[part]?.includes(binding.key) ) return parts;
parts.unshift(KeyboardManager.getKeycodeDisplayString(part));
return parts;
}, [KeyboardManager.getKeycodeDisplayString(binding.key)]);
return stringParts.join(" + ");
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
const actionBindings = html.find(".action-bindings");
actionBindings.on("dblclick", ".editable-binding", this._onDoubleClickKey.bind(this));
actionBindings.on("click", ".control", this._onClickBindingControl.bind(this));
actionBindings.on("keydown", ".binding-input", this._onKeydownBindingInput.bind(this));
}
/* -------------------------------------------- */
/** @override */
async _onResetDefaults(event) {
return Dialog.confirm({
title: game.i18n.localize("KEYBINDINGS.ResetTitle"),
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("KEYBINDINGS.ResetWarning")}</p>`,
yes: async () => {
await game.keybindings.resetDefaults();
this.#cachedData = undefined;
this.#pendingEdits.clear();
this.render();
ui.notifications.info("KEYBINDINGS.ResetSuccess", {localize: true});
},
no: () => {},
defaultYes: false
});
}
/* -------------------------------------------- */
/**
* Handle Control clicks
* @param {MouseEvent} event
* @private
*/
_onClickBindingControl(event) {
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "add":
this._onClickAdd(event); break;
case "delete":
this._onClickDelete(event); break;
case "edit":
return this._onClickEditableBinding(event);
case "save":
return this._onClickSaveBinding(event);
}
}
/* -------------------------------------------- */
/**
* Handle left-click events to show / hide a certain category
* @param {MouseEvent} event
* @private
*/
async _onClickAdd(event) {
const {actionId, namespace, action} = this._getParentAction(event);
const {bindingHtml, bindingId} = this._getParentBinding(event);
const bindings = game.keybindings.bindings.get(actionId);
const newBindingId = `${namespace}.${action}.binding.${bindings.length}`;
const toInsert =
`<li class="binding flexrow inserted" data-binding-id="${newBindingId}">
<div class="editable-binding">
<div class="form-fields binding-fields">
<input type="text" class="binding-input" name="${newBindingId}" id="${newBindingId}" placeholder="Control + 1">
<i class="far fa-keyboard binding-input-icon"></i>
</div>
</div>
<div class="binding-controls flexrow">
<a class="control save-edit" title="${game.i18n.localize("KEYBINDINGS.SaveBinding")}" data-action="save"><i class="fas fa-save"></i></a>
<a class="control" title="${game.i18n.localize("KEYBINDINGS.DeleteBinding")}" data-action="delete"><i class="fas fa-trash-alt"></i></a>
</div>
</li>`;
bindingHtml.closest(".action-bindings").insertAdjacentHTML("beforeend", toInsert);
document.getElementById(newBindingId).focus();
// If this is an empty binding, delete it
if ( bindingId === "empty" ) {
bindingHtml.remove();
}
}
/* -------------------------------------------- */
/**
* Handle left-click events to show / hide a certain category
* @param {MouseEvent} event
* @private
*/
async _onClickDelete(event) {
const {namespace, action} = this._getParentAction(event);
const {bindingId} = this._getParentBinding(event);
const bindingIndex = Number.parseInt(bindingId.split(".")[3]);
this._addPendingEdit(namespace, action, bindingIndex, {index: bindingIndex, key: null});
await this._savePendingEdits();
}
/* -------------------------------------------- */
/**
* Inserts a Binding into the Pending Edits object, creating a new Map entry as needed
* @param {string} namespace
* @param {string} action
* @param {number} bindingIndex
* @param {KeybindingActionBinding} binding
* @private
*/
_addPendingEdit(namespace, action, bindingIndex, binding) {
// Save pending edits
const pendingEditKey = `${namespace}.${action}`;
if ( this.#pendingEdits.has(pendingEditKey) ) {
// Filter out any existing pending edits for this Binding so we don't add each Key in "Shift + A"
let currentBindings = this.#pendingEdits.get(pendingEditKey).filter(x => x.index !== bindingIndex);
currentBindings.push(binding);
this.#pendingEdits.set(pendingEditKey, currentBindings);
} else {
this.#pendingEdits.set(pendingEditKey, [binding]);
}
}
/* -------------------------------------------- */
/**
* Toggle visibility of the Edit / Save UI
* @param {MouseEvent} event
* @private
*/
_onClickEditableBinding(event) {
const target = event.currentTarget;
const bindingRow = target.closest("li.binding");
target.classList.toggle("hidden");
bindingRow.querySelector(".save-edit").classList.toggle("hidden");
for ( let binding of bindingRow.querySelectorAll(".editable-binding") ) {
binding.classList.toggle("hidden");
binding.getElementsByClassName("binding-input")[0]?.focus();
}
}
/* -------------------------------------------- */
/**
* Toggle visibility of the Edit UI
* @param {MouseEvent} event
* @private
*/
_onDoubleClickKey(event) {
const target = event.currentTarget;
// If this is an inserted binding, don't try to swap to a non-edit mode
if ( target.parentNode.parentNode.classList.contains("inserted") ) return;
for ( let child of target.parentNode.getElementsByClassName("editable-binding") ) {
child.classList.toggle("hidden");
child.getElementsByClassName("binding-input")[0]?.focus();
}
const bindingRow = target.closest(".binding");
for ( let child of bindingRow.getElementsByClassName("save-edit") ) {
child.classList.toggle("hidden");
}
}
/* -------------------------------------------- */
/**
* Save the new Binding value and update the display of the UI
* @param {MouseEvent} event
* @private
*/
async _onClickSaveBinding(event) {
await this._savePendingEdits();
}
/* -------------------------------------------- */
/**
* Given a clicked Action element, finds the parent Action
* @param {MouseEvent|KeyboardEvent} event
* @returns {{namespace: string, action: string, actionHtml: *}}
* @private
*/
_getParentAction(event) {
const actionHtml = event.currentTarget.closest(".action");
const actionId = actionHtml.dataset.actionId;
let [namespace, ...action] = actionId.split(".");
action = action.join(".");
return {actionId, actionHtml, namespace, action};
}
/* -------------------------------------------- */
/**
* Given a Clicked binding control element, finds the parent Binding
* @param {MouseEvent|KeyboardEvent} event
* @returns {{bindingHtml: *, bindingId: string}}
* @private
*/
_getParentBinding(event) {
const bindingHtml = event.currentTarget.closest(".binding");
const bindingId = bindingHtml.dataset.bindingId;
return {bindingHtml, bindingId};
}
/* -------------------------------------------- */
/**
* Iterates over all Pending edits, merging them in with unedited Bindings and then saving and resetting the UI
* @returns {Promise<void>}
* @private
*/
async _savePendingEdits() {
for ( let [id, pendingBindings] of this.#pendingEdits ) {
let [namespace, ...action] = id.split(".");
action = action.join(".");
const bindingsData = game.keybindings.bindings.get(id);
const actionData = game.keybindings.actions.get(id);
// Identify the set of bindings which should be saved
const toSet = [];
for ( const [index, binding] of bindingsData.entries() ) {
if ( actionData.uneditable.includes(binding) ) continue;
const {key, modifiers} = binding;
toSet[index] = {key, modifiers};
}
for ( const binding of pendingBindings ) {
const {index, key, modifiers} = binding;
toSet[index] = {key, modifiers};
}
// Try to save the binding, reporting any errors
try {
await game.keybindings.set(namespace, action, toSet.filter(b => !!b?.key));
}
catch(e) {
ui.notifications.error(e);
}
}
// Reset and rerender
this.#cachedData = undefined;
this.#pendingEdits.clear();
this.render();
}
/* -------------------------------------------- */
/**
* Processes input from the keyboard to form a list of pending Binding edits
* @param {KeyboardEvent} event The keyboard event
* @private
*/
_onKeydownBindingInput(event) {
const context = KeyboardManager.getKeyboardEventContext(event);
// Stop propagation
event.preventDefault();
event.stopPropagation();
const {bindingHtml, bindingId} = this._getParentBinding(event);
const {namespace, action} = this._getParentAction(event);
// Build pending Binding
const bindingIdParts = bindingId.split(".");
const bindingIndex = Number.parseInt(bindingIdParts[bindingIdParts.length - 1]);
const {MODIFIER_KEYS, MODIFIER_CODES} = KeyboardManager;
/** @typedef {KeybindingActionBinding} **/
let binding = {
index: bindingIndex,
key: context.key,
modifiers: []
};
if ( context.isAlt && !MODIFIER_CODES[MODIFIER_KEYS.ALT].includes(context.key) ) {
binding.modifiers.push(MODIFIER_KEYS.ALT);
}
if ( context.isShift && !MODIFIER_CODES[MODIFIER_KEYS.SHIFT].includes(context.key) ) {
binding.modifiers.push(MODIFIER_KEYS.SHIFT);
}
if ( context.isControl && !MODIFIER_CODES[MODIFIER_KEYS.CONTROL].includes(context.key) ) {
binding.modifiers.push(MODIFIER_KEYS.CONTROL);
}
// Save pending edits
this._addPendingEdit(namespace, action, bindingIndex, binding);
// Predetect potential conflicts
const conflicts = this._detectConflictingActions(`${namespace}.${action}`, game.keybindings.actions.get(`${namespace}.${action}`), binding);
const conflictString = game.i18n.format("KEYBINDINGS.Conflict", {
conflicts: conflicts.map(action => game.i18n.localize(action.name)).join(", ")
});
// Remove existing conflicts and add a new one
for ( const conflict of bindingHtml.getElementsByClassName("conflicts") ) {
conflict.remove();
}
if ( conflicts.length > 0 ) {
const conflictHtml = `<div class="control conflicts" title="${conflictString}"><i class="fas fa-exclamation-triangle"></i></div>`;
bindingHtml.getElementsByClassName("binding-controls")[0].insertAdjacentHTML("afterbegin", conflictHtml);
}
// Set value
event.currentTarget.value = this.constructor._humanizeBinding(binding);
}
}

View File

@@ -0,0 +1,563 @@
/**
* The Module Management Application.
* This application provides a view of which modules are available to be used and allows for configuration of the
* set of modules which are active within the World.
*/
class ModuleManagement extends FormApplication {
constructor(...args) {
super(...args);
this._filter = this.isEditable ? "all" : "active";
this._expanded = true;
}
/**
* The named game setting which persists module configuration.
* @type {string}
*/
static CONFIG_SETTING = "moduleConfiguration";
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("MODMANAGE.Title"),
id: "module-management",
template: "templates/sidebar/apps/module-management.html",
popOut: true,
width: 680,
height: "auto",
scrollY: [".package-list"],
closeOnSubmit: false,
filters: [{inputSelector: 'input[name="search"]', contentSelector: ".package-list"}]
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get isEditable() {
return game.user.can("SETTINGS_MODIFY");
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const editable = this.isEditable;
const counts = {all: game.modules.size, active: 0, inactive: 0};
// Prepare modules
const modules = game.modules.reduce((arr, module) => {
const isActive = module.active;
if ( isActive ) counts.active++;
else if ( !editable ) return arr;
else counts.inactive++;
const mod = module.toObject();
mod.active = isActive;
mod.css = isActive ? " active" : "";
mod.hasPacks = mod.packs.length > 0;
mod.hasScripts = mod.scripts.length > 0;
mod.hasStyles = mod.styles.length > 0;
mod.systemOnly = mod.relationships?.systems.find(s => s.id === game.system.id);
mod.systemTag = game.system.id;
mod.authors = mod.authors.map(a => {
if ( a.url ) return `<a href="${a.url}" target="_blank">${a.name}</a>`;
return a.name;
}).join(", ");
mod.tooltip = null; // No tooltip by default
const requiredModules = Array.from(game.world.relationships.requires)
.concat(Array.from(game.system.relationships.requires));
mod.required = !!requiredModules.find(r => r.id === mod.id);
if ( mod.required ) mod.tooltip = game.i18n.localize("MODMANAGE.RequiredModule");
// String formatting labels
const authorsLabel = game.i18n.localize(`Author${module.authors.size > 1 ? "Pl" : ""}`);
mod.labels = {authors: authorsLabel};
mod.badge = module.getVersionBadge();
// Document counts.
const subTypeCounts = game.issues.getSubTypeCountsFor(mod);
if ( subTypeCounts ) mod.documents = this._formatDocumentSummary(subTypeCounts, isActive);
// If the current System is not one of the supported ones, don't return
if ( mod.relationships?.systems.size > 0 && !mod.systemOnly ) return arr;
mod.enableable = true;
this._evaluateDependencies(mod);
this._evaluateSystemCompatibility(mod);
mod.disabled = mod.required || !mod.enableable;
return arr.concat([mod]);
}, []).sort((a, b) => a.title.localeCompare(b.title));
// Filters
const filters = editable ? ["all", "active", "inactive"].map(f => ({
id: f,
label: game.i18n.localize(`MODMANAGE.Filter${f.titleCase()}`),
count: counts[f] || 0
})) : [];
// Return data for rendering
return { editable, filters, modules, expanded: this._expanded };
}
/* -------------------------------------------- */
/**
* Given a module, determines if it meets minimum and maximum compatibility requirements of its dependencies.
* If not, it is marked as being unable to be activated.
* If the package does not meet verified requirements, it is marked with a warning instead.
* @param {object} module The module.
* @protected
*/
_evaluateDependencies(module) {
for ( const required of module.relationships.requires ) {
if ( required.type !== "module" ) continue;
// Verify the required package is installed
const pkg = game.modules.get(required.id);
if ( !pkg ) {
module.enableable = false;
required.class = "error";
required.message = game.i18n.localize("SETUP.DependencyNotInstalled");
continue;
}
// Test required package compatibility
const c = required.compatibility;
if ( !c ) continue;
const dependencyVersion = pkg.version;
if ( c.minimum && foundry.utils.isNewerVersion(c.minimum, dependencyVersion) ) {
module.enableable = false;
required.class = "error";
required.message = game.i18n.format("SETUP.CompatibilityRequireUpdate",
{ version: required.compatibility.minimum});
continue;
}
if ( c.maximum && foundry.utils.isNewerVersion(dependencyVersion, c.maximum) ) {
module.enableable = false;
required.class = "error";
required.message = game.i18n.format("SETUP.CompatibilityRequireDowngrade",
{ version: required.compatibility.maximum});
continue;
}
if ( c.verified && !foundry.utils.isNewerVersion(dependencyVersion, c.verified) ) {
required.class = "warning";
required.message = game.i18n.format("SETUP.CompatibilityRiskWithVersion",
{version: required.compatibility.verified});
}
}
// Record that a module may not be able to be enabled
if ( !module.enableable ) module.tooltip = game.i18n.localize("MODMANAGE.DependencyIssues");
}
/* -------------------------------------------- */
/**
* Given a module, determine if it meets the minimum and maximum system compatibility requirements.
* @param {object} module The module.
* @protected
*/
_evaluateSystemCompatibility(module) {
if ( !module.relationships.systems?.length ) return;
const supportedSystem = module.relationships.systems.find(s => s.id === game.system.id);
const {minimum, maximum} = supportedSystem?.compatibility ?? {};
const {version} = game.system;
if ( !minimum && !maximum ) return;
if ( minimum && foundry.utils.isNewerVersion(minimum, version) ) {
module.enableable = false;
module.tooltip = game.i18n.format("MODMANAGE.SystemCompatibilityIssueMinimum", {minimum, version});
}
if ( maximum && foundry.utils.isNewerVersion(version, maximum) ) {
module.enableable = false;
module.tooltip = game.i18n.format("MODMANAGE.SystemCompatibilityIssueMaximum", {maximum, version});
}
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find('button[name="deactivate"]').click(this._onDeactivateAll.bind(this));
html.find(".filter").click(this._onFilterList.bind(this));
html.find("button.expand").click(this._onExpandCollapse.bind(this));
html.find('input[type="checkbox"]').change(this._onChangeCheckbox.bind(this));
// Allow users to filter modules even if they don't have permission to edit them.
html.find('input[name="search"]').attr("disabled", false);
html.find("button.expand").attr("disabled", false);
// Activate the appropriate filter.
html.find(`a[data-filter="${this._filter}"]`).addClass("active");
// Initialize
this._onExpandCollapse();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
await loadTemplates(["templates/setup/parts/package-tags.hbs"]);
return super._renderInner(...args);
}
/* -------------------------------------------- */
/** @inheritdoc */
_getSubmitData(updateData={}) {
const formData = super._getSubmitData(updateData);
delete formData.search;
return formData;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
const settings = game.settings.get("core", this.constructor.CONFIG_SETTING);
const requiresReload = !foundry.utils.isEmpty(foundry.utils.diffObject(settings, formData));
const setting = foundry.utils.mergeObject(settings, formData);
// Ensure all relationships are satisfied
for ( let [k, v] of Object.entries(setting) ) {
if ( v === false ) continue;
const mod = game.modules.get(k);
if ( !mod ) {
delete setting[k];
continue;
}
if ( !mod.relationships?.requires?.length ) continue;
const missing = mod.relationships.requires.reduce((arr, d) => {
if ( d.type && (d.type !== "module") ) return arr;
if ( !setting[d.id] ) arr.push(d.id);
return arr;
}, []);
if ( missing.length ) {
const warning = game.i18n.format("MODMANAGE.DepMissing", {module: k, missing: missing.join(", ")});
this.options.closeOnSubmit = false;
return ui.notifications.warn(warning);
}
}
// Apply the setting
if ( requiresReload ) SettingsConfig.reloadConfirm({world: true});
return game.settings.set("core", this.constructor.CONFIG_SETTING, setting);
}
/* -------------------------------------------- */
/**
* Handle changes to a module checkbox to prompt for whether to enable dependencies.
* @param {Event} event The change event.
* @protected
*/
async _onChangeCheckbox(event) {
const input = event.target;
const module = game.modules.get(input.name);
const enabling = input.checked;
// If disabling Packages, warn that subdocuments will be unavailable
if ( !enabling ) {
const confirm = await this._confirmDocumentsUnavailable(module);
if ( !confirm ) {
input.checked = true;
return;
}
}
// Check for impacted dependencies
const impactedDependencies = new Map();
for ( const pkg of module.relationships.requires ) {
if ( impactedDependencies.has(pkg.id) ) continue;
this.#checkImpactedDependency(impactedDependencies, pkg, enabling, input, true);
}
for ( const pkg of module.relationships.recommends ) {
if ( impactedDependencies.has(pkg.id) ) continue;
this.#checkImpactedDependency(impactedDependencies, pkg, enabling, input, false);
}
// Check for upstream packages
const upstreamPackages = enabling ? new Set() : this.#checkUpstreamPackages(module);
if ( !impactedDependencies.size && !upstreamPackages.size ) return;
const dependencies = Array.from(impactedDependencies.values());
const { requiredDependencies, optionalDependencies } = dependencies.reduce((obj, dep) => {
if ( dep.required ) obj.requiredDependencies.push(dep);
else obj.optionalDependencies.push(dep);
return obj;
}, { requiredDependencies: [], optionalDependencies: [] });
const html = await renderTemplate("templates/setup/impacted-dependencies.html", {
enabling,
dependencies,
requiredDependencies,
optionalDependencies,
upstreamPackages: Array.from(upstreamPackages.values())
});
function deactivateUpstreamPackages() {
for ( let d of upstreamPackages ) {
const dep = input.form[d.id];
if ( dep ) {
dep.checked = false;
}
}
}
// If there is a choice to be made, verify choices with the user via Dialog.confirm.
// Otherwise, just notify them with Dialog.prompt
const dialogConfigData = {
title: `${module.title} ${game.i18n.localize("MODMANAGE.Dependencies")}`,
content: html
};
// If there are no dependencies, just inform the user about the upstream packages
if ( !impactedDependencies.size ) {
dialogConfigData.callback = deactivateUpstreamPackages;
dialogConfigData.rejectClose = false;
return Dialog.prompt(dialogConfigData);
}
// Otherwise, prompt the user to confirm the changes to impacted dependencies
return Dialog.confirm(foundry.utils.mergeObject(dialogConfigData, {
yes: (html) => {
const inputs = Array.from(html[0].querySelectorAll("input"));
for ( let d of dependencies ) {
const dep = input.form[d.id];
if ( dep ) dep.checked = inputs.find(i => i.name === d.id)?.checked ? input.checked : dep.checked;
}
deactivateUpstreamPackages();
},
no: () => {
input.checked = false;
deactivateUpstreamPackages();
},
}));
}
/* -------------------------------------------- */
/**
* @typedef {Object} ImpactedDependency
* @property {string} id The dependency ID
* @property {string} title The dependency title
* @property {string} reason The reason the dependency is related to this package
* @property {boolean} required Whether the dependency is required
* @property {string} note A note to display to the user
*/
/**
* Check whether a dependency is impacted by another package being enabled / disabled.
* @param {Map<string,ImpactedDependency>} impactedDependencies The map of impacted dependencies.
* @param {Package} pkg The dependency to check.
* @param {boolean} enabling Whether the dependency is being enabled or disabled.
* @param {HTMLInputElement} input The checkbox input for the dependency.
* @param {boolean} required Whether the dependency is required.
*/
#checkImpactedDependency(impactedDependencies, pkg, enabling,
input, required=true) {
if ( pkg.type !== "module" ) return null;
/** @type {ImpactedDependency} */
const impactedDependency = {
id: pkg.id,
reason: pkg.reason
};
if ( enabling ) {
impactedDependency.required = required;
impactedDependency.note = required ? game.i18n.localize("SETUP.RequiredPackageNote") :
game.i18n.localize("SETUP.RecommendedPackageNote")
}
const pack = game.modules.get(pkg.id);
if ( !pack ) {
if ( required ) ui.notifications.error(game.i18n.format("MODMANAGE.DepNotInstalled",
{missing: pkg.id}));
return null;
}
impactedDependency.title = pack.title;
if ( pack.active === input.checked ) return null;
if ( !enabling ) {
// Check if other modules depend on this dependency, and if so, remove it from the to-disable list.
const allPackages = Array.from(game.modules).concat([game.system, game.world]);
const requiredByOtherPackages = !!allPackages.find(a => {
if ( (a.type === "module") && !a.active ) return false;
if ( a.id === input.name ) return false;
if ( !a.relationships?.length ) return false;
const dependencies = Array.from(a.relationships.requires).concat(Array.from(a.relationships.recommends));
return dependencies.find(d => d.id === pkg.id);
});
if ( requiredByOtherPackages ) return null;
}
// Add the dependency to the list of impacted dependencies
impactedDependencies.set(pkg.id, impactedDependency);
// Recursively check downstream dependencies
const fullPackage = game.modules.get(pkg.id);
for ( const p of fullPackage.relationships.requires ) {
if ( impactedDependencies.has(p.id) ) continue;
this.#checkImpactedDependency(impactedDependencies, p, enabling, input, true);
}
for ( const p of fullPackage.relationships.recommends ) {
if ( impactedDependencies.has(p.id) ) continue;
this.#checkImpactedDependency(impactedDependencies, p, enabling, input, false);
}
}
/* -------------------------------------------- */
/**
* Recursively check if any upstream packages would become disabled if the module were disabled.
* @param {Package} pkg The dependency to check.
* @param {Set<Package>} upstreamPackages The current Set of detected upstream packages.
* @returns {Set<Package>} A Set of upstream packages.
*/
#checkUpstreamPackages(pkg, upstreamPackages=null) {
if ( !upstreamPackages ) upstreamPackages = new Set();
const packagesThatRequireThis = game.modules.filter(m => m.active && m.relationships.requires.some(r => r.id === pkg.id));
for ( let p of packagesThatRequireThis ) {
if ( upstreamPackages.has(p) ) continue;
upstreamPackages.add(p);
this.#checkUpstreamPackages(p, upstreamPackages);
}
return upstreamPackages;
}
/* -------------------------------------------- */
/**
* Indicate if any Documents would become unavailable if the module were disabled, and confirm if the user wishes to
* proceed.
* @param {Module} module The module being disabled.
* @returns {Promise<boolean>} A Promise which resolves to true if disabling should continue.
* @protected
*/
async _confirmDocumentsUnavailable(module) {
const counts = game.issues.getSubTypeCountsFor(module.id);
if ( !counts ) return true;
const confirm = await Dialog.confirm({
title: game.i18n.localize("MODMANAGE.UnavailableDocuments"),
content: `
<p>${game.i18n.localize("MODMANAGE.UnavailableDocumentsConfirm")}</p>
<hr>
<p>${this._formatDocumentSummary(counts)}</p>
`,
yes: () => true,
no: () => false
});
return !!confirm;
}
/* -------------------------------------------- */
/**
* Handle a button-click to deactivate all modules
* @private
*/
_onDeactivateAll(event) {
event.preventDefault();
for ( let input of this.element[0].querySelectorAll('input[type="checkbox"]') ) {
if ( !input.disabled ) input.checked = false;
}
}
/* -------------------------------------------- */
/**
* Handle expanding or collapsing the display of descriptive elements
* @private
*/
_onExpandCollapse(event) {
event?.preventDefault();
this._expanded = !this._expanded;
this.form.querySelectorAll(".package-description").forEach(pack =>
pack.classList.toggle("hidden", !this._expanded)
);
const icon = this.form.querySelector("i.fa");
icon.classList.toggle("fa-angle-double-down", this._expanded);
icon.classList.toggle("fa-angle-double-up", !this._expanded);
icon.parentElement.title = this._expanded ?
game.i18n.localize("Collapse") : game.i18n.localize("Expand");
}
/* -------------------------------------------- */
/**
* Handle switching the module list filter.
* @private
*/
_onFilterList(event) {
event.preventDefault();
this._filter = event.target.dataset.filter;
// Toggle the activity state of all filters.
this.form.querySelectorAll("a[data-filter]").forEach(a =>
a.classList.toggle("active", a.dataset.filter === this._filter));
// Iterate over modules and toggle their hidden states based on the chosen filter.
const settings = game.settings.get("core", this.constructor.CONFIG_SETTING);
const list = this.form.querySelector("#module-list");
for ( const li of list.children ) {
const name = li.dataset.moduleId;
const isActive = settings[name] === true;
const hidden = ((this._filter === "active") && !isActive) || ((this._filter === "inactive") && isActive);
li.classList.toggle("hidden", hidden);
}
// Re-apply any search filter query.
const searchFilter = this._searchFilters[0];
searchFilter.filter(null, searchFilter._input.value);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onSearchFilter(event, query, rgx, html) {
const settings = game.settings.get("core", this.constructor.CONFIG_SETTING);
for ( let li of html.children ) {
const name = li.dataset.moduleId;
const isActive = settings[name] === true;
if ( (this._filter === "active") && !isActive ) continue;
if ( (this._filter === "inactive") && isActive ) continue;
if ( !query ) {
li.classList.remove("hidden");
continue;
}
const title = (li.querySelector(".package-title")?.textContent || "").trim();
const author = (li.querySelector(".author")?.textContent || "").trim();
const match = rgx.test(SearchFilter.cleanQuery(name)) ||
rgx.test(SearchFilter.cleanQuery(title)) ||
rgx.test(SearchFilter.cleanQuery(author));
li.classList.toggle("hidden", !match);
}
}
/* -------------------------------------------- */
/**
* Format a document count collection for display.
* @param {ModuleSubTypeCounts} counts An object of sub-type counts.
* @param {boolean} isActive Whether the module is active.
* @protected
*/
_formatDocumentSummary(counts, isActive) {
return Object.entries(counts).map(([documentName, types]) => {
let total = 0;
const typesList = game.i18n.getListFormatter().format(Object.entries(types).map(([subType, count]) => {
total += count;
const label = game.i18n.localize(CONFIG[documentName].typeLabels?.[subType] ?? subType);
return `<strong>${count}</strong> ${label}`;
}));
const cls = getDocumentClass(documentName);
const label = total === 1 ? cls.metadata.label : cls.metadata.labelPlural;
if ( isActive ) return `${typesList} ${game.i18n.localize(label)}`;
return `<strong>${total}</strong> ${game.i18n.localize(label)}`;
}).join(" &bull; ");
}
}

View File

@@ -0,0 +1,128 @@
/**
* An application for configuring the permissions which are available to each User role.
*/
class PermissionConfig extends FormApplication {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("PERMISSION.Title"),
id: "permissions-config",
template: "templates/sidebar/apps/permission-config.html",
width: 660,
height: "auto",
scrollY: [".permissions-list"],
closeOnSubmit: true
});
}
/* -------------------------------------------- */
/** @override */
async getData(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._getPermissions(current)
};
}
/* -------------------------------------------- */
/**
* Prepare the permissions object used to render the configuration template
* @param {object} current The current permission configuration
* @returns {object[]} Permission data for sheet rendering
* @private
*/
_getPermissions(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));
// 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),
disabled: (role === rgm) && (!p.disableGM)
});
return arr;
}, []);
}
return perms;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find("button[name='reset']").click(this._onResetDefaults.bind(this));
}
/* -------------------------------------------- */
/**
* Handle button click to reset default settings
* @param {Event} event The initial button click event
* @private
*/
async _onResetDefaults(event) {
event.preventDefault();
// Collect default permissions.
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});
return this.render();
}
/* -------------------------------------------- */
/** @override */
async _onSubmit(event, options) {
event.target.querySelectorAll("input[disabled]").forEach(i => i.disabled = false);
return super._onSubmit(event, options);
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
const permissions = foundry.utils.expandObject(formData);
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});
}
}

View File

@@ -0,0 +1,241 @@
/**
* Support Info and Report
* @type {Application}
*/
class SupportDetails extends Application {
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.title = "SUPPORT.Title";
options.id = "support-details";
options.template = "templates/sidebar/apps/support-details.html";
options.width = 780;
options.height = 680;
options.resizable = true;
options.classes = ["sheet"];
options.tabs = [{navSelector: ".tabs", contentSelector: "article", initial: "support"}];
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
// Build report data
context.report = SupportDetails.generateSupportReport();
// Build document issues data.
context.documentIssues = this._getDocumentValidationErrors();
// Build module issues data.
context.moduleIssues = this._getModuleIssues();
// Build client issues data.
context.clientIssues = Object.values(game.issues.usabilityIssues).map(({message, severity, params}) => {
return {severity, message: params ? game.i18n.format(message, params) : game.i18n.localize(message)};
});
return context;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("button[data-action]").on("click", this._onClickAction.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force=false, options={}) {
await super._render(force, options);
if ( options.tab ) this._tabs[0].activate(options.tab);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(data) {
await loadTemplates({supportDetailsReport: "templates/sidebar/apps/parts/support-details-report.html"});
return super._renderInner(data);
}
/* -------------------------------------------- */
/**
* Handle a button click action.
* @param {MouseEvent} event The click event.
* @protected
*/
_onClickAction(event) {
const action = event.currentTarget.dataset.action;
switch ( action ) {
case "copy":
this._copyReport();
break;
}
}
/* -------------------------------------------- */
/**
* Copy the support details report to clipboard.
* @protected
*/
_copyReport() {
const report = document.getElementById("support-report");
game.clipboard.copyPlainText(report.innerText);
ui.notifications.info("SUPPORT.ReportCopied", {localize: true});
}
/* -------------------------------------------- */
/**
* Marshal information on Documents that failed validation and format it for display.
* @returns {object[]}
* @protected
*/
_getDocumentValidationErrors() {
const context = [];
for ( const [documentName, documents] of Object.entries(game.issues.validationFailures) ) {
const cls = getDocumentClass(documentName);
const label = game.i18n.localize(cls.metadata.labelPlural);
context.push({
label,
documents: Object.entries(documents).map(([id, {name, error}]) => {
return {name: name ?? id, validationError: error.asHTML()};
})
});
}
return context;
}
/* -------------------------------------------- */
/**
* Marshal package-related warnings and errors and format it for display.
* @returns {object[]}
* @protected
*/
_getModuleIssues() {
const errors = {label: game.i18n.localize("Errors"), issues: []};
const warnings = {label: game.i18n.localize("Warnings"), issues: []};
for ( const [moduleId, {error, warning}] of Object.entries(game.issues.packageCompatibilityIssues) ) {
const label = game.modules.get(moduleId)?.title ?? moduleId;
if ( error.length ) errors.issues.push({label, issues: error.map(message => ({severity: "error", message}))});
if ( warning.length ) warnings.issues.push({
label,
issues: warning.map(message => ({severity: "warning", message}))
});
}
const context = [];
if ( errors.issues.length ) context.push(errors);
if ( warnings.issues.length ) context.push(warnings);
return context;
}
/* -------------------------------------------- */
/**
* A bundle of metrics for Support
* @typedef {Object} SupportReportData
* @property {number} coreVersion
* @property {string} systemVersion
* @property {number} activeModuleCount
* @property {string} os
* @property {string} client
* @property {string} gpu
* @property {number|string} maxTextureSize
* @property {string} sceneDimensions
* @property {number} grid
* @property {float} padding
* @property {number} walls
* @property {number} lights
* @property {number} sounds
* @property {number} tiles
* @property {number} tokens
* @property {number} actors
* @property {number} items
* @property {number} journals
* @property {number} tables
* @property {number} playlists
* @property {number} packs
* @property {number} messages
*/
/**
* Collects a number of metrics that is useful for Support
* @returns {SupportReportData}
*/
static generateSupportReport() {
// Create a WebGL Context if necessary
let tempCanvas;
let gl = canvas.app?.renderer?.gl;
if ( !gl ) {
const tempCanvas = document.createElement("canvas");
if ( tempCanvas.getContext ) {
gl = tempCanvas.getContext("webgl2") || tempCanvas.getContext("webgl") || tempCanvas.getContext("experimental-webgl");
}
}
const rendererInfo = this.getWebGLRendererInfo(gl) ?? "Unknown Renderer";
// Build report data
const viewedScene = game.scenes.get(game.user.viewedScene);
/** @type {SupportReportData} **/
const report = {
coreVersion: `${game.release.display}, ${game.release.version}`,
systemVersion: `${game.system.id}, ${game.system.version}`,
activeModuleCount: Array.from(game.modules.values()).filter(x => x.active).length,
performanceMode: game.settings.get("core", "performanceMode"),
os: navigator.oscpu ?? "Unknown",
client: navigator.userAgent,
gpu: rendererInfo,
maxTextureSize: gl && gl.getParameter ? gl.getParameter(gl.MAX_TEXTURE_SIZE) : "Could not detect",
hasViewedScene: viewedScene,
packs: game.packs.size,
};
// Attach Document Collection counts
const reportCollections = [ "actors", "items", "journal", "tables", "playlists", "messages" ];
for ( let c of reportCollections ) {
const collection = game[c];
report[c] = `${collection.size}${collection.invalidDocumentIds.size > 0 ?
` (${collection.invalidDocumentIds.size} ${game.i18n.localize("Invalid")})` : ""}`;
}
if ( viewedScene ) {
report.sceneDimensions = `${viewedScene.dimensions.width} x ${viewedScene.dimensions.height}`;
report.grid = viewedScene.grid.size;
report.padding = viewedScene.padding;
report.walls = viewedScene.walls.size;
report.lights = viewedScene.lights.size;
report.sounds = viewedScene.sounds.size;
report.tiles = viewedScene.tiles.size;
report.tokens = viewedScene.tokens.size;
}
// Clean up temporary canvas
if ( tempCanvas ) tempCanvas.remove();
return report;
}
/* -------------------------------------------- */
/**
* Get a WebGL renderer information string
* @param {WebGLRenderingContext} gl The rendering context
* @returns {string} The unmasked renderer string
*/
static getWebGLRendererInfo(gl) {
if ( navigator.userAgent.match(/Firefox\/([0-9]+)\./) ) {
return gl.getParameter(gl.RENDERER);
} else {
return gl.getParameter(gl.getExtension("WEBGL_debug_renderer_info").UNMASKED_RENDERER_WEBGL);
}
}
}

View File

@@ -0,0 +1,133 @@
/**
* A management app for configuring which Tours are available or have been completed.
*/
class ToursManagement extends PackageConfiguration {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "tours-management",
title: game.i18n.localize("SETTINGS.Tours"),
categoryTemplate: "templates/sidebar/apps/tours-management-category.html"
});
}
/* -------------------------------------------- */
/** @override */
_prepareCategoryData() {
// Classify all Actions
let categories = new Map();
let total = 0;
for ( let tour of game.tours.values() ) {
if ( !tour.config.display || (tour.config.restricted && !game.user.isGM) ) continue;
total++;
// Determine what category the action belongs to
let category = this._categorizeEntry(tour.namespace);
// Convert Tour to render data
const tourData = {};
tourData.category = category.title;
tourData.id = `${tour.namespace}.${tour.id}`;
tourData.title = game.i18n.localize(tour.title);
tourData.description = game.i18n.localize(tour.description);
tourData.cssClass = tour.config.restricted ? "gm" : "";
tourData.notes = [
tour.config.restricted ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
tour.description
].filterJoin("<br>");
switch ( tour.status ) {
case Tour.STATUS.UNSTARTED: {
tourData.status = game.i18n.localize("TOURS.NotStarted");
tourData.canBePlayed = tour.canStart;
tourData.canBeReset = false;
tourData.startOrResume = game.i18n.localize("TOURS.Start");
break;
}
case Tour.STATUS.IN_PROGRESS: {
tourData.status = game.i18n.format("TOURS.InProgress", {
current: tour.stepIndex + 1,
total: tour.steps.length ?? 0
});
tourData.canBePlayed = tour.canStart;
tourData.canBeReset = true;
tourData.startOrResume = game.i18n.localize(`TOURS.${tour.config.canBeResumed ? "Resume" : "Restart"}`);
break;
}
case Tour.STATUS.COMPLETED: {
tourData.status = game.i18n.localize("TOURS.Completed");
tourData.canBeReset = true;
tourData.cssClass += " completed";
break;
}
}
// Register a category the first time it is seen, otherwise add to it
if ( !categories.has(category.id) ) {
categories.set(category.id, {
id: category.id,
title: category.title,
tours: [tourData],
count: 0
});
} else categories.get(category.id).tours.push(tourData);
}
// Sort Actions by priority and assign Counts
for ( let category of categories.values() ) {
category.count = category.tours.length;
}
categories = Array.from(categories.values()).sort(this._sortCategories.bind(this));
return {categories, total};
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find(".controls").on("click", ".control", this._onClickControl.bind(this));
}
/* -------------------------------------------- */
/** @override */
async _onResetDefaults(event) {
return Dialog.confirm({
title: game.i18n.localize("TOURS.ResetTitle"),
content: `<p>${game.i18n.localize("TOURS.ResetWarning")}</p>`,
yes: async () => {
await Promise.all(game.tours.contents.map(tour => tour.reset()));
ui.notifications.info("TOURS.ResetSuccess", {localize: true});
this.render(true);
},
no: () => {},
defaultYes: false
});
}
/* -------------------------------------------- */
/**
* Handle Control clicks
* @param {MouseEvent} event
* @private
*/
_onClickControl(event) {
const button = event.currentTarget;
const div = button.closest(".tour");
const tour = game.tours.get(div.dataset.tour);
switch ( button.dataset.action ) {
case "play":
this.close();
return tour.start();
case "reset": return tour.reset();
}
}
}

View File

@@ -0,0 +1,180 @@
/**
* @typedef {FormApplicationOptions} WorldConfigOptions
* @property {boolean} [create=false] Whether the world is being created or updated.
*/
/**
* The World Management setup application
* @param {World} object The world being configured.
* @param {WorldConfigOptions} [options] Application configuration options.
*/
class WorldConfig extends FormApplication {
/**
* @override
* @returns {WorldConfigOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "world-config",
template: "templates/setup/world-config.hbs",
width: 620,
height: "auto",
create: false
});
}
/**
* A semantic alias for the World object which is being configured by this form.
* @type {World}
*/
get world() {
return this.object;
}
/**
* The website knowledge base URL.
* @type {string}
* @private
*/
static #WORLD_KB_URL = "https://foundryvtt.com/article/game-worlds/";
/* -------------------------------------------- */
/** @override */
get title() {
return this.options.create ? game.i18n.localize("WORLD.TitleCreate")
: `${game.i18n.localize("WORLD.TitleEdit")}: ${this.world.title}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find('[name="title"]').on("input", this.#onTitleChange.bind(this));
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const ac = CONST.PACKAGE_AVAILABILITY_CODES;
const nextDate = new Date(this.world.nextSession || undefined);
const context = {
world: this.world,
isCreate: this.options.create,
submitText: game.i18n.localize(this.options.create ? "WORLD.TitleCreate" : "WORLD.SubmitEdit"),
nextDate: nextDate.isValid() ? nextDate.toDateInputString() : "",
nextTime: nextDate.isValid() ? nextDate.toTimeInputString() : "",
worldKbUrl: WorldConfig.#WORLD_KB_URL,
inWorld: !!game.world,
themes: CONST.WORLD_JOIN_THEMES
};
context.showEditFields = !context.isCreate && !context.inWorld;
if ( game.systems ) {
context.systems = game.systems.filter(system => {
if ( this.world.system === system.id ) return true;
return ( system.availability <= ac.UNVERIFIED_GENERATION );
}).sort((a, b) => a.title.localeCompare(b.title));
}
return context;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
_getSubmitData(updateData={}) {
const data = super._getSubmitData(updateData);
// Augment submission actions
if ( this.options.create ) {
data.action = "createWorld";
if ( !data.id.length ) data.id = data.title.slugify({strict: true});
}
else {
data.id = this.world.id;
if ( !data.resetKeys ) delete data.resetKeys;
if ( !data.safeMode ) delete data.safeMode;
}
// Handle next session schedule fields
if ( data.nextSession.some(t => !!t) ) {
const now = new Date();
const dateStr = `${data.nextSession[0] || now.toDateString()} ${data.nextSession[1] || now.toTimeString()}`;
const date = new Date(dateStr);
data.nextSession = isNaN(Number(date)) ? null : date.toISOString();
}
else data.nextSession = null;
if ( data.joinTheme === CONST.WORLD_JOIN_THEMES.default ) delete data.joinTheme;
return data;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
formData = foundry.utils.expandObject(formData);
const form = event.target || this.form;
form.disable = true;
// Validate the submission data
try {
this.world.validate({changes: formData, clean: true});
formData.action = this.options.create ? "createWorld" : "editWorld";
} catch(err) {
ui.notifications.error(err.message.replace("\n", ". "));
throw err;
}
// Dispatch the POST request
let response;
try {
response = await foundry.utils.fetchJsonWithTimeout(foundry.utils.getRoute("setup"), {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(formData)
});
form.disabled = false;
// Display error messages
if (response.error) return ui.notifications.error(response.error);
}
catch(e) {
return ui.notifications.error(e);
}
// Handle successful creation
if ( formData.action === "createWorld" ) {
const world = new this.world.constructor(response);
game.worlds.set(world.id, world);
}
else this.world.updateSource(response);
if ( ui.setup ) ui.setup.refresh(); // TODO old V10
if ( ui.setupPackages ) ui.setupPackages.render(); // New v11
}
/* -------------------------------------------- */
/**
* Update the world name placeholder when the title is changed.
* @param {Event} event The input change event
* @private
*/
#onTitleChange(event) {
let slug = this.form.elements.title.value.slugify({strict: true});
if ( !slug.length ) slug = "world-name";
this.form.elements.id?.setAttribute("placeholder", slug);
}
/* -------------------------------------------- */
/** @inheritDoc */
async activateEditor(name, options={}, initialContent="") {
const toolbar = CONFIG.TinyMCE.toolbar.split(" ").filter(t => t !== "save").join(" ");
foundry.utils.mergeObject(options, {toolbar});
return super.activateEditor(name, options, initialContent);
}
}

View File

@@ -0,0 +1,880 @@
/**
* @typedef {Object} DirectoryMixinEntry
* @property {string} id The unique id of the entry
* @property {Folder|string} folder The folder id or folder object to which this entry belongs
* @property {string} [img] An image path to display for the entry
* @property {string} [sort] A numeric sort value which orders this entry relative to others
* @interface
*/
/**
* Augment an Application instance with functionality that supports rendering as a directory of foldered entries.
* @param {typeof Application} Base The base Application class definition
* @returns {typeof DirectoryApplication} The decorated DirectoryApplication class definition
*/
function DirectoryApplicationMixin(Base) {
return class DirectoryApplication extends Base {
/**
* The path to the template partial which renders a single Entry within this directory
* @type {string}
*/
static entryPartial = "templates/sidebar/partials/entry-partial.html";
/**
* The path to the template partial which renders a single Folder within this directory
* @type {string}
*/
static folderPartial = "templates/sidebar/folder-partial.html";
/* -------------------------------------------- */
/**
* @inheritdoc
* @returns {DocumentDirectoryOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
renderUpdateKeys: ["name", "sort", "sorting", "folder"],
height: "auto",
scrollY: ["ol.directory-list"],
dragDrop: [{dragSelector: ".directory-item", dropSelector: ".directory-list"}],
filters: [{inputSelector: 'input[name="search"]', contentSelector: ".directory-list"}],
contextMenuSelector: ".directory-item.document",
entryClickSelector: ".entry-name"
});
}
/* -------------------------------------------- */
/**
* The type of Entry that is contained in this DirectoryTab.
* @type {string}
*/
get entryType() {
throw new Error("You must implement the entryType getter for this DirectoryTab");
}
/* -------------------------------------------- */
/**
* The maximum depth of folder nesting which is allowed in this DirectoryTab
* @returns {number}
*/
get maxFolderDepth() {
return this.collection.maxFolderDepth;
}
/* -------------------------------------------- */
/**
* Can the current User create new Entries in this DirectoryTab?
* @returns {boolean}
*/
get canCreateEntry() {
return game.user.isGM;
}
/* -------------------------------------------- */
/**
* Can the current User create new Folders in this DirectoryTab?
* @returns {boolean}
*/
get canCreateFolder() {
return this.canCreateEntry;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onSearchFilter(event, query, rgx, html) {
const isSearch = !!query;
let entryIds = new Set();
const folderIds = new Set();
const autoExpandFolderIds = new Set();
// Match entries and folders
if ( isSearch ) {
// Include folders and their parents, auto-expanding parent folders
const includeFolder = (folder, autoExpand = true) => {
if ( !folder ) return;
if ( typeof folder === "string" ) folder = this.collection.folders.get(folder);
if ( !folder ) return;
const folderId = folder._id;
if ( folderIds.has(folderId) ) {
// If this folder is not already auto-expanding, but it should be, add it to the set
if ( autoExpand && !autoExpandFolderIds.has(folderId) ) autoExpandFolderIds.add(folderId);
return;
}
folderIds.add(folderId);
if ( autoExpand ) autoExpandFolderIds.add(folderId);
if ( folder.folder ) includeFolder(folder.folder);
};
// First match folders
this._matchSearchFolders(rgx, includeFolder);
// Next match entries
this._matchSearchEntries(rgx, entryIds, folderIds, includeFolder);
}
// Toggle each directory item
for ( let el of html.querySelectorAll(".directory-item") ) {
if ( el.classList.contains("hidden") ) continue;
if ( el.classList.contains("folder") ) {
let match = isSearch && folderIds.has(el.dataset.folderId);
el.style.display = (!isSearch || match) ? "flex" : "none";
if ( autoExpandFolderIds.has(el.dataset.folderId) ) {
if ( isSearch && match ) el.classList.remove("collapsed");
}
else el.classList.toggle("collapsed", !game.folders._expanded[el.dataset.uuid]);
}
else el.style.display = (!isSearch || entryIds.has(el.dataset.entryId)) ? "flex" : "none";
}
}
/* -------------------------------------------- */
/**
* Identify folders in the collection which match a provided search query.
* This method is factored out to be extended by subclasses, for example to support compendium indices.
* @param {RegExp} query The search query
* @param {Function} includeFolder A callback function to include the folder of any matched entry
* @protected
*/
_matchSearchFolders(query, includeFolder) {
for ( const folder of this.collection.folders ) {
if ( query.test(SearchFilter.cleanQuery(folder.name)) ) {
includeFolder(folder, false);
}
}
}
/* -------------------------------------------- */
/**
* Identify entries in the collection which match a provided search query.
* This method is factored out to be extended by subclasses, for example to support compendium indices.
* @param {RegExp} query The search query
* @param {Set<string>} entryIds The set of matched Entry IDs
* @param {Set<string>} folderIds The set of matched Folder IDs
* @param {Function} includeFolder A callback function to include the folder of any matched entry
* @protected
*/
_matchSearchEntries(query, entryIds, folderIds, includeFolder) {
const nameOnlySearch = (this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME);
const entries = this.collection.index ?? this.collection.contents;
// Copy the folderIds to a new set so we can add to the original set without incorrectly adding child entries
const matchedFolderIds = new Set(folderIds);
for ( const entry of entries ) {
const entryId = this._getEntryId(entry);
// If we matched a folder, add its children entries
if ( matchedFolderIds.has(entry.folder?._id ?? entry.folder) ) entryIds.add(entryId);
// Otherwise, if we are searching by name, match the entry name
else if ( nameOnlySearch && query.test(SearchFilter.cleanQuery(this._getEntryName(entry))) ) {
entryIds.add(entryId);
includeFolder(entry.folder);
}
}
if ( nameOnlySearch ) return;
// Full Text Search
const matches = this.collection.search({query: query.source, exclude: Array.from(entryIds)});
for ( const match of matches ) {
if ( entryIds.has(match._id) ) continue;
entryIds.add(match._id);
includeFolder(match.folder);
}
}
/* -------------------------------------------- */
/**
* Get the name to search against for a given entry
* @param {Document|object} entry The entry to get the name for
* @returns {string} The name of the entry
* @protected
*/
_getEntryName(entry) {
return entry.name;
}
/* -------------------------------------------- */
/**
* Get the ID for a given entry
* @param {Document|object} entry The entry to get the id for
* @returns {string} The id of the entry
* @protected
*/
_getEntryId(entry) {
return entry._id;
}
/* -------------------------------------------- */
/** @inheritDoc */
async getData(options) {
const data = await super.getData(options);
return foundry.utils.mergeObject(data, {
tree: this.collection.tree,
entryPartial: this.#getEntryPartial(),
folderPartial: this.constructor.folderPartial,
canCreateEntry: this.canCreateEntry,
canCreateFolder: this.canCreateFolder,
sortIcon: this.collection.sortingMode === "a" ? "fa-arrow-down-a-z" : "fa-arrow-down-short-wide",
sortTooltip: this.collection.sortingMode === "a" ? "SIDEBAR.SortModeAlpha" : "SIDEBAR.SortModeManual",
searchIcon: this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "fa-search" :
"fa-file-magnifying-glass",
searchTooltip: this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "SIDEBAR.SearchModeName" :
"SIDEBAR.SearchModeFull"
});
}
/* -------------------------------------------- */
/** @inheritDoc */
async _render(force, options) {
await loadTemplates([this.#getEntryPartial(), this.constructor.folderPartial]);
return super._render(force, options);
}
/* -------------------------------------------- */
/**
* Retrieve the entry partial.
* @returns {string}
*/
#getEntryPartial() {
/**
* @deprecated since v11
*/
if ( this.constructor.documentPartial ) {
foundry.utils.logCompatibilityWarning("Your sidebar application defines the documentPartial static property"
+ " which is deprecated. Please use entryPartial instead.", {since: 11, until: 13});
return this.constructor.documentPartial;
}
return this.constructor.entryPartial;
}
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
const directory = html.find(".directory-list");
const entries = directory.find(".directory-item");
// Handle folder depth and collapsing
html.find(`[data-folder-depth="${this.maxFolderDepth}"] .create-folder`).remove();
html.find(".toggle-sort").click(this.#onToggleSort.bind(this));
html.find(".toggle-search-mode").click(this.#onToggleSearchMode.bind(this));
html.find(".collapse-all").click(this.collapseAll.bind(this));
// Intersection Observer
const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), { root: directory[0] });
entries.each((i, li) => observer.observe(li));
// Entry-level events
directory.on("click", this.options.entryClickSelector, this._onClickEntryName.bind(this));
directory.on("click", ".folder-header", this._toggleFolder.bind(this));
const dh = this._onDragHighlight.bind(this);
html.find(".folder").on("dragenter", dh).on("dragleave", dh);
this._contextMenu(html);
// Allow folder and entry creation
if ( this.canCreateFolder ) html.find(".create-folder").click(this._onCreateFolder.bind(this));
if ( this.canCreateEntry ) html.find(".create-entry").click(this._onCreateEntry.bind(this));
}
/* -------------------------------------------- */
/**
* Swap the sort mode between "a" (Alphabetical) and "m" (Manual by sort property)
* @param {PointerEvent} event The originating pointer event
*/
#onToggleSort(event) {
event.preventDefault();
this.collection.toggleSortingMode();
this.render();
}
/* -------------------------------------------- */
/**
* Swap the search mode between "name" and "full"
* @param {PointerEvent} event The originating pointer event
*/
#onToggleSearchMode(event) {
event.preventDefault();
this.collection.toggleSearchMode();
this.render();
}
/* -------------------------------------------- */
/**
* Collapse all subfolders in this directory
*/
collapseAll() {
this.element.find("li.folder").addClass("collapsed");
for ( let f of this.collection.folders ) {
game.folders._expanded[f.uuid] = false;
}
if ( this.popOut ) this.setPosition();
}
/* -------------------------------------------- */
/**
* Create a new Folder in this SidebarDirectory
* @param {PointerEvent} event The originating button click event
* @protected
*/
_onCreateFolder(event) {
event.preventDefault();
event.stopPropagation();
const button = event.currentTarget;
const li = button.closest(".directory-item");
const data = {folder: li?.dataset?.folderId || null, type: this.entryType};
const options = {top: button.offsetTop, left: window.innerWidth - 310 - FolderConfig.defaultOptions.width};
if ( this.collection instanceof CompendiumCollection ) options.pack = this.collection.collection;
Folder.createDialog(data, options);
}
/* -------------------------------------------- */
/**
* Handle toggling the collapsed or expanded state of a folder within the directory tab
* @param {PointerEvent} event The originating click event
* @protected
*/
_toggleFolder(event) {
let folder = $(event.currentTarget.parentElement);
let collapsed = folder.hasClass("collapsed");
const folderUuid = folder[0].dataset.uuid;
game.folders._expanded[folderUuid] = collapsed;
// Expand
if ( collapsed ) folder.removeClass("collapsed");
// Collapse
else {
folder.addClass("collapsed");
const subs = folder.find(".folder").addClass("collapsed");
subs.each((i, f) => game.folders._expanded[folderUuid] = false);
}
// Resize container
if ( this.popOut ) this.setPosition();
}
/* -------------------------------------------- */
/**
* Handle clicking on a Document name in the Sidebar directory
* @param {PointerEvent} event The originating click event
* @protected
*/
async _onClickEntryName(event) {
event.preventDefault();
const element = event.currentTarget;
const entryId = element.parentElement.dataset.entryId;
const entry = this.collection.get(entryId);
entry.sheet.render(true);
}
/* -------------------------------------------- */
/**
* Handle new Entry creation request
* @param {PointerEvent} event The originating button click event
* @protected
*/
async _onCreateEntry(event) {
throw new Error("You must implement the _onCreateEntry method");
}
/* -------------------------------------------- */
/** @override */
_onDragStart(event) {
if ( ui.context ) ui.context.close({animate: false});
const li = event.currentTarget.closest(".directory-item");
const isFolder = li.classList.contains("folder");
const dragData = isFolder
? this._getFolderDragData(li.dataset.folderId)
: this._getEntryDragData(li.dataset.entryId);
if ( !dragData ) return;
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
}
/* -------------------------------------------- */
/**
* Get the data transfer object for a Entry being dragged from this SidebarDirectory
* @param {string} entryId The Entry's _id being dragged
* @returns {Object}
* @private
*/
_getEntryDragData(entryId) {
const entry = this.collection.get(entryId);
return entry?.toDragData();
}
/* -------------------------------------------- */
/**
* Get the data transfer object for a Folder being dragged from this SidebarDirectory
* @param {string} folderId The Folder _id being dragged
* @returns {Object}
* @private
*/
_getFolderDragData(folderId) {
const folder = this.collection.folders.get(folderId);
if ( !folder ) return null;
return {
type: "Folder",
uuid: folder.uuid
};
}
/* -------------------------------------------- */
/** @override */
_canDragStart(selector) {
return true;
}
/* -------------------------------------------- */
/**
* Highlight folders as drop targets when a drag event enters or exits their area
* @param {DragEvent} event The DragEvent which is in progress
*/
_onDragHighlight(event) {
const li = event.currentTarget;
if ( !li.classList.contains("folder") ) return;
event.stopPropagation(); // Don't bubble to parent folders
// Remove existing drop targets
if ( event.type === "dragenter" ) {
for ( let t of li.closest(".directory-list").querySelectorAll(".droptarget") ) {
t.classList.remove("droptarget");
}
}
// Remove current drop target
if ( event.type === "dragleave" ) {
const el = document.elementFromPoint(event.clientX, event.clientY);
const parent = el.closest(".folder");
if ( parent === li ) return;
}
// Add new drop target
li.classList.toggle("droptarget", event.type === "dragenter");
}
/* -------------------------------------------- */
/** @override */
_onDrop(event) {
const data = TextEditor.getDragEventData(event);
if ( !data.type ) return;
const target = event.target.closest(".directory-item") || null;
switch ( data.type ) {
case "Folder":
return this._handleDroppedFolder(target, data);
case this.entryType:
return this._handleDroppedEntry(target, data);
}
}
/* -------------------------------------------- */
/**
* Handle Folder data being dropped into the directory.
* @param {HTMLElement} target The target element
* @param {object} data The data being dropped
* @protected
*/
async _handleDroppedFolder(target, data) {
// Determine the closest Folder
const closestFolder = target ? target.closest(".folder") : null;
if ( closestFolder ) closestFolder.classList.remove("droptarget");
const closestFolderId = closestFolder ? closestFolder.dataset.folderId : null;
// Obtain the dropped Folder
let folder = await fromUuid(data.uuid);
if ( !folder ) return;
if ( folder?.type !== this.entryType ) {
ui.notifications.warn(game.i18n.format("FOLDER.InvalidDocumentType", {type: this.collection.documentName}));
return;
}
// Sort into another Folder
const sortData = {sortKey: "sort", sortBefore: true};
const isRelative = target && target.dataset.folderId;
if ( isRelative ) {
const targetFolder = await fromUuid(target.dataset.uuid);
// Sort relative to a collapsed Folder
if ( target.classList.contains("collapsed") ) {
sortData.target = targetFolder;
sortData.parentId = targetFolder.folder?.id;
sortData.parentUuid = targetFolder.folder?.uuid;
}
// Drop into an expanded Folder
else {
sortData.target = null;
sortData.parentId = targetFolder.id;
sortData.parentUuid = targetFolder.uuid;
}
}
// Sort relative to existing Folder contents
else {
sortData.parentId = closestFolderId;
sortData.parentUuid = closestFolder?.dataset?.uuid;
sortData.target = closestFolder && closestFolder.classList.contains("collapsed") ? closestFolder : null;
}
if ( sortData.parentId ) {
const parentFolder = await fromUuid(sortData.parentUuid);
if ( parentFolder === folder ) return; // Prevent assigning a folder as its own parent.
if ( parentFolder.ancestors.includes(folder) ) return; // Prevent creating a cycle.
// Prevent going beyond max depth
const maxDepth = f => Math.max(f.depth, ...f.children.filter(n => n.folder).map(n => maxDepth(n.folder)));
if ( (parentFolder.depth + (maxDepth(folder) - folder.depth + 1)) > this.maxFolderDepth ) {
ui.notifications.error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: this.maxFolderDepth}), {console: false});
return;
}
}
// Determine siblings
sortData.siblings = this.collection.folders.filter(f => {
return (f.folder?.id === sortData.parentId) && (f.type === folder.type) && (f !== folder);
});
// Handle dropping of some folder that is foreign to this collection
if ( this.collection.folders.get(folder.id) !== folder ) {
const dropped = await this._handleDroppedForeignFolder(folder, closestFolderId, sortData);
if ( !dropped || !dropped.sortNeeded ) return;
folder = dropped.folder;
}
// Resort the collection
sortData.updateData = { folder: sortData.parentId };
return folder.sortRelative(sortData);
}
/* -------------------------------------------- */
/**
* Handle a new Folder being dropped into the directory.
* This case is not handled by default, but subclasses may implement custom handling here.
* @param {Folder} folder The Folder being dropped
* @param {string} closestFolderId The closest Folder _id to the drop target
* @param {object} sortData The sort data for the Folder
* @param {string} sortData.sortKey The sort key to use for sorting
* @param {boolean} sortData.sortBefore Sort before the target?
* @returns {Promise<{folder: Folder, sortNeeded: boolean}|null>} The handled folder creation, or null
* @protected
*/
async _handleDroppedForeignFolder(folder, closestFolderId, sortData) {
return null;
}
/* -------------------------------------------- */
/**
* Handle Entry data being dropped into the directory.
* @param {HTMLElement} target The target element
* @param {object} data The data being dropped
* @protected
*/
async _handleDroppedEntry(target, data) {
// Determine the closest Folder
const closestFolder = target ? target.closest(".folder") : null;
if ( closestFolder ) closestFolder.classList.remove("droptarget");
let folder = closestFolder ? await fromUuid(closestFolder.dataset.uuid) : null;
let entry = await this._getDroppedEntryFromData(data);
if ( !entry ) return;
// Sort relative to another Document
const collection = this.collection.index ?? this.collection;
const sortData = {sortKey: "sort"};
const isRelative = target && target.dataset.entryId;
if ( isRelative ) {
if ( entry.id === target.dataset.entryId ) return; // Don't drop on yourself
const targetDocument = collection.get(target.dataset.entryId);
sortData.target = targetDocument;
folder = targetDocument?.folder;
}
// Sort within to the closest Folder
else sortData.target = null;
// Determine siblings
if ( folder instanceof foundry.abstract.Document ) folder = folder.id;
sortData.siblings = collection.filter(d => !this._entryIsSelf(d, entry) && this._entryBelongsToFolder(d, folder));
if ( !this._entryAlreadyExists(entry) ) {
// Try to predetermine the sort order
const sorted = SortingHelpers.performIntegerSort(entry, sortData);
if ( sorted.length === 1 ) entry = entry.clone({sort: sorted[0].update[sortData.sortKey]}, {keepId: true});
entry = await this._createDroppedEntry(entry, folder);
// No need to resort other documents if the document was created with a specific sort order
if ( sorted.length === 1 ) return;
}
// Resort the collection
sortData.updateData = {folder: folder || null};
return this._sortRelative(entry, sortData);
}
/* -------------------------------------------- */
/**
* Determine if an Entry is being compared to itself
* @param {DirectoryMixinEntry} entry The Entry
* @param {DirectoryMixinEntry} otherEntry The other Entry
* @returns {boolean} Is the Entry being compared to itself?
* @protected
*/
_entryIsSelf(entry, otherEntry) {
return entry._id === otherEntry._id;
}
/* -------------------------------------------- */
/**
* Determine whether an Entry belongs to the target folder
* @param {DirectoryMixinEntry} entry The Entry
* @param {Folder} folder The target folder
* @returns {boolean} Is the Entry a sibling?
* @protected
*/
_entryBelongsToFolder(entry, folder) {
if ( !entry.folder && !folder ) return true;
if ( entry.folder instanceof foundry.abstract.Document ) return entry.folder.id === folder;
return entry.folder === folder;
}
/* -------------------------------------------- */
/**
* Check if an Entry is already present in the Collection
* @param {DirectoryMixinEntry} entry The Entry being dropped
* @returns {boolean} Is the Entry already present?
* @private
*/
_entryAlreadyExists(entry) {
return this.collection.get(entry.id) === entry;
}
/* -------------------------------------------- */
/**
* Get the dropped Entry from the drop data
* @param {object} data The data being dropped
* @returns {Promise<DirectoryMixinEntry>} The dropped Entry
* @protected
*/
async _getDroppedEntryFromData(data) {
throw new Error("The _getDroppedEntryFromData method must be implemented");
}
/* -------------------------------------------- */
/**
* Create a dropped Entry in this Collection
* @param {DirectoryMixinEntry} entry The Entry being dropped
* @param {string} [folderId] The ID of the Folder to which the Entry should be added
* @returns {Promise<DirectoryMixinEntry>} The created Entry
* @protected
*/
async _createDroppedEntry(entry, folderId) {
throw new Error("The _createDroppedEntry method must be implemented");
}
/* -------------------------------------------- */
/**
* Sort a relative entry within a collection
* @param {DirectoryMixinEntry} entry The entry to sort
* @param {object} sortData The sort data
* @param {string} sortData.sortKey The sort key to use for sorting
* @param {boolean} sortData.sortBefore Sort before the target?
* @param {object} sortData.updateData Additional data to update on the entry
* return {Promise<object>} The sorted entry
*/
async _sortRelative(entry, sortData) {
throw new Error("The _sortRelative method must be implemented");
}
/* -------------------------------------------- */
/** @inheritdoc */
_contextMenu(html) {
/**
* A hook event that fires when the context menu for folders in this DocumentDirectory is constructed.
* Substitute the class name in the hook event, for example "getActorDirectoryFolderContext".
* @function getSidebarTabFolderContext
* @memberof hookEvents
* @param {jQuery} html The HTML element to which the context options are attached
* @param {ContextMenuEntry[]} entryOptions The context menu entries
*/
ContextMenu.create(this, html, ".folder .folder-header", this._getFolderContextOptions(), {
hookName: "FolderContext"
});
ContextMenu.create(this, html, this.options.contextMenuSelector, this._getEntryContextOptions());
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be used for Folders in a SidebarDirectory
* @returns {object[]} The Array of context options passed to the ContextMenu instance
* @protected
*/
_getFolderContextOptions() {
return [
{
name: "FOLDER.Edit",
icon: '<i class="fas fa-edit"></i>',
condition: game.user.isGM,
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
const r = li.getBoundingClientRect();
const options = {top: r.top, left: r.left - FolderConfig.defaultOptions.width - 10};
new FolderConfig(folder, options).render(true);
}
},
{
name: "FOLDER.CreateTable",
icon: `<i class="${CONFIG.RollTable.sidebarIcon}"></i>`,
condition: header => {
const li = header.closest(".directory-item")[0];
const folder = fromUuidSync(li.dataset.uuid);
return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
},
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
return Dialog.confirm({
title: `${game.i18n.localize("FOLDER.CreateTable")}: ${folder.name}`,
content: game.i18n.localize("FOLDER.CreateTableConfirm"),
yes: () => RollTable.fromFolder(folder),
options: {
top: Math.min(li.offsetTop, window.innerHeight - 350),
left: window.innerWidth - 680,
width: 360
}
});
}
},
{
name: "FOLDER.Remove",
icon: '<i class="fas fa-trash"></i>',
condition: game.user.isGM,
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
return Dialog.confirm({
title: `${game.i18n.localize("FOLDER.Remove")} ${folder.name}`,
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("FOLDER.RemoveWarning")}</p>`,
yes: () => folder.delete({deleteSubfolders: false, deleteContents: false}),
options: {
top: Math.min(li.offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
}
});
}
},
{
name: "FOLDER.Delete",
icon: '<i class="fas fa-dumpster"></i>',
condition: game.user.isGM,
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
return Dialog.confirm({
title: `${game.i18n.localize("FOLDER.Delete")} ${folder.name}`,
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("FOLDER.DeleteWarning")}</p>`,
yes: () => folder.delete({deleteSubfolders: true, deleteContents: true}),
options: {
top: Math.min(li.offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
}
});
}
}
];
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be used for Entries in a SidebarDirectory
* @returns {object[]} The Array of context options passed to the ContextMenu instance
* @protected
*/
_getEntryContextOptions() {
return [
{
name: "FOLDER.Clear",
icon: '<i class="fas fa-folder"></i>',
condition: header => {
const li = header.closest(".directory-item");
const entry = this.collection.get(li.data("entryId"));
return game.user.isGM && !!entry.folder;
},
callback: header => {
const li = header.closest(".directory-item");
const entry = this.collection.get(li.data("entryId"));
entry.update({folder: null});
}
},
{
name: "SIDEBAR.Delete",
icon: '<i class="fas fa-trash"></i>',
condition: () => game.user.isGM,
callback: header => {
const li = header.closest(".directory-item");
const entry = this.collection.get(li.data("entryId"));
if ( !entry ) return;
return entry.deleteDialog({
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720
});
}
},
{
name: "SIDEBAR.Duplicate",
icon: '<i class="far fa-copy"></i>',
condition: () => game.user.isGM || this.collection.documentClass.canUserCreate(game.user),
callback: header => {
const li = header.closest(".directory-item");
const original = this.collection.get(li.data("entryId"));
return original.clone({name: `${original._source.name} (Copy)`}, {save: true});
}
}
];
}
};
}

View File

@@ -0,0 +1,561 @@
/**
* @typedef {ApplicationOptions} DocumentDirectoryOptions
* @property {string[]} [renderUpdateKeys] A list of data property keys that will trigger a rerender of the tab if
* they are updated on a Document that this tab is responsible for.
* @property {string} [contextMenuSelector] The CSS selector that activates the context menu for displayed Documents.
* @property {string} [entryClickSelector] The CSS selector for the clickable area of an entry in the tab.
*/
/**
* A shared pattern for the sidebar directory which Actors, Items, and Scenes all use
* @extends {SidebarTab}
* @abstract
* @interface
*
* @param {DocumentDirectoryOptions} [options] Application configuration options.
*/
class DocumentDirectory extends DirectoryApplicationMixin(SidebarTab) {
constructor(options={}) {
super(options);
/**
* References to the set of Documents which are displayed in the Sidebar
* @type {ClientDocument[]}
*/
this.documents = null;
/**
* Reference the set of Folders which exist in this Sidebar
* @type {Folder[]}
*/
this.folders = null;
// If a collection was provided, use it instead of the default
this.#collection = options.collection ?? this.constructor.collection;
// Initialize sidebar content
this.initialize();
// Record the directory as an application of the collection if it is not a popout
if ( !this.options.popOut ) this.collection.apps.push(this);
}
/* -------------------------------------------- */
/**
* A reference to the named Document type that this Sidebar Directory instance displays
* @type {string}
*/
static documentName = "Document";
/** @override */
static entryPartial = "templates/sidebar/partials/document-partial.html";
/** @override */
get entryType() {
return this.constructor.documentName;
}
/* -------------------------------------------- */
/**
* @override
* @returns {DocumentDirectoryOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/sidebar/document-directory.html",
renderUpdateKeys: ["name", "img", "thumb", "ownership", "sort", "sorting", "folder"]
});
}
/* -------------------------------------------- */
/** @inheritDoc */
get title() {
const cls = getDocumentClass(this.constructor.documentName);
return `${game.i18n.localize(cls.metadata.labelPlural)} Directory`;
}
/* -------------------------------------------- */
/** @inheritDoc */
get id() {
const cls = getDocumentClass(this.constructor.documentName);
const pack = cls.metadata.collection;
return `${pack}${this._original ? "-popout" : ""}`;
}
/* -------------------------------------------- */
/** @inheritDoc */
get tabName() {
const cls = getDocumentClass(this.constructor.documentName);
return cls.metadata.collection;
}
/* -------------------------------------------- */
/**
* The WorldCollection instance which this Sidebar Directory displays.
* @type {WorldCollection}
*/
static get collection() {
return game.collections.get(this.documentName);
}
/* -------------------------------------------- */
/**
* The collection of Documents which are displayed in this Sidebar Directory
* @type {DocumentCollection}
*/
get collection() {
return this.#collection;
}
/* -------------------------------------------- */
/**
* A per-instance reference to a collection of documents which are displayed in this Sidebar Directory. If set, supersedes the World Collection
* @private
*/
#collection;
/* -------------------------------------------- */
/* Initialization Helpers */
/* -------------------------------------------- */
/**
* Initialize the content of the directory by categorizing folders and documents into a hierarchical tree structure.
*/
initialize() {
// Assign Folders
this.folders = this.collection.folders.contents;
// Assign Documents
this.documents = this.collection.filter(e => e.visible);
// Build Tree
this.collection.initializeTree();
}
/* -------------------------------------------- */
/* Application Rendering
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, context={}) {
// Only re-render the sidebar directory for certain types of updates
const {action, data, documentType} = context;
if ( action && !["create", "update", "delete"].includes(action) ) return this;
if ( (documentType !== "Folder") && (action === "update") && !data.some(d => {
return this.options.renderUpdateKeys.some(k => k in d);
}) ) return;
// Re-build the tree and render
this.initialize();
return super._render(force, context);
}
/* -------------------------------------------- */
/** @override */
get canCreateEntry() {
const cls = getDocumentClass(this.constructor.documentName);
return cls.canUserCreate(game.user);
}
/* -------------------------------------------- */
/** @override */
get canCreateFolder() {
return this.canCreateEntry;
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const context = await super.getData(options);
const cfg = CONFIG[this.collection.documentName];
const cls = cfg.documentClass;
return foundry.utils.mergeObject(context, {
documentCls: cls.documentName.toLowerCase(),
tabName: cls.metadata.collection,
sidebarIcon: cfg.sidebarIcon,
folderIcon: CONFIG.Folder.sidebarIcon,
label: game.i18n.localize(cls.metadata.label),
labelPlural: game.i18n.localize(cls.metadata.labelPlural),
unavailable: game.user.isGM ? cfg.collection?.instance?.invalidDocumentIds?.size : 0
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".show-issues").on("click", () => new SupportDetails().render(true, {tab: "documents"}));
}
/* -------------------------------------------- */
/** @override */
async _onClickEntryName(event) {
event.preventDefault();
const element = event.currentTarget;
const documentId = element.parentElement.dataset.documentId;
const document = this.collection.get(documentId) ?? await this.collection.getDocument(documentId);
document.sheet.render(true);
}
/* -------------------------------------------- */
/** @override */
async _onCreateEntry(event, { _skipDeprecated=false }={}) {
/**
* @deprecated since v11
*/
if ( (this._onCreateDocument !== DocumentDirectory.prototype._onCreateDocument) && !_skipDeprecated ) {
foundry.utils.logCompatibilityWarning("DocumentDirectory#_onCreateDocument is deprecated. "
+ "Please use DocumentDirectory#_onCreateEntry instead.", {since: 11, until: 13});
return this._onCreateDocument(event);
}
event.preventDefault();
event.stopPropagation();
const button = event.currentTarget;
const li = button.closest(".directory-item");
const data = {folder: li?.dataset?.folderId};
const options = {width: 320, left: window.innerWidth - 630, top: button.offsetTop };
if ( this.collection instanceof CompendiumCollection ) options.pack = this.collection.collection;
const cls = getDocumentClass(this.collection.documentName);
return cls.createDialog(data, options);
}
/* -------------------------------------------- */
/** @override */
_onDrop(event) {
const data = TextEditor.getDragEventData(event);
if ( !data.type ) return;
const target = event.target.closest(".directory-item") || null;
// Call the drop handler
switch ( data.type ) {
case "Folder":
return this._handleDroppedFolder(target, data);
case this.collection.documentName:
return this._handleDroppedEntry(target, data);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _handleDroppedEntry(target, data, { _skipDeprecated=false }={}) {
/**
* @deprecated since v11
*/
if ( (this._handleDroppedDocument !== DocumentDirectory.prototype._handleDroppedDocument) && !_skipDeprecated ) {
foundry.utils.logCompatibilityWarning("DocumentDirectory#_handleDroppedDocument is deprecated. "
+ "Please use DocumentDirectory#_handleDroppedEntry instead.", {since: 11, until: 13});
return this._handleDroppedDocument(target, data);
}
return super._handleDroppedEntry(target, data);
}
/* -------------------------------------------- */
/** @override */
async _getDroppedEntryFromData(data) {
const cls = this.collection.documentClass;
return cls.fromDropData(data);
}
/* -------------------------------------------- */
/** @override */
async _sortRelative(entry, sortData) {
return entry.sortRelative(sortData);
}
/* -------------------------------------------- */
/** @override */
async _createDroppedEntry(document, folderId) {
const data = document.toObject();
data.folder = folderId || null;
return document.constructor.create(data, {fromCompendium: !!document.compendium });
}
/* -------------------------------------------- */
/** @override */
async _handleDroppedForeignFolder(folder, closestFolderId, sortData) {
const createdFolders = await this._createDroppedFolderContent(folder, this.collection.folders.get(closestFolderId));
if ( createdFolders.length ) folder = createdFolders[0];
return {
sortNeeded: true,
folder: folder
};
}
/* -------------------------------------------- */
/**
* Create a dropped Folder and its children in this Collection, if they do not already exist
* @param {Folder} folder The Folder being dropped
* @param {Folder} targetFolder The Folder to which the Folder should be added
* @returns {Promise<Array<Folder>>} The created Folders
* @protected
*/
async _createDroppedFolderContent(folder, targetFolder) {
const {foldersToCreate, documentsToCreate} = await this._organizeDroppedFoldersAndDocuments(folder, targetFolder);
// Create Folders
let createdFolders;
try {
createdFolders = await Folder.createDocuments(foldersToCreate, {
pack: this.collection.collection,
keepId: true
});
}
catch (err) {
ui.notifications.error(err.message);
throw err;
}
// Create Documents
await this._createDroppedFolderDocuments(folder, documentsToCreate);
return createdFolders;
}
/* -------------------------------------------- */
/**
* Organize a dropped Folder and its children into a list of folders to create and documents to create
* @param {Folder} folder The Folder being dropped
* @param {Folder} targetFolder The Folder to which the Folder should be added
* @returns {Promise<{foldersToCreate: Array<Folder>, documentsToCreate: Array<Document>}>}
* @private
*/
async _organizeDroppedFoldersAndDocuments(folder, targetFolder) {
let foldersToCreate = [];
let documentsToCreate = [];
let exceededMaxDepth = false;
const addFolder = (folder, currentDepth) => {
if ( !folder ) return;
// If the Folder does not already exist, add it to the list of folders to create
if ( this.collection.folders.get(folder.id) !== folder ) {
const createData = folder.toObject();
if ( targetFolder ) {
createData.folder = targetFolder.id;
targetFolder = undefined;
}
if ( currentDepth > this.maxFolderDepth ) {
exceededMaxDepth = true;
return;
}
createData.pack = this.collection.collection;
foldersToCreate.push(createData);
}
// If the Folder has documents, check those as well
if ( folder.contents?.length ) {
for ( const document of folder.contents ) {
const createData = document.toObject ? document.toObject() : foundry.utils.deepClone(document);
documentsToCreate.push(createData);
}
}
// Recursively check child folders
for ( const child of folder.children ) {
addFolder(child.folder, currentDepth + 1);
}
};
const currentDepth = (targetFolder?.ancestors.length ?? 0) + 1;
addFolder(folder, currentDepth);
if ( exceededMaxDepth ) {
ui.notifications.error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: this.maxFolderDepth}), {console: false});
foldersToCreate.length = documentsToCreate.length = 0;
}
return {foldersToCreate, documentsToCreate};
}
/* -------------------------------------------- */
/**
* Create a list of documents in a dropped Folder
* @param {Folder} folder The Folder being dropped
* @param {Array<Document>} documentsToCreate The documents to create
* @returns {Promise<void>}
* @protected
*/
async _createDroppedFolderDocuments(folder, documentsToCreate) {
if ( folder.pack ) {
const pack = game.packs.get(folder.pack);
if ( pack ) {
const ids = documentsToCreate.map(d => d._id);
documentsToCreate = await pack.getDocuments({_id__in: ids});
}
}
try {
await this.collection.documentClass.createDocuments(documentsToCreate, {
pack: this.collection.collection,
keepId: true
});
}
catch (err) {
ui.notifications.error(err.message);
throw err;
}
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be used for Folders in a SidebarDirectory
* @returns {object[]} The Array of context options passed to the ContextMenu instance
* @protected
*/
_getFolderContextOptions() {
const options = super._getFolderContextOptions();
return options.concat([
{
name: "OWNERSHIP.Configure",
icon: '<i class="fas fa-lock"></i>',
condition: () => game.user.isGM,
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
new DocumentOwnershipConfig(folder, {
top: Math.min(li.offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720
}).render(true);
}
},
{
name: "FOLDER.Export",
icon: '<i class="fas fa-atlas"></i>',
condition: header => {
const folder = fromUuidSync(header.parent().data("uuid"));
return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
},
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
return folder.exportDialog(null, {
top: Math.min(li.offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
});
}
}
]);
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be used for Documents in a SidebarDirectory
* @returns {object[]} The Array of context options passed to the ContextMenu instance
* @protected
*/
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
return [
{
name: "OWNERSHIP.Configure",
icon: '<i class="fas fa-lock"></i>',
condition: () => game.user.isGM,
callback: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
new DocumentOwnershipConfig(document, {
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720
}).render(true);
}
},
{
name: "SIDEBAR.Export",
icon: '<i class="fas fa-file-export"></i>',
condition: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
return document.isOwner;
},
callback: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
return document.exportToJSON();
}
},
{
name: "SIDEBAR.Import",
icon: '<i class="fas fa-file-import"></i>',
condition: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
return document.isOwner;
},
callback: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
return document.importFromJSONDialog();
}
}
].concat(options);
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async _onCreateDocument(event) {
foundry.utils.logCompatibilityWarning("DocumentDirectory#_onCreateDocument is deprecated. "
+ "Please use DocumentDirectory#_onCreateEntry instead.", {since: 11, until: 13});
return this._onCreateEntry(event, { _skipDeprecated: true });
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async _handleDroppedDocument(target, data) {
foundry.utils.logCompatibilityWarning("DocumentDirectory#_handleDroppedDocument is deprecated. "
+ "Please use DocumentDirectory#_handleDroppedEntry instead.", {since: 11, until: 13});
return this._handleDroppedEntry(target, data, { _skipDeprecated: true });
}
}
/**
* @deprecated since v11
*/
Object.defineProperty(globalThis, "SidebarDirectory", {
get() {
foundry.utils.logCompatibilityWarning("SidebarDirectory has been deprecated. Please use DocumentDirectory instead.",
{since: 11, until: 13});
return DocumentDirectory;
}
});

View File

@@ -0,0 +1,171 @@
/**
* An application for configuring data across all installed and active packages.
*/
class PackageConfiguration extends FormApplication {
static get categoryOrder() {
return ["all", "core", "system", "module", "unmapped"];
}
/**
* The name of the currently active tab.
* @type {string}
*/
get activeCategory() {
return this._tabs[0].active;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["package-configuration"],
template: "templates/sidebar/apps/package-configuration.html",
categoryTemplate: undefined,
width: 780,
height: 680,
resizable: true,
scrollY: [".filters", ".categories"],
tabs: [{navSelector: ".tabs", contentSelector: "form .scrollable", initial: "all"}],
filters: [{inputSelector: 'input[name="filter"]', contentSelector: ".categories"}],
submitButton: false
});
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const data = this._prepareCategoryData();
data.categoryTemplate = this.options.categoryTemplate;
data.submitButton = this.options.submitButton;
return data;
}
/* -------------------------------------------- */
/**
* Prepare the structure of category data which is rendered in this configuration form.
* @abstract
* @protected
*/
_prepareCategoryData() {
return {categories: [], total: 0};
}
/* -------------------------------------------- */
/**
* Classify what Category an Action belongs to
* @param {string} namespace The entry to classify
* @returns {{id: string, title: string}} The category the entry belongs to
* @protected
*/
_categorizeEntry(namespace) {
if ( namespace === "core" ) return {
id: "core",
title: game.i18n.localize("PACKAGECONFIG.Core")
};
else if ( namespace === game.system.id ) return {
id: "system",
title: game.system.title
};
else {
const module = game.modules.get(namespace);
if ( module ) return {
id: module.id,
title: module.title
};
return {
id: "unmapped",
title: game.i18n.localize("PACKAGECONFIG.Unmapped")
};
}
}
/* -------------------------------------------- */
/**
* Reusable logic for how categories are sorted in relation to each other.
* @param {object} a
* @param {object} b
* @protected
*/
_sortCategories(a, b) {
const categories = this.constructor.categoryOrder;
let ia = categories.indexOf(a.id);
if ( ia === -1 ) ia = categories.length - 2; // Modules second from last
let ib = this.constructor.categoryOrder.indexOf(b.id);
if ( ib === -1 ) ib = categories.length - 2; // Modules second from last
return (ia - ib) || a.title.localeCompare(b.title);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _render(force, {activeCategory, ...options}={}) {
await loadTemplates([this.options.categoryTemplate]);
await super._render(force, options);
if ( activeCategory ) this._tabs[0].activate(activeCategory);
const activeTab = this._tabs[0]?.active;
if ( activeTab ) this.element[0].querySelector(`.tabs [data-tab="${activeTab}"]`)?.scrollIntoView();
}
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
if ( this.activeCategory === "all" ) {
this._tabs[0]._content.querySelectorAll(".tab").forEach(tab => tab.classList.add("active"));
}
html.find("button.reset-all").click(this._onResetDefaults.bind(this));
html.find("input[name=filter]").focus();
}
/* -------------------------------------------- */
/** @override */
_onChangeTab(event, tabs, active) {
if ( active === "all" ) {
tabs._content.querySelectorAll(".tab").forEach(tab => tab.classList.add("active"));
}
}
/* -------------------------------------------- */
/** @override */
_onSearchFilter(event, query, rgx, html) {
const visibleCategories = new Set();
// Hide entries
for ( const entry of html.querySelectorAll(".form-group") ) {
if ( !query ) {
entry.classList.remove("hidden");
continue;
}
const label = entry.querySelector("label")?.textContent;
const notes = entry.querySelector(".notes")?.textContent;
const match = (label && rgx.test(SearchFilter.cleanQuery(label)))
|| (notes && rgx.test(SearchFilter.cleanQuery(notes)));
entry.classList.toggle("hidden", !match);
if ( match ) visibleCategories.add(entry.parentElement.dataset.category);
}
// Hide categories which have no visible children
for ( const category of html.querySelectorAll(".category") ) {
category.classList.toggle("hidden", query && !visibleCategories.has(category.dataset.category));
}
}
/* -------------------------------------------- */
/**
* Handle button click to reset default settings
* @param {Event} event The initial button click event
* @abstract
* @protected
*/
_onResetDefaults(event) {}
}

View File

@@ -0,0 +1,171 @@
/**
* An abstract pattern followed by the different tabs of the sidebar
* @abstract
* @interface
*/
class SidebarTab extends Application {
constructor(...args) {
super(...args);
/**
* A reference to the pop-out variant of this SidebarTab, if one exists
* @type {SidebarTab}
* @protected
*/
this._popout = null;
/**
* Denote whether this is the original version of the sidebar tab, or a pop-out variant
* @type {SidebarTab}
*/
this._original = null;
// Adjust options
if ( this.options.popOut ) this.options.classes.push("sidebar-popout");
this.options.classes.push(`${this.tabName}-sidebar`);
// Register the tab as the sidebar singleton
if ( !this.popOut && ui.sidebar ) ui.sidebar.tabs[this.tabName] = this;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: null,
popOut: false,
width: 300,
height: "auto",
classes: ["tab", "sidebar-tab"],
baseApplication: "SidebarTab"
});
}
/* -------------------------------------------- */
/** @override */
get id() {
return `${this.options.id}${this._original ? "-popout" : ""}`;
}
/* -------------------------------------------- */
/**
* The base name of this sidebar tab
* @type {string}
*/
get tabName() {
return this.constructor.defaultOptions.id ?? this.id;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
return {
cssId: this.id,
cssClass: this.options.classes.join(" "),
tabName: this.tabName,
user: game.user
};
}
/* -------------------------------------------- */
/** @override */
async _render(force=false, options={}) {
await super._render(force, options);
if ( this._popout ) await this._popout._render(force, options);
}
/* -------------------------------------------- */
/** @override */
async _renderInner(data) {
let html = await super._renderInner(data);
if ( ui.sidebar?.activeTab === this.id ) html.addClass("active");
if ( this.popOut ) html.removeClass("tab");
return html;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Activate this SidebarTab, switching focus to it
*/
activate() {
ui.sidebar.activateTab(this.tabName);
}
/* -------------------------------------------- */
/** @override */
async close(options) {
if ( this.popOut ) {
const base = this._original;
if ( base ) base._popout = null;
return super.close(options);
}
}
/* -------------------------------------------- */
/**
* Create a second instance of this SidebarTab class which represents a singleton popped-out container
* @returns {SidebarTab} The popped out sidebar tab instance
*/
createPopout() {
if ( this._popout ) return this._popout;
const pop = new this.constructor({popOut: true});
this._popout = pop;
pop._original = this;
return pop;
}
/* -------------------------------------------- */
/**
* Render the SidebarTab as a pop-out container
*/
renderPopout() {
const pop = this.createPopout();
pop.render(true);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/**
* Handle lazy loading for sidebar images to only load them once they become observed
* @param {HTMLElement[]} entries The entries which are now observed
* @param {IntersectionObserver} observer The intersection observer instance
*/
_onLazyLoadImage(entries, observer) {
for ( let e of entries ) {
if ( !e.isIntersecting ) continue;
const li = e.target;
// Background Image
if ( li.dataset.backgroundImage ) {
li.style["background-image"] = `url("${li.dataset.backgroundImage}")`;
delete li.dataset.backgroundImage;
}
// Avatar image
const img = li.querySelector("img");
if ( img && img.dataset.src ) {
img.src = img.dataset.src;
delete img.dataset.src;
}
// No longer observe the target
observer.unobserve(e.target);
}
}
}

View File

@@ -0,0 +1,273 @@
/**
* Render the Sidebar container, and after rendering insert Sidebar tabs.
*/
class Sidebar extends Application {
/**
* Singleton application instances for each sidebar tab
* @type {Object<SidebarTab>}
*/
tabs = {};
/**
* Track whether the sidebar container is currently collapsed
* @type {boolean}
*/
_collapsed = false;
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "sidebar",
template: "templates/sidebar/sidebar.html",
popOut: false,
width: 300,
tabs: [{navSelector: ".tabs", contentSelector: "#sidebar", initial: "chat"}]
});
}
/* -------------------------------------------- */
/**
* Return the name of the active Sidebar tab
* @type {string}
*/
get activeTab() {
return this._tabs[0].active;
}
/* -------------------------------------------- */
/**
* Singleton application instances for each popout tab
* @type {Object<SidebarTab>}
*/
get popouts() {
const popouts = {};
for ( let [name, app] of Object.entries(this.tabs) ) {
if ( app._popout ) popouts[name] = app._popout;
}
return popouts;
}
/* -------------------------------------------- */
/* Rendering
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const isGM = game.user.isGM;
// Configure tabs
const tabs = {
chat: {
tooltip: ChatMessage.metadata.labelPlural,
icon: CONFIG.ChatMessage.sidebarIcon,
notification: "<i id=\"chat-notification\" class=\"notification-pip fas fa-exclamation-circle\"></i>"
},
combat: {
tooltip: Combat.metadata.labelPlural,
icon: CONFIG.Combat.sidebarIcon
},
scenes: {
tooltip: Scene.metadata.labelPlural,
icon: CONFIG.Scene.sidebarIcon
},
actors: {
tooltip: Actor.metadata.labelPlural,
icon: CONFIG.Actor.sidebarIcon
},
items: {
tooltip: Item.metadata.labelPlural,
icon: CONFIG.Item.sidebarIcon
},
journal: {
tooltip: "SIDEBAR.TabJournal",
icon: CONFIG.JournalEntry.sidebarIcon
},
tables: {
tooltip: RollTable.metadata.labelPlural,
icon: CONFIG.RollTable.sidebarIcon
},
cards: {
tooltip: Cards.metadata.labelPlural,
icon: CONFIG.Cards.sidebarIcon
},
playlists: {
tooltip: Playlist.metadata.labelPlural,
icon: CONFIG.Playlist.sidebarIcon
},
compendium: {
tooltip: "SIDEBAR.TabCompendium",
icon: "fas fa-atlas"
},
settings: {
tooltip: "SIDEBAR.TabSettings",
icon: "fas fa-cogs"
}
};
if ( !isGM ) delete tabs.scenes;
// Display core or system update notification?
if ( isGM && (game.data.coreUpdate.hasUpdate || game.data.systemUpdate.hasUpdate) ) {
tabs.settings.notification = `<i class="notification-pip fas fa-exclamation-circle"></i>`;
}
return {tabs};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
// Render the Sidebar container only once
if ( !this.rendered ) await super._render(force, options);
// Render sidebar Applications
const renders = [];
for ( let [name, app] of Object.entries(this.tabs) ) {
renders.push(app._render(true).catch(err => {
Hooks.onError("Sidebar#_render", err, {
msg: `Failed to render Sidebar tab ${name}`,
log: "error",
name
});
}));
}
Promise.all(renders).then(() => this.activateTab(this.activeTab));
}
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/**
* Expand the Sidebar container from a collapsed state.
* Take no action if the sidebar is already expanded.
*/
expand() {
if ( !this._collapsed ) return;
const sidebar = this.element;
const tab = sidebar.find(".sidebar-tab.active");
const tabs = sidebar.find("#sidebar-tabs");
const icon = tabs.find("a.collapse i");
// Animate the sidebar expansion
tab.hide();
sidebar.animate({width: this.options.width, height: this.position.height}, 150, () => {
sidebar.css({width: "", height: ""}); // Revert to default styling
sidebar.removeClass("collapsed");
tabs[0].dataset.tooltipDirection = TooltipManager.TOOLTIP_DIRECTIONS.DOWN;
tab.fadeIn(250, () => {
tab.css({
display: "",
height: ""
});
});
icon.removeClass("fa-caret-left").addClass("fa-caret-right");
this._collapsed = false;
Hooks.callAll("collapseSidebar", this, this._collapsed);
});
}
/* -------------------------------------------- */
/**
* Collapse the sidebar to a minimized state.
* Take no action if the sidebar is already collapsed.
*/
collapse() {
if ( this._collapsed ) return;
const sidebar = this.element;
const tab = sidebar.find(".sidebar-tab.active");
const tabs = sidebar.find("#sidebar-tabs");
const icon = tabs.find("a.collapse i");
// Animate the sidebar collapse
tab.fadeOut(250, () => {
sidebar.animate({width: 32, height: (32 + 4) * (Object.values(this.tabs).length + 1)}, 150, () => {
sidebar.css("height", ""); // Revert to default styling
sidebar.addClass("collapsed");
tabs[0].dataset.tooltipDirection = TooltipManager.TOOLTIP_DIRECTIONS.LEFT;
tab.css("display", "");
icon.removeClass("fa-caret-right").addClass("fa-caret-left");
this._collapsed = true;
Hooks.callAll("collapseSidebar", this, this._collapsed);
});
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// Right click pop-out
const nav = this._tabs[0]._nav;
nav.addEventListener("contextmenu", this._onRightClickTab.bind(this));
// Toggle Collapse
const collapse = nav.querySelector(".collapse");
collapse.addEventListener("click", this._onToggleCollapse.bind(this));
// Left click a tab
const tabs = nav.querySelectorAll(".item");
tabs.forEach(tab => tab.addEventListener("click", this._onLeftClickTab.bind(this)));
}
/* -------------------------------------------- */
/** @override */
_onChangeTab(event, tabs, active) {
const app = ui[active];
if ( (active === "chat") && app ) app.scrollBottom();
Hooks.callAll("changeSidebarTab", app);
}
/* -------------------------------------------- */
/**
* Handle the special case of left-clicking a tab when the sidebar is collapsed.
* @param {MouseEvent} event The originating click event
* @private
*/
_onLeftClickTab(event) {
const app = ui[event.currentTarget.dataset.tab];
if ( app && this._collapsed ) app.renderPopout(app);
}
/* -------------------------------------------- */
/**
* Handle right-click events on tab controls to trigger pop-out containers for each tab
* @param {Event} event The originating contextmenu event
* @private
*/
_onRightClickTab(event) {
const li = event.target.closest(".item");
if ( !li ) return;
event.preventDefault();
const tabApp = ui[li.dataset.tab];
tabApp.renderPopout(tabApp);
}
/* -------------------------------------------- */
/**
* Handle toggling of the Sidebar container's collapsed or expanded state
* @param {Event} event
* @private
*/
_onToggleCollapse(event) {
event.preventDefault();
if ( this._collapsed ) this.expand();
else this.collapse();
}
}

View File

@@ -0,0 +1,94 @@
/**
* The sidebar directory which organizes and displays world-level Actor documents.
*/
class ActorDirectory extends DocumentDirectory {
constructor(...args) {
super(...args);
this._dragDrop[0].permissions.dragstart = () => game.user.can("TOKEN_CREATE");
this._dragDrop[0].permissions.drop = () => game.user.can("ACTOR_CREATE");
}
/* -------------------------------------------- */
/** @override */
static documentName = "Actor";
/* -------------------------------------------- */
/** @override */
_canDragStart(selector) {
return game.user.can("TOKEN_CREATE");
}
/* -------------------------------------------- */
/** @override */
_onDragStart(event) {
const li = event.currentTarget.closest(".directory-item");
let actor = null;
if ( li.dataset.documentId ) {
actor = game.actors.get(li.dataset.documentId);
if ( !actor || !actor.visible ) return false;
}
// Parent directory drag start handling
super._onDragStart(event);
// Create the drag preview for the Token
if ( actor && canvas.ready ) {
const img = li.querySelector("img");
const pt = actor.prototypeToken;
const w = pt.width * canvas.dimensions.size * Math.abs(pt.texture.scaleX) * canvas.stage.scale.x;
const h = pt.height * canvas.dimensions.size * Math.abs(pt.texture.scaleY) * canvas.stage.scale.y;
const preview = DragDrop.createDragImage(img, w, h);
event.dataTransfer.setDragImage(preview, w / 2, h / 2);
}
}
/* -------------------------------------------- */
/** @override */
_canDragDrop(selector) {
return game.user.can("ACTOR_CREATE");
}
/* -------------------------------------------- */
/** @override */
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
return [
{
name: "SIDEBAR.CharArt",
icon: '<i class="fas fa-image"></i>',
condition: li => {
const actor = game.actors.get(li.data("documentId"));
return actor.img !== CONST.DEFAULT_TOKEN;
},
callback: li => {
const actor = game.actors.get(li.data("documentId"));
new ImagePopout(actor.img, {
title: actor.name,
uuid: actor.uuid
}).render(true);
}
},
{
name: "SIDEBAR.TokenArt",
icon: '<i class="fas fa-image"></i>',
condition: li => {
const actor = game.actors.get(li.data("documentId"));
if ( actor.prototypeToken.randomImg ) return false;
return ![null, undefined, CONST.DEFAULT_TOKEN].includes(actor.prototypeToken.texture.src);
},
callback: li => {
const actor = game.actors.get(li.data("documentId"));
new ImagePopout(actor.prototypeToken.texture.src, {
title: actor.name,
uuid: actor.uuid
}).render(true);
}
}
].concat(options);
}
}

View File

@@ -0,0 +1,21 @@
/**
* The sidebar directory which organizes and displays world-level Cards documents.
* @extends {DocumentDirectory}
*/
class CardsDirectory extends DocumentDirectory {
/** @override */
static documentName = "Cards";
/** @inheritDoc */
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
const duplicate = options.find(o => o.name === "SIDEBAR.Duplicate");
duplicate.condition = li => {
if ( !game.user.isGM ) return false;
const cards = this.constructor.collection.get(li.data("documentId"));
return cards.canClone;
};
return options;
}
}

View File

@@ -0,0 +1,934 @@
/**
* @typedef {ApplicationOptions} ChatLogOptions
* @property {boolean} [stream] Is this chat log being rendered as part of the stream view?
*/
/**
* The sidebar directory which organizes and displays world-level ChatMessage documents.
* @extends {SidebarTab}
* @see {Sidebar}
* @param {ChatLogOptions} [options] Application configuration options.
*/
class ChatLog extends SidebarTab {
constructor(options) {
super(options);
/**
* Track any pending text which the user has submitted in the chat log textarea
* @type {string}
* @private
*/
this._pendingText = "";
/**
* Track the history of the past 5 sent messages which can be accessed using the arrow keys
* @type {object[]}
* @private
*/
this._sentMessages = [];
/**
* Track which remembered message is being currently displayed to cycle properly
* @type {number}
* @private
*/
this._sentMessageIndex = -1;
/**
* Track the time when the last message was sent to avoid flooding notifications
* @type {number}
* @private
*/
this._lastMessageTime = 0;
/**
* Track the id of the last message displayed in the log
* @type {string|null}
* @private
*/
this._lastId = null;
/**
* Track the last received message which included the user as a whisper recipient.
* @type {ChatMessage|null}
* @private
*/
this._lastWhisper = null;
/**
* A reference to the chat text entry bound key method
* @type {Function|null}
* @private
*/
this._onChatKeyDownBinding = null;
// Update timestamps every 15 seconds
setInterval(this.updateTimestamps.bind(this), 1000 * 15);
}
/**
* A flag for whether the chat log is currently scrolled to the bottom
* @type {boolean}
*/
#isAtBottom = true;
/**
* A cache of the Jump to Bottom element
*/
#jumpToBottomElement;
/* -------------------------------------------- */
/**
* Returns if the chat log is currently scrolled to the bottom
* @returns {boolean}
*/
get isAtBottom() {
return this.#isAtBottom;
}
/* -------------------------------------------- */
/**
* @override
* @returns {ChatLogOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "chat",
template: "templates/sidebar/chat-log.html",
title: game.i18n.localize("CHAT.Title"),
stream: false,
scrollY: ["#chat-log"]
});
}
/* -------------------------------------------- */
/**
* An enumeration of regular expression patterns used to match chat messages.
* @enum {RegExp}
*/
static MESSAGE_PATTERNS = (() => {
const dice = "([^#]+)(?:#(.*))?"; // Dice expression with appended flavor text
const any = "([^]*)"; // Any character, including new lines
return {
roll: new RegExp(`^(\\/r(?:oll)? )${dice}$`, "i"), // Regular rolls: /r or /roll
gmroll: new RegExp(`^(\\/gmr(?:oll)? )${dice}$`, "i"), // GM rolls: /gmr or /gmroll
blindroll: new RegExp(`^(\\/b(?:lind)?r(?:oll)? )${dice}$`, "i"), // Blind rolls: /br or /blindroll
selfroll: new RegExp(`^(\\/s(?:elf)?r(?:oll)? )${dice}$`, "i"), // Self rolls: /sr or /selfroll
publicroll: new RegExp(`^(\\/p(?:ublic)?r(?:oll)? )${dice}$`, "i"), // Public rolls: /pr or /publicroll
ic: new RegExp(`^(/ic )${any}`, "i"),
ooc: new RegExp(`^(/ooc )${any}`, "i"),
emote: new RegExp(`^(/(?:em(?:ote)?|me) )${any}`, "i"),
whisper: new RegExp(/^(\/w(?:hisper)?\s)(\[(?:[^\]]+)\]|(?:[^\s]+))\s*([^]*)/, "i"),
reply: new RegExp(`^(/reply )${any}`, "i"),
gm: new RegExp(`^(/gm )${any}`, "i"),
players: new RegExp(`^(/players )${any}`, "i"),
macro: new RegExp(`^(\\/m(?:acro)? )${any}`, "i"),
invalid: /^(\/[^\s]+)/ // Any other message starting with a slash command is invalid
};
})();
/* -------------------------------------------- */
/**
* The set of commands that can be processed over multiple lines.
* @type {Set<string>}
*/
static MULTILINE_COMMANDS = new Set(["roll", "gmroll", "blindroll", "selfroll", "publicroll"]);
/* -------------------------------------------- */
/**
* A reference to the Messages collection that the chat log displays
* @type {Messages}
*/
get collection() {
return game.messages;
}
/* -------------------------------------------- */
/* Application Rendering */
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const context = await super.getData(options);
return foundry.utils.mergeObject(context, {
rollMode: game.settings.get("core", "rollMode"),
rollModes: CONFIG.Dice.rollModes,
isStream: !!this.options.stream
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
if ( this.rendered ) return; // Never re-render the Chat Log itself, only its contents
await super._render(force, options);
return this.scrollBottom({waitImages: true});
}
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(data) {
const html = await super._renderInner(data);
await this._renderBatch(html, CONFIG.ChatMessage.batchSize);
return html;
}
/* -------------------------------------------- */
/**
* Render a batch of additional messages, prepending them to the top of the log
* @param {jQuery} html The rendered jQuery HTML object
* @param {number} size The batch size to include
* @returns {Promise<void>}
* @private
*/
async _renderBatch(html, size) {
const messages = game.messages.contents;
const log = html.find("#chat-log, #chat-log-popout");
// Get the index of the last rendered message
let lastIdx = messages.findIndex(m => m.id === this._lastId);
lastIdx = lastIdx !== -1 ? lastIdx : messages.length;
// Get the next batch to render
let targetIdx = Math.max(lastIdx - size, 0);
let m = null;
if ( lastIdx !== 0 ) {
let html = [];
for ( let i=targetIdx; i<lastIdx; i++) {
m = messages[i];
if (!m.visible) continue;
m.logged = true;
try {
html.push(await m.getHTML());
} catch(err) {
err.message = `Chat message ${m.id} failed to render: ${err})`;
console.error(err);
}
}
// Prepend the HTML
log.prepend(html);
this._lastId = messages[targetIdx].id;
}
}
/* -------------------------------------------- */
/* Chat Sidebar Methods */
/* -------------------------------------------- */
/**
* Delete a single message from the chat log
* @param {string} messageId The ChatMessage document to remove from the log
* @param {boolean} [deleteAll] Is this part of a flush operation to delete all messages?
*/
deleteMessage(messageId, {deleteAll=false}={}) {
// Get the chat message being removed from the log
const message = game.messages.get(messageId, {strict: false});
if ( message ) message.logged = false;
// Get the current HTML element for the message
let li = this.element.find(`.message[data-message-id="${messageId}"]`);
if ( !li.length ) return;
// Update the last index
if ( deleteAll ) {
this._lastId = null;
} else if ( messageId === this._lastId ) {
const next = li[0].nextElementSibling;
this._lastId = next ? next.dataset.messageId : null;
}
// Remove the deleted message
li.slideUp(100, () => li.remove());
// Delete from popout tab
if ( this._popout ) this._popout.deleteMessage(messageId, {deleteAll});
if ( this.popOut ) this.setPosition();
}
/* -------------------------------------------- */
/**
* Trigger a notification that alerts the user visually and audibly that a new chat log message has been posted
* @param {ChatMessage} message The message generating a notification
*/
notify(message) {
this._lastMessageTime = Date.now();
if ( !this.rendered ) return;
// Display the chat notification icon and remove it 3 seconds later
let icon = $("#chat-notification");
if ( icon.is(":hidden") ) icon.fadeIn(100);
setTimeout(() => {
if ( (Date.now() - this._lastMessageTime > 3000) && icon.is(":visible") ) icon.fadeOut(100);
}, 3001);
// Play a notification sound effect
if ( message.sound ) AudioHelper.play({src: message.sound});
}
/* -------------------------------------------- */
/**
* Parse a chat string to identify the chat command (if any) which was used
* @param {string} message The message to match
* @returns {string[]} The identified command and regex match
*/
static parse(message) {
for ( const [rule, rgx] of Object.entries(this.MESSAGE_PATTERNS) ) {
// For multi-line matches, the first line must match
if ( this.MULTILINE_COMMANDS.has(rule) ) {
const lines = message.split("\n");
if ( rgx.test(lines[0]) ) return [rule, lines.map(l => l.match(rgx))];
}
// For single-line matches, match directly
else {
const match = message.match(rgx);
if ( match ) return [rule, match];
}
}
return ["none", [message, "", message]];
}
/* -------------------------------------------- */
/**
* Post a single chat message to the log
* @param {ChatMessage} message A ChatMessage document instance to post to the log
* @param {object} [options={}] Additional options for how the message is posted to the log
* @param {string} [options.before] An existing message ID to append the message before, by default the new message is
* appended to the end of the log.
* @param {boolean} [options.notify] Trigger a notification which shows the log as having a new unread message.
* @returns {Promise<void>} A Promise which resolves once the message is posted
*/
async postOne(message, {before, notify=false}={}) {
if ( !message.visible ) return;
message.logged = true;
// Track internal flags
if ( !this._lastId ) this._lastId = message.id; // Ensure that new messages don't result in batched scrolling
if ( (message.whisper || []).includes(game.user.id) && !message.isRoll ) {
this._lastWhisper = message;
}
// Render the message to the log
const html = await message.getHTML();
const log = this.element.find("#chat-log");
// Append the message after some other one
const existing = before ? this.element.find(`.message[data-message-id="${before}"]`) : [];
if ( existing.length ) existing.before(html);
// Otherwise, append the message to the bottom of the log
else {
log.append(html);
if ( this.isAtBottom || (message.user._id === game.user._id) ) this.scrollBottom({waitImages: true});
}
// Post notification
if ( notify ) this.notify(message);
// Update popout tab
if ( this._popout ) await this._popout.postOne(message, {before, notify: false});
if ( this.popOut ) this.setPosition();
}
/* -------------------------------------------- */
/**
* Scroll the chat log to the bottom
* @param {object} [options]
* @param {boolean} [options.popout=false] If a popout exists, scroll it to the bottom too.
* @param {boolean} [options.waitImages=false] Wait for any images embedded in the chat log to load first
* before scrolling?
* @param {ScrollIntoViewOptions} [options.scrollOptions] Options to configure scrolling behaviour.
*/
async scrollBottom({popout=false, waitImages=false, scrollOptions={}}={}) {
if ( !this.rendered ) return;
if ( waitImages ) await this._waitForImages();
const log = this.element[0].querySelector("#chat-log");
log.lastElementChild?.scrollIntoView(scrollOptions);
if ( popout ) this._popout?.scrollBottom({waitImages, scrollOptions});
}
/* -------------------------------------------- */
/**
* Update the content of a previously posted message after its data has been replaced
* @param {ChatMessage} message The ChatMessage instance to update
* @param {boolean} notify Trigger a notification which shows the log as having a new unread message
*/
async updateMessage(message, notify=false) {
let li = this.element.find(`.message[data-message-id="${message.id}"]`);
if ( li.length ) {
const html = await message.getHTML();
li.replaceWith(html);
}
// Add a newly visible message to the log
else {
const messages = game.messages.contents;
const messageIndex = messages.findIndex(m => m === message);
let nextMessage;
for ( let i = messageIndex + 1; i < messages.length; i++ ) {
if ( messages[i].visible ) {
nextMessage = messages[i];
break;
}
}
await this.postOne(message, {before: nextMessage?.id, notify: false});
}
// Post notification of update
if ( notify ) this.notify(message);
// Update popout tab
if ( this._popout ) await this._popout.updateMessage(message, false);
if ( this.popOut ) this.setPosition();
}
/* -------------------------------------------- */
/**
* Update the displayed timestamps for every displayed message in the chat log.
* Timestamps are displayed in a humanized "timesince" format.
*/
updateTimestamps() {
const messages = this.element.find("#chat-log .message");
for ( let li of messages ) {
const message = game.messages.get(li.dataset.messageId);
if ( !message?.timestamp ) return;
const stamp = li.querySelector(".message-timestamp");
stamp.textContent = foundry.utils.timeSince(message.timestamp);
}
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
// Load new messages on scroll
html.find("#chat-log").scroll(this._onScrollLog.bind(this));
// Chat message entry
this._onChatKeyDownBinding = this._onChatKeyDown.bind(this);
html.find("#chat-message").keydown(this._onChatKeyDownBinding);
// Expand dice roll tooltips
html.on("click", ".dice-roll", this._onDiceRollClick.bind(this));
// Modify Roll Type
html.find('select[name="rollMode"]').change(this._onChangeRollMode.bind(this));
// Single Message Delete
html.on("click", "a.message-delete", this._onDeleteMessage.bind(this));
// Flush log
html.find("a.chat-flush").click(this._onFlushLog.bind(this));
// Export log
html.find("a.export-log").click(this._onExportLog.bind(this));
// Jump to Bottom
html.find(".jump-to-bottom > a").click(() => this.scrollBottom());
// Content Link Dragging
html[0].addEventListener("drop", ChatLog._onDropTextAreaData);
// Chat Entry context menu
this._contextMenu(html);
}
/* -------------------------------------------- */
/**
* Handle dropping of transferred data onto the chat editor
* @param {DragEvent} event The originating drop event which triggered the data transfer
* @private
*/
static async _onDropTextAreaData(event) {
event.preventDefault();
const textarea = event.target;
// Drop cross-linked content
const eventData = TextEditor.getDragEventData(event);
const link = await TextEditor.getContentLink(eventData);
if ( link ) textarea.value += link;
// Record pending text
this._pendingText = textarea.value;
}
/* -------------------------------------------- */
/**
* Prepare the data object of chat message data depending on the type of message being posted
* @param {string} message The original string of the message content
* @returns {Promise<Object|void>} The prepared chat data object, or void if we were executing a macro instead
*/
async processMessage(message) {
message = message.trim();
if ( !message ) return;
const cls = ChatMessage.implementation;
// Set up basic chat data
const chatData = {
user: game.user.id,
speaker: cls.getSpeaker()
};
if ( Hooks.call("chatMessage", this, message, chatData) === false ) return;
// Parse the message to determine the matching handler
let [command, match] = this.constructor.parse(message);
// Special handlers for no command
if ( command === "invalid" ) throw new Error(game.i18n.format("CHAT.InvalidCommand", {command: match[1]}));
else if ( command === "none" ) command = chatData.speaker.token ? "ic" : "ooc";
// Process message data based on the identified command type
const createOptions = {};
switch (command) {
case "roll": case "gmroll": case "blindroll": case "selfroll": case "publicroll":
await this._processDiceCommand(command, match, chatData, createOptions);
break;
case "whisper": case "reply": case "gm": case "players":
this._processWhisperCommand(command, match, chatData, createOptions);
break;
case "ic": case "emote": case "ooc":
this._processChatCommand(command, match, chatData, createOptions);
break;
case "macro":
this._processMacroCommand(command, match);
return;
}
// Create the message using provided data and options
return cls.create(chatData, createOptions);
}
/* -------------------------------------------- */
/**
* Process messages which are posted using a dice-roll command
* @param {string} command The chat command type
* @param {RegExpMatchArray[]} matches Multi-line matched roll expressions
* @param {Object} chatData The initial chat data
* @param {Object} createOptions Options used to create the message
* @private
*/
async _processDiceCommand(command, matches, chatData, createOptions) {
const actor = ChatMessage.getSpeakerActor(chatData.speaker) || game.user.character;
const rollData = actor ? actor.getRollData() : {};
const rolls = [];
for ( const match of matches ) {
if ( !match ) continue;
const [formula, flavor] = match.slice(2, 4);
if ( flavor && !chatData.flavor ) chatData.flavor = flavor;
const roll = Roll.create(formula, rollData);
await roll.evaluate({async: true});
rolls.push(roll);
}
chatData.type = CONST.CHAT_MESSAGE_TYPES.ROLL;
chatData.rolls = rolls;
chatData.sound = CONFIG.sounds.dice;
chatData.content = rolls.reduce((t, r) => t + r.total, 0);
createOptions.rollMode = command;
}
/* -------------------------------------------- */
/**
* Process messages which are posted using a chat whisper command
* @param {string} command The chat command type
* @param {RegExpMatchArray} match The matched RegExp expressions
* @param {Object} chatData The initial chat data
* @param {Object} createOptions Options used to create the message
* @private
*/
_processWhisperCommand(command, match, chatData, createOptions) {
// Prepare whisper data
chatData.type = CONST.CHAT_MESSAGE_TYPES.WHISPER;
delete chatData.speaker;
// Determine the recipient users
let users = [];
let message= "";
switch(command) {
case "whisper":
message = match[3];
const names = match[2].replace(/[\[\]]/g, "").split(",").map(n => n.trim());
users = names.reduce((arr, n) => arr.concat(ChatMessage.getWhisperRecipients(n)), []);
break;
case "reply":
message = match[2];
const w = this._lastWhisper;
if ( w ) {
const group = new Set(w.data.whisper);
group.add(w.data.user);
group.delete(game.user.id);
users = Array.from(group).map(id => game.users.get(id));
}
break;
case "gm":
message = match[2];
users = ChatMessage.getWhisperRecipients("gm");
break;
case "players":
message = match[2];
users = ChatMessage.getWhisperRecipients("players");
break;
}
// Add line break elements
message = message.replace(/\n/g, "<br>");
// Ensure we have valid whisper targets
if ( !users.length ) throw new Error(game.i18n.localize("ERROR.NoTargetUsersForWhisper"));
if ( users.some(u => !u.isGM) && !game.user.can("MESSAGE_WHISPER") ) {
throw new Error(game.i18n.localize("ERROR.CantWhisper"));
}
// Update chat data
chatData.whisper = users.map(u => u.id);
chatData.content = message;
chatData.sound = CONFIG.sounds.notification;
}
/* -------------------------------------------- */
/**
* Process messages which are posted using a chat whisper command
* @param {string} command The chat command type
* @param {RegExpMatchArray} match The matched RegExp expressions
* @param {Object} chatData The initial chat data
* @param {Object} createOptions Options used to create the message
* @private
*/
_processChatCommand(command, match, chatData, createOptions) {
if ( ["ic", "emote"].includes(command) && !(chatData.speaker.actor || chatData.speaker.token) ) {
throw new Error("You cannot chat in-character without an identified speaker");
}
chatData.content = match[2].replace(/\n/g, "<br>");
// Augment chat data
if ( command === "ic" ) {
chatData.type = CONST.CHAT_MESSAGE_TYPES.IC;
createOptions.chatBubble = true;
} else if ( command === "emote" ) {
chatData.type = CONST.CHAT_MESSAGE_TYPES.EMOTE;
chatData.content = `${chatData.speaker.alias} ${chatData.content}`;
createOptions.chatBubble = true;
}
else {
chatData.type = CONST.CHAT_MESSAGE_TYPES.OOC;
delete chatData.speaker;
}
}
/* -------------------------------------------- */
/**
* Process messages which execute a macro.
* @param {string} command The chat command typed.
* @param {RegExpMatchArray} match The RegExp matches.
* @private
*/
_processMacroCommand(command, match) {
// Parse the macro command with the form /macro {macroName} [param1=val1] [param2=val2] ...
let [macroName, ...params] = match[2].split(" ");
let expandName = true;
const scope = {};
for ( const p of params ) {
const kv = p.split("=");
if ( kv.length === 2 ) {
scope[kv[0]] = kv[1];
expandName = false;
}
else if ( expandName ) macroName += ` ${p}`; // Macro names may contain spaces
}
macroName = macroName.trimEnd(); // Eliminate trailing spaces
// Get the target macro by number or by name
let macro;
if ( Number.isNumeric(macroName) ) {
const macroID = game.user.hotbar[macroName];
macro = game.macros.get(macroID);
}
if ( !macro ) macro = game.macros.getName(macroName);
if ( !macro ) throw new Error(`Requested Macro "${macroName}" was not found as a named macro or hotbar position`);
// Execute the Macro with provided scope
return macro.execute(scope);
}
/* -------------------------------------------- */
/**
* Add a sent message to an array of remembered messages to be re-sent if the user pages up with the up arrow key
* @param {string} message The message text being remembered
* @private
*/
_remember(message) {
if ( this._sentMessages.length === 5 ) this._sentMessages.splice(4, 1);
this._sentMessages.unshift(message);
this._sentMessageIndex = -1;
}
/* -------------------------------------------- */
/**
* Recall a previously sent message by incrementing up (1) or down (-1) through the sent messages array
* @param {number} direction The direction to recall, positive for older, negative for more recent
* @return {string} The recalled message, or an empty string
* @private
*/
_recall(direction) {
if ( this._sentMessages.length > 0 ) {
let idx = this._sentMessageIndex + direction;
this._sentMessageIndex = Math.clamped(idx, -1, this._sentMessages.length-1);
}
return this._sentMessages[this._sentMessageIndex] || "";
}
/* -------------------------------------------- */
/** @inheritdoc */
_contextMenu(html) {
ContextMenu.create(this, html, ".message", this._getEntryContextOptions());
}
/* -------------------------------------------- */
/**
* Get the ChatLog entry context options
* @return {object[]} The ChatLog entry context options
* @private
*/
_getEntryContextOptions() {
return [
{
name: "CHAT.PopoutMessage",
icon: '<i class="fas fa-external-link-alt fa-rotate-180"></i>',
condition: li => {
const message = game.messages.get(li.data("messageId"));
return message.getFlag("core", "canPopout") === true;
},
callback: li => {
const message = game.messages.get(li.data("messageId"));
new ChatPopout(message).render(true);
}
},
{
name: "CHAT.RevealMessage",
icon: '<i class="fas fa-eye"></i>',
condition: li => {
const message = game.messages.get(li.data("messageId"));
const isLimited = message.whisper.length || message.blind;
return isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
},
callback: li => {
const message = game.messages.get(li.data("messageId"));
return message.update({whisper: [], blind: false});
}
},
{
name: "CHAT.ConcealMessage",
icon: '<i class="fas fa-eye-slash"></i>',
condition: li => {
const message = game.messages.get(li.data("messageId"));
const isLimited = message.whisper.length || message.blind;
return !isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
},
callback: li => {
const message = game.messages.get(li.data("messageId"));
return message.update({whisper: ChatMessage.getWhisperRecipients("gm").map(u => u.id), blind: false});
}
},
{
name: "SIDEBAR.Delete",
icon: '<i class="fas fa-trash"></i>',
condition: li => {
const message = game.messages.get(li.data("messageId"));
return message.canUserModify(game.user, "delete");
},
callback: li => {
const message = game.messages.get(li.data("messageId"));
return message.delete();
}
}
];
}
/* -------------------------------------------- */
/**
* Handle keydown events in the chat entry textarea
* @param {KeyboardEvent} event
* @private
*/
_onChatKeyDown(event) {
const code = event.code;
const textarea = event.currentTarget;
if ( event.originalEvent.isComposing ) return; // Ignore IME composition
// UP/DOWN ARROW -> Recall Previous Messages
const isArrow = ["ArrowUp", "ArrowDown"].includes(code);
if ( isArrow ) {
if ( this._pendingText ) return;
event.preventDefault();
textarea.value = this._recall(code === "ArrowUp" ? 1 : -1);
return;
}
// ENTER -> Send Message
const isEnter = ( (code === "Enter") || (code === "NumpadEnter") ) && !event.shiftKey;
if ( isEnter ) {
event.preventDefault();
const message = textarea.value;
if (!message) return;
this._pendingText = "";
// Prepare chat message data and handle result
return this.processMessage(message).then(() => {
textarea.value = "";
this._remember(message);
}).catch(error => {
ui.notifications.error(error);
throw error;
});
}
// BACKSPACE -> Remove pending text
if ( event.key === "Backspace" ) {
this._pendingText = this._pendingText.slice(0, -1);
return
}
// Otherwise, record that there is pending text
this._pendingText = textarea.value + (event.key.length === 1 ? event.key : "");
}
/* -------------------------------------------- */
/**
* Handle setting the preferred roll mode
* @param {Event} event
* @private
*/
_onChangeRollMode(event) {
event.preventDefault();
game.settings.set("core", "rollMode", event.target.value);
}
/* -------------------------------------------- */
/**
* Handle single message deletion workflow
* @param {Event} event
* @private
*/
_onDeleteMessage(event) {
event.preventDefault();
const li = event.currentTarget.closest(".message");
const messageId = li.dataset.messageId;
const message = game.messages.get(messageId);
return message ? message.delete() : this.deleteMessage(messageId);
}
/* -------------------------------------------- */
/**
* Handle clicking of dice tooltip buttons
* @param {Event} event
* @private
*/
_onDiceRollClick(event) {
event.preventDefault();
// Toggle the message flag
let roll = event.currentTarget;
const message = game.messages.get(roll.closest(".message").dataset.messageId);
message._rollExpanded = !message._rollExpanded;
// Expand or collapse tooltips
const tooltips = roll.querySelectorAll(".dice-tooltip");
for ( let tip of tooltips ) {
if ( message._rollExpanded ) $(tip).slideDown(200);
else $(tip).slideUp(200);
tip.classList.toggle("expanded", message._rollExpanded);
}
}
/* -------------------------------------------- */
/**
* Handle click events to export the chat log
* @param {Event} event
* @private
*/
_onExportLog(event) {
event.preventDefault();
game.messages.export();
}
/* -------------------------------------------- */
/**
* Handle click events to flush the chat log
* @param {Event} event
* @private
*/
_onFlushLog(event) {
event.preventDefault();
game.messages.flush(this.#jumpToBottomElement);
}
/* -------------------------------------------- */
/**
* Handle scroll events within the chat log container
* @param {UIEvent} event The initial scroll event
* @private
*/
_onScrollLog(event) {
if ( !this.rendered ) return;
const log = event.target;
const pct = log.scrollTop / (log.scrollHeight - log.clientHeight);
if ( !this.#jumpToBottomElement ) this.#jumpToBottomElement = this.element.find(".jump-to-bottom")[0];
this.#isAtBottom = pct > 0.99;
this.#jumpToBottomElement.classList.toggle("hidden", this.#isAtBottom);
if ( pct < 0.01 ) return this._renderBatch(this.element, CONFIG.ChatMessage.batchSize);
}
/* -------------------------------------------- */
/**
* Update roll mode select dropdowns when the setting is changed
* @param {string} mode The new roll mode setting
*/
static _setRollMode(mode) {
for ( let select of $(".roll-type-select") ) {
for ( let option of select.options ) {
option.selected = option.value === mode;
}
}
}
}

View File

@@ -0,0 +1,522 @@
/**
* The sidebar directory which organizes and displays world-level Combat documents.
*/
class CombatTracker extends SidebarTab {
constructor(options) {
super(options);
if ( !this.popOut ) game.combats.apps.push(this);
/**
* Record a reference to the currently highlighted Token
* @type {Token|null}
* @private
*/
this._highlighted = null;
/**
* Record the currently tracked Combat encounter
* @type {Combat|null}
*/
this.viewed = null;
// Initialize the starting encounter
this.initialize({render: false});
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "combat",
template: "templates/sidebar/combat-tracker.html",
title: "COMBAT.SidebarTitle",
scrollY: [".directory-list"]
});
}
/* -------------------------------------------- */
/**
* Return an array of Combat encounters which occur within the current Scene.
* @type {Combat[]}
*/
get combats() {
return game.combats.combats;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
createPopout() {
const pop = super.createPopout();
pop.initialize({combat: this.viewed, render: true});
return pop;
}
/* -------------------------------------------- */
/**
* Initialize the combat tracker to display a specific combat encounter.
* If no encounter is provided, the tracker will be initialized with the first encounter in the viewed scene.
* @param {object} [options] Additional options to configure behavior.
* @param {Combat|null} [options.combat=null] The combat encounter to initialize
* @param {boolean} [options.render=true] Whether to re-render the sidebar after initialization
*/
initialize({combat=null, render=true}={}) {
// Retrieve a default encounter if none was provided
if ( combat === null ) {
const combats = this.combats;
combat = combats.length ? combats.find(c => c.active) || combats[0] : null;
combat?.updateCombatantActors();
}
// Prepare turn order
if ( combat && !combat.turns ) combat.turns = combat.setupTurns();
// Set flags
this.viewed = combat;
this._highlighted = null;
// Also initialize the popout
if ( this._popout ) {
this._popout.viewed = combat;
this._popout._highlighted = null;
}
// Render the tracker
if ( render ) this.render();
}
/* -------------------------------------------- */
/**
* Scroll the combat log container to ensure the current Combatant turn is centered vertically
*/
scrollToTurn() {
const combat = this.viewed;
if ( !combat || (combat.turn === null) ) return;
let active = this.element.find(".active")[0];
if ( !active ) return;
let container = active.parentElement;
const nViewable = Math.floor(container.offsetHeight / active.offsetHeight);
container.scrollTop = (combat.turn * active.offsetHeight) - ((nViewable/2) * active.offsetHeight);
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
let context = await super.getData(options);
// Get the combat encounters possible for the viewed Scene
const combat = this.viewed;
const hasCombat = combat !== null;
const combats = this.combats;
const currentIdx = combats.findIndex(c => c === combat);
const previousId = currentIdx > 0 ? combats[currentIdx-1].id : null;
const nextId = currentIdx < combats.length - 1 ? combats[currentIdx+1].id : null;
const settings = game.settings.get("core", Combat.CONFIG_SETTING);
// Prepare rendering data
context = foundry.utils.mergeObject(context, {
combats: combats,
currentIndex: currentIdx + 1,
combatCount: combats.length,
hasCombat: hasCombat,
combat,
turns: [],
previousId,
nextId,
started: this.started,
control: false,
settings,
linked: combat?.scene !== null,
labels: {}
});
context.labels.scope = game.i18n.localize(`COMBAT.${context.linked ? "Linked" : "Unlinked"}`);
if ( !hasCombat ) return context;
// Format information about each combatant in the encounter
let hasDecimals = false;
const turns = [];
for ( let [i, combatant] of combat.turns.entries() ) {
if ( !combatant.visible ) continue;
// Prepare turn data
const resource = combatant.permission >= CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER ? combatant.resource : null;
const turn = {
id: combatant.id,
name: combatant.name,
img: await this._getCombatantThumbnail(combatant),
active: i === combat.turn,
owner: combatant.isOwner,
defeated: combatant.isDefeated,
hidden: combatant.hidden,
initiative: combatant.initiative,
hasRolled: combatant.initiative !== null,
hasResource: resource !== null,
resource: resource,
canPing: (combatant.sceneId === canvas.scene?.id) && game.user.hasPermission("PING_CANVAS")
};
if ( (turn.initiative !== null) && !Number.isInteger(turn.initiative) ) hasDecimals = true;
turn.css = [
turn.active ? "active" : "",
turn.hidden ? "hidden" : "",
turn.defeated ? "defeated" : ""
].join(" ").trim();
// Actor and Token status effects
turn.effects = new Set();
if ( combatant.token ) {
combatant.token.effects.forEach(e => turn.effects.add(e));
if ( combatant.token.overlayEffect ) turn.effects.add(combatant.token.overlayEffect);
}
if ( combatant.actor ) {
for ( const effect of combatant.actor.temporaryEffects ) {
if ( effect.statuses.has(CONFIG.specialStatusEffects.DEFEATED) ) turn.defeated = true;
else if ( effect.icon ) turn.effects.add(effect.icon);
}
}
turns.push(turn);
}
// Format initiative numeric precision
const precision = CONFIG.Combat.initiative.decimals;
turns.forEach(t => {
if ( t.initiative !== null ) t.initiative = t.initiative.toFixed(hasDecimals ? precision : 0);
});
// Merge update data for rendering
return foundry.utils.mergeObject(context, {
round: combat.round,
turn: combat.turn,
turns: turns,
control: combat.combatant?.players?.includes(game.user)
});
}
/* -------------------------------------------- */
/**
* Retrieve a source image for a combatant.
* @param {Combatant} combatant The combatant queried for image.
* @returns {Promise<string>} The source image attributed for this combatant.
* @protected
*/
async _getCombatantThumbnail(combatant) {
if ( combatant._videoSrc && !combatant.img ) {
if ( combatant._thumb ) return combatant._thumb;
return combatant._thumb = await game.video.createThumbnail(combatant._videoSrc, {width: 100, height: 100});
}
return combatant.img ?? CONST.DEFAULT_TOKEN;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
const tracker = html.find("#combat-tracker");
const combatants = tracker.find(".combatant");
// Create new Combat encounter
html.find(".combat-create").click(ev => this._onCombatCreate(ev));
// Display Combat settings
html.find(".combat-settings").click(ev => {
ev.preventDefault();
new CombatTrackerConfig().render(true);
});
// Cycle the current Combat encounter
html.find(".combat-cycle").click(ev => this._onCombatCycle(ev));
// Combat control
html.find(".combat-control").click(ev => this._onCombatControl(ev));
// Combatant control
html.find(".combatant-control").click(ev => this._onCombatantControl(ev));
// Hover on Combatant
combatants.hover(this._onCombatantHoverIn.bind(this), this._onCombatantHoverOut.bind(this));
// Click on Combatant
combatants.click(this._onCombatantMouseDown.bind(this));
// Context on right-click
if ( game.user.isGM ) this._contextMenu(html);
// Intersection Observer for Combatant avatars
const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), {root: tracker[0]});
combatants.each((i, li) => observer.observe(li));
}
/* -------------------------------------------- */
/**
* Handle new Combat creation request
* @param {Event} event
* @private
*/
async _onCombatCreate(event) {
event.preventDefault();
let scene = game.scenes.current;
const cls = getDocumentClass("Combat");
const combat = await cls.create({scene: scene?.id});
await combat.activate({render: false});
}
/* -------------------------------------------- */
/**
* Handle a Combat cycle request
* @param {Event} event
* @private
*/
async _onCombatCycle(event) {
event.preventDefault();
const btn = event.currentTarget;
const combat = game.combats.get(btn.dataset.documentId);
if ( !combat ) return;
await combat.activate({render: false});
}
/* -------------------------------------------- */
/**
* Handle click events on Combat control buttons
* @private
* @param {Event} event The originating mousedown event
*/
async _onCombatControl(event) {
event.preventDefault();
const combat = this.viewed;
const ctrl = event.currentTarget;
if ( ctrl.getAttribute("disabled") ) return;
else ctrl.setAttribute("disabled", true);
const fn = combat[ctrl.dataset.control];
if ( fn ) await fn.bind(combat)();
ctrl.removeAttribute("disabled");
}
/* -------------------------------------------- */
/**
* Handle a Combatant control toggle
* @private
* @param {Event} event The originating mousedown event
*/
async _onCombatantControl(event) {
event.preventDefault();
event.stopPropagation();
const btn = event.currentTarget;
const li = btn.closest(".combatant");
const combat = this.viewed;
const c = combat.combatants.get(li.dataset.combatantId);
// Switch control action
switch (btn.dataset.control) {
// Toggle combatant visibility
case "toggleHidden":
return c.update({hidden: !c.hidden});
// Toggle combatant defeated flag
case "toggleDefeated":
return this._onToggleDefeatedStatus(c);
// Roll combatant initiative
case "rollInitiative":
return combat.rollInitiative([c.id]);
// Actively ping the Combatant
case "pingCombatant":
return this._onPingCombatant(c);
}
}
/* -------------------------------------------- */
/**
* Handle toggling the defeated status effect on a combatant Token
* @param {Combatant} combatant The combatant data being modified
* @returns {Promise} A Promise that resolves after all operations are complete
* @private
*/
async _onToggleDefeatedStatus(combatant) {
const isDefeated = !combatant.isDefeated;
await combatant.update({defeated: isDefeated});
const token = combatant.token;
if ( !token ) return;
// Push the defeated status to the token
const status = CONFIG.statusEffects.find(e => e.id === CONFIG.specialStatusEffects.DEFEATED);
if ( !status && !token.object ) return;
const effect = token.actor && status ? status : CONFIG.controlIcons.defeated;
if ( token.object ) await token.object.toggleEffect(effect, {overlay: true, active: isDefeated});
else await token.toggleActiveEffect(effect, {overlay: true, active: isDefeated});
}
/* -------------------------------------------- */
/**
* Handle pinging a combatant Token
* @param {Combatant} combatant The combatant data
* @returns {Promise}
* @protected
*/
async _onPingCombatant(combatant) {
if ( !canvas.ready || (combatant.sceneId !== canvas.scene.id) ) return;
if ( !combatant.token.object.visible ) return ui.notifications.warn(game.i18n.localize("COMBAT.PingInvisibleToken"));
await canvas.ping(combatant.token.object.center);
}
/* -------------------------------------------- */
/**
* Handle mouse-down event on a combatant name in the tracker
* @param {Event} event The originating mousedown event
* @returns {Promise} A Promise that resolves once the pan is complete
* @private
*/
async _onCombatantMouseDown(event) {
event.preventDefault();
const li = event.currentTarget;
const combatant = this.viewed.combatants.get(li.dataset.combatantId);
const token = combatant.token;
if ( !combatant.actor?.testUserPermission(game.user, "OBSERVER") ) return;
const now = Date.now();
// Handle double-left click to open sheet
const dt = now - this._clickTime;
this._clickTime = now;
if ( dt <= 250 ) return combatant.actor?.sheet.render(true);
// Control and pan to Token object
if ( token?.object ) {
token.object?.control({releaseOthers: true});
return canvas.animatePan(token.object.center);
}
}
/* -------------------------------------------- */
/**
* Handle mouse-hover events on a combatant in the tracker
* @private
*/
_onCombatantHoverIn(event) {
event.preventDefault();
if ( !canvas.ready ) return;
const li = event.currentTarget;
const combatant = this.viewed.combatants.get(li.dataset.combatantId);
const token = combatant.token?.object;
if ( token?.isVisible ) {
if ( !token.controlled ) token._onHoverIn(event, {hoverOutOthers: true});
this._highlighted = token;
}
}
/* -------------------------------------------- */
/**
* Handle mouse-unhover events for a combatant in the tracker
* @private
*/
_onCombatantHoverOut(event) {
event.preventDefault();
if ( this._highlighted ) this._highlighted._onHoverOut(event);
this._highlighted = null;
}
/* -------------------------------------------- */
/**
* Highlight a hovered combatant in the tracker.
* @param {Combatant} combatant The Combatant
* @param {boolean} hover Whether they are being hovered in or out.
*/
hoverCombatant(combatant, hover) {
const trackers = [this.element[0]];
if ( this._popout ) trackers.push(this._popout.element[0]);
for ( const tracker of trackers ) {
const li = tracker.querySelector(`.combatant[data-combatant-id="${combatant.id}"]`);
if ( !li ) continue;
if ( hover ) li.classList.add("hover");
else li.classList.remove("hover");
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_contextMenu(html) {
ContextMenu.create(this, html, ".directory-item", this._getEntryContextOptions());
}
/* -------------------------------------------- */
/**
* Get the Combatant entry context options
* @returns {object[]} The Combatant entry context options
* @private
*/
_getEntryContextOptions() {
return [
{
name: "COMBAT.CombatantUpdate",
icon: '<i class="fas fa-edit"></i>',
callback: this._onConfigureCombatant.bind(this)
},
{
name: "COMBAT.CombatantClear",
icon: '<i class="fas fa-undo"></i>',
condition: li => {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
return Number.isNumeric(combatant?.initiative);
},
callback: li => {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
if ( combatant ) return combatant.update({initiative: null});
}
},
{
name: "COMBAT.CombatantReroll",
icon: '<i class="fas fa-dice-d20"></i>',
callback: li => {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
if ( combatant ) return this.viewed.rollInitiative([combatant.id]);
}
},
{
name: "COMBAT.CombatantRemove",
icon: '<i class="fas fa-trash"></i>',
callback: li => {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
if ( combatant ) return combatant.delete();
}
}
];
}
/* -------------------------------------------- */
/**
* Display a dialog which prompts the user to enter a new initiative value for a Combatant
* @param {jQuery} li
* @private
*/
_onConfigureCombatant(li) {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
new CombatantConfig(combatant, {
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
}).render(true);
}
}

View File

@@ -0,0 +1,434 @@
/**
* A compendium of knowledge arcane and mystical!
* Renders the sidebar directory of compendium packs
* @extends {SidebarTab}
* @mixes {DirectoryApplication}
*/
class CompendiumDirectory extends DirectoryApplicationMixin(SidebarTab) {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "compendium",
template: "templates/sidebar/compendium-directory.html",
title: "COMPENDIUM.SidebarTitle",
contextMenuSelector: ".directory-item.compendium",
entryClickSelector: ".compendium"
});
}
/**
* A reference to the currently active compendium types. If empty, all types are shown.
* @type {string[]}
*/
#activeFilters = [];
get activeFilters() {
return this.#activeFilters;
}
/* -------------------------------------------- */
/** @override */
entryType = "Compendium";
/* -------------------------------------------- */
/** @override */
static entryPartial = "templates/sidebar/partials/pack-partial.html";
/* -------------------------------------------- */
/** @override */
_entryAlreadyExists(entry) {
return this.collection.has(entry.collection);
}
/* -------------------------------------------- */
/** @override */
_getEntryDragData(entryId) {
const pack = this.collection.get(entryId);
return {
type: "Compendium",
id: pack.collection
};
}
/* -------------------------------------------- */
/** @override */
_entryIsSelf(entry, otherEntry) {
return entry.metadata.id === otherEntry.metadata.id;
}
/* -------------------------------------------- */
/** @override */
async _sortRelative(entry, sortData) {
// We build up a single update object for all compendiums to prevent multiple re-renders
const packConfig = game.settings.get("core", "compendiumConfiguration");
const targetFolderId = sortData.updateData.folder;
if ( targetFolderId ) {
packConfig[entry.collection] = foundry.utils.mergeObject(packConfig[entry.collection] || {}, {
folder: targetFolderId
});
}
// Update sorting
const sorting = SortingHelpers.performIntegerSort(entry, sortData);
for ( const s of sorting ) {
const pack = s.target;
const existingConfig = packConfig[pack.collection] || {};
existingConfig.sort = s.update.sort;
}
await game.settings.set("core", "compendiumConfiguration", packConfig);
}
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
html.find(".filter").click(this._displayFilterCompendiumMenu.bind(this));
}
/* -------------------------------------------- */
/**
* Display a menu of compendium types to filter by
* @param {PointerEvent} event The originating pointer event
* @returns {Promise<void>}
* @protected
*/
async _displayFilterCompendiumMenu(event) {
// If there is a current dropdown menu, remove it
const dropdown = document.getElementsByClassName("dropdown-menu")[0];
if ( dropdown ) {
dropdown.remove();
return;
}
const button = event.currentTarget;
// Display a menu of compendium types to filter by
const choices = CONST.COMPENDIUM_DOCUMENT_TYPES.map(t => {
const config = CONFIG[t];
return {
name: game.i18n.localize(config.documentClass.metadata.label),
icon: config.sidebarIcon,
type: t,
callback: (event) => this._onToggleCompendiumFilterType(event, t)
};
});
// If there are active filters, add a "Clear Filters" option
if ( this.#activeFilters.length ) {
choices.unshift({
name: game.i18n.localize("COMPENDIUM.ClearFilters"),
icon: "fas fa-times",
type: null,
callback: (event) => this._onToggleCompendiumFilterType(event, null)
});
}
// Create a vertical list of buttons contained in a div
const menu = document.createElement("div");
menu.classList.add("dropdown-menu");
const list = document.createElement("div");
list.classList.add("dropdown-list", "flexcol");
menu.appendChild(list);
for ( let c of choices ) {
const dropdownItem = document.createElement("a");
dropdownItem.classList.add("dropdown-item");
if ( this.#activeFilters.includes(c.type) ) dropdownItem.classList.add("active");
dropdownItem.innerHTML = `<i class="${c.icon}"></i> ${c.name}`;
dropdownItem.addEventListener("click", c.callback);
list.appendChild(dropdownItem);
}
// Position the menu
const pos = {
top: button.offsetTop + 10,
left: button.offsetLeft + 10
};
menu.style.top = `${pos.top}px`;
menu.style.left = `${pos.left}px`;
button.parentElement.appendChild(menu);
}
/* -------------------------------------------- */
/**
* Handle toggling a compendium type filter
* @param {PointerEvent} event The originating pointer event
* @param {string|null} type The compendium type to filter by. If null, clear all filters.
* @protected
*/
_onToggleCompendiumFilterType(event, type) {
if ( type === null ) this.#activeFilters = [];
else this.#activeFilters = this.#activeFilters.includes(type) ?
this.#activeFilters.filter(t => t !== type) : this.#activeFilters.concat(type);
this.render();
}
/* -------------------------------------------- */
/**
* The collection of Compendium Packs which are displayed in this Directory
* @returns {CompendiumPacks<string, CompendiumCollection>}
*/
get collection() {
return game.packs;
}
/* -------------------------------------------- */
/**
* Get the dropped Entry from the drop data
* @param {object} data The data being dropped
* @returns {Promise<object>} The dropped Entry
* @protected
*/
async _getDroppedEntryFromData(data) {
return game.packs.get(data.id);
}
/* -------------------------------------------- */
/** @override */
async _createDroppedEntry(document, folder) {
throw new Error("The _createDroppedEntry shouldn't be called for CompendiumDirectory");
}
/* -------------------------------------------- */
/** @override */
_getEntryName(entry) {
return entry.metadata.label;
}
/* -------------------------------------------- */
/** @override */
_getEntryId(entry) {
return entry.metadata.id;
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
let context = await super.getData(options);
// For each document, assign a default image if one is not already present, and calculate the style string
const packageTypeIcons = {
"world": World.icon,
"system": System.icon,
"module": Module.icon
};
const packContext = {};
for ( const pack of this.collection ) {
packContext[pack.collection] = {
locked: pack.locked,
customOwnership: "ownership" in pack.config,
collection: pack.collection,
name: pack.metadata.packageName,
label: pack.metadata.label,
icon: CONFIG[pack.metadata.type].sidebarIcon,
hidden: this.#activeFilters?.length ? !this.#activeFilters.includes(pack.metadata.type) : false,
banner: pack.banner,
sourceIcon: packageTypeIcons[pack.metadata.packageType]
};
}
// Return data to the sidebar
context = foundry.utils.mergeObject(context, {
folderIcon: CONFIG.Folder.sidebarIcon,
label: game.i18n.localize("PACKAGE.TagCompendium"),
labelPlural: game.i18n.localize("SIDEBAR.TabCompendium"),
sidebarIcon: "fas fa-atlas",
filtersActive: !!this.#activeFilters.length
});
context.packContext = packContext;
return context;
}
/* -------------------------------------------- */
/** @override */
async render(force=false, options={}) {
game.packs.initializeTree();
return super.render(force, options);
}
/* -------------------------------------------- */
/** @override */
_getEntryContextOptions() {
if ( !game.user.isGM ) return [];
return [
{
name: "OWNERSHIP.Configure",
icon: '<i class="fa-solid fa-user-lock"></i>',
callback: li => {
const pack = game.packs.get(li.data("pack"));
return pack.configureOwnershipDialog();
}
},
{
name: "FOLDER.Clear",
icon: '<i class="fas fa-folder"></i>',
condition: header => {
const li = header.closest(".directory-item");
const entry = this.collection.get(li.data("entryId"));
return !!entry.folder;
},
callback: header => {
const li = header.closest(".directory-item");
const entry = this.collection.get(li.data("entryId"));
entry.setFolder(null);
}
},
{
name: "COMPENDIUM.ToggleLocked",
icon: '<i class="fas fa-lock"></i>',
callback: li => {
let pack = game.packs.get(li.data("pack"));
const isUnlock = pack.locked;
if ( isUnlock && (pack.metadata.packageType !== "world")) {
return Dialog.confirm({
title: `${game.i18n.localize("COMPENDIUM.ToggleLocked")}: ${pack.title}`,
content: `<p><strong>${game.i18n.localize("Warning")}:</strong> ${game.i18n.localize("COMPENDIUM.ToggleLockedWarning")}</p>`,
yes: () => pack.configure({locked: !pack.locked}),
options: {
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
}
});
}
else return pack.configure({locked: !pack.locked});
}
},
{
name: "COMPENDIUM.Duplicate",
icon: '<i class="fas fa-copy"></i>',
callback: li => {
let pack = game.packs.get(li.data("pack"));
const html = `<form>
<div class="form-group">
<label>${game.i18n.localize("COMPENDIUM.DuplicateTitle")}</label>
<input type="text" name="label" value="${game.i18n.format("DOCUMENT.CopyOf", {name: pack.title})}"/>
<p class="notes">${game.i18n.localize("COMPENDIUM.DuplicateHint")}</p>
</div>
</form>`;
return Dialog.confirm({
title: `${game.i18n.localize("COMPENDIUM.Duplicate")}: ${pack.title}`,
content: html,
yes: html => {
const label = html.querySelector('input[name="label"]').value;
return pack.duplicateCompendium({label});
},
options: {
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400,
jQuery: false
}
});
}
},
{
name: "COMPENDIUM.ImportAll",
icon: '<i class="fas fa-download"></i>',
condition: li => game.packs.get(li.data("pack"))?.documentName !== "Adventure",
callback: li => {
let pack = game.packs.get(li.data("pack"));
return pack.importDialog({
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
});
}
},
{
name: "COMPENDIUM.Delete",
icon: '<i class="fas fa-trash"></i>',
condition: li => {
let pack = game.packs.get(li.data("pack"));
return pack.metadata.packageType === "world";
},
callback: li => {
let pack = game.packs.get(li.data("pack"));
return this._onDeleteCompendium(pack);
}
}
];
}
/* -------------------------------------------- */
/** @override */
async _onClickEntryName(event) {
event.preventDefault();
const element = event.currentTarget;
const packId = element.closest("[data-pack]").dataset.pack;
const pack = game.packs.get(packId);
pack.render(true);
}
/* -------------------------------------------- */
/** @override */
async _onCreateEntry(event) {
event.preventDefault();
event.stopPropagation();
const li = event.currentTarget.closest(".directory-item");
const targetFolderId = li ? li.dataset.folderId : null;
const types = CONST.COMPENDIUM_DOCUMENT_TYPES.map(documentName => {
return { value: documentName, label: game.i18n.localize(getDocumentClass(documentName).metadata.label) };
});
game.i18n.sortObjects(types, "label");
const folders = this.collection._formatFolderSelectOptions();
const html = await renderTemplate("templates/sidebar/compendium-create.html",
{types, folders, folder: targetFolderId, hasFolders: folders.length >= 1});
return Dialog.prompt({
title: game.i18n.localize("COMPENDIUM.Create"),
content: html,
label: game.i18n.localize("COMPENDIUM.Create"),
callback: async html => {
const form = html.querySelector("#compendium-create");
const fd = new FormDataExtended(form);
const metadata = fd.object;
let targetFolderId = metadata.folder;
if ( metadata.folder ) delete metadata.folder;
if ( !metadata.label ) {
let defaultName = game.i18n.format("DOCUMENT.New", {type: game.i18n.localize("PACKAGE.TagCompendium")});
const count = game.packs.size;
if ( count > 0 ) defaultName += ` (${count + 1})`;
metadata.label = defaultName;
}
const pack = await CompendiumCollection.createCompendium(metadata);
if ( targetFolderId ) await pack.setFolder(targetFolderId);
},
rejectClose: false,
options: { jQuery: false }
});
}
/* -------------------------------------------- */
/**
* Handle a Compendium Pack deletion request
* @param {object} pack The pack object requested for deletion
* @private
*/
_onDeleteCompendium(pack) {
return Dialog.confirm({
title: `${game.i18n.localize("COMPENDIUM.Delete")}: ${pack.title}`,
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("COMPENDIUM.DeleteWarning")}</p>`,
yes: () => pack.deleteCompendium(),
defaultYes: false
});
}
}

View File

@@ -0,0 +1,39 @@
/**
* The sidebar directory which organizes and displays world-level Item documents.
*/
class ItemDirectory extends DocumentDirectory {
/** @override */
static documentName = "Item";
/* -------------------------------------------- */
/** @override */
_canDragDrop(selector) {
return game.user.can("ITEM_CREATE");
}
/* -------------------------------------------- */
/** @override */
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
return [
{
name: "ITEM.ViewArt",
icon: '<i class="fas fa-image"></i>',
condition: li => {
const item = game.items.get(li.data("documentId"));
return item.img !== CONST.DEFAULT_TOKEN;
},
callback: li => {
const item = game.items.get(li.data("documentId"));
new ImagePopout(item.img, {
title: item.name,
uuid: item.uuid
}).render(true);
}
}
].concat(options);
}
}

View File

@@ -0,0 +1,30 @@
/**
* The sidebar directory which organizes and displays world-level JournalEntry documents.
* @extends {DocumentDirectory}
*/
class JournalDirectory extends DocumentDirectory {
/** @override */
static documentName = "JournalEntry";
/* -------------------------------------------- */
/** @override */
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
return options.concat([
{
name: "SIDEBAR.JumpPin",
icon: '<i class="fas fa-crosshairs"></i>',
condition: li => {
const entry = game.journal.get(li.data("document-id"));
return !!entry.sceneNote;
},
callback: li => {
const entry = game.journal.get(li.data("document-id"));
return entry.panToNote();
}
}
]);
}
}

View File

@@ -0,0 +1,19 @@
/**
* The directory, not displayed in the sidebar, which organizes and displays world-level Macro documents.
* @extends {DocumentDirectory}
*
* @see {@link Macros} The WorldCollection of Macro Documents
* @see {@link Macro} The Macro Document
* @see {@link MacroConfig} The Macro Configuration Sheet
*/
class MacroDirectory extends DocumentDirectory {
constructor(options={}) {
options.popOut = true;
super(options);
delete ui.sidebar.tabs["macros"];
game.macros.apps.push(this);
}
/** @override */
static documentName = "Macro";
}

View File

@@ -0,0 +1,782 @@
/**
* The sidebar directory which organizes and displays world-level Playlist documents.
* @extends {DocumentDirectory}
*/
class PlaylistDirectory extends DocumentDirectory {
constructor(options) {
super(options);
/**
* Track the playlist IDs which are currently expanded in their display
* @type {Set<string>}
*/
this._expanded = this._createExpandedSet();
/**
* Are the global volume controls currently expanded?
* @type {boolean}
* @private
*/
this._volumeExpanded = true;
/**
* Cache the set of Playlist documents that are displayed as playing when the directory is rendered
* @type {Playlist[]}
*/
this._playingPlaylists = [];
/**
* Cache the set of PlaylistSound documents that are displayed as playing when the directory is rendered
* @type {PlaylistSound[]}
*/
this._playingSounds = [];
// Update timestamps every second
setInterval(this._updateTimestamps.bind(this), 1000);
// Playlist 'currently playing' pinned location.
game.settings.register("core", "playlist.playingLocation", {
scope: "client",
config: false,
default: "top",
type: String,
onChange: () => ui.playlists.render()
});
}
/** @override */
static documentName = "Playlist";
/** @override */
static entryPartial = "templates/sidebar/partials/playlist-partial.html";
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
const options = super.defaultOptions;
options.template = "templates/sidebar/playlists-directory.html";
options.dragDrop[0].dragSelector = ".folder, .playlist-name, .sound-name";
options.renderUpdateKeys = ["name", "playing", "mode", "sounds", "sort", "sorting", "folder"];
options.contextMenuSelector = ".document .playlist-header";
return options;
}
/* -------------------------------------------- */
/**
* Initialize the set of Playlists which should be displayed in an expanded form
* @returns {Set<string>}
* @private
*/
_createExpandedSet() {
const expanded = new Set();
for ( let playlist of this.documents ) {
if ( playlist.playing ) expanded.add(playlist.id);
}
return expanded;
}
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* Return an Array of the Playlist documents which are currently playing
* @type {Playlist[]}
*/
get playing() {
return this._playingPlaylists;
}
/**
* Whether the 'currently playing' element is pinned to the top or bottom of the display.
* @type {string}
* @private
*/
get _playingLocation() {
return game.settings.get("core", "playlist.playingLocation");
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
this._playingPlaylists = [];
this._playingSounds = [];
this._playingSoundsData = [];
this._prepareTreeData(this.collection.tree);
const data = await super.getData(options);
const currentAtTop = this._playingLocation === "top";
return foundry.utils.mergeObject(data, {
playingSounds: this._playingSoundsData,
showPlaying: this._playingSoundsData.length > 0,
playlistModifier: AudioHelper.volumeToInput(game.settings.get("core", "globalPlaylistVolume")),
playlistTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalPlaylistVolume")),
ambientModifier: AudioHelper.volumeToInput(game.settings.get("core", "globalAmbientVolume")),
ambientTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalAmbientVolume")),
interfaceModifier: AudioHelper.volumeToInput(game.settings.get("core", "globalInterfaceVolume")),
interfaceTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalInterfaceVolume")),
volumeExpanded: this._volumeExpanded,
currentlyPlaying: {
class: `location-${currentAtTop ? "top" : "bottom"}`,
location: {top: currentAtTop, bottom: !currentAtTop},
pin: {label: `PLAYLIST.PinTo${currentAtTop ? "Bottom" : "Top"}`, caret: currentAtTop ? "down" : "up"}
}
});
}
/* -------------------------------------------- */
/**
* Converts a volume level to a human-friendly % value
* @param {number} volume Value between [0, 1] of the volume level
* @returns {string}
*/
static volumeToTooltip(volume) {
return game.i18n.format("PLAYLIST.VolumeTooltip", { volume: Math.round(AudioHelper.volumeToInput(volume) * 100) });
}
/* -------------------------------------------- */
/**
* Augment the tree directory structure with playlist-level data objects for rendering
* @param {object} node The tree leaf node being prepared
* @private
*/
_prepareTreeData(node) {
node.entries = node.entries.map(p => this._preparePlaylistData(p));
for ( const child of node.children ) this._prepareTreeData(child);
}
/* -------------------------------------------- */
/**
* Create an object of rendering data for each Playlist document being displayed
* @param {Playlist} playlist The playlist to display
* @returns {object} The data for rendering
* @private
*/
_preparePlaylistData(playlist) {
const isGM = game.user.isGM;
if ( playlist.playing ) this._playingPlaylists.push(playlist);
// Playlist configuration
const p = playlist.toObject(false);
p.modeTooltip = this._getModeTooltip(p.mode);
p.modeIcon = this._getModeIcon(p.mode);
p.disabled = p.mode === CONST.PLAYLIST_MODES.DISABLED;
p.expanded = this._expanded.has(p._id);
p.css = [p.expanded ? "" : "collapsed", playlist.playing ? "playing" : ""].filterJoin(" ");
p.controlCSS = (isGM && !p.disabled) ? "" : "disabled";
// Playlist sounds
const sounds = [];
for ( const soundId of playlist.playbackOrder ) {
const sound = playlist.sounds.get(soundId);
if ( !isGM && !sound.playing ) continue;
// All sounds
const s = sound.toObject(false);
s.playlistId = playlist.id;
s.css = s.playing ? "playing" : "";
s.controlCSS = isGM ? "" : "disabled";
s.playIcon = this._getPlayIcon(sound);
s.playTitle = s.pausedTime ? "PLAYLIST.SoundResume" : "PLAYLIST.SoundPlay";
// Playing sounds
if ( sound.sound && !sound.sound.failed && (sound.playing || s.pausedTime) ) {
s.isPaused = !sound.playing && s.pausedTime;
s.pauseIcon = this._getPauseIcon(sound);
s.lvolume = AudioHelper.volumeToInput(s.volume);
s.volumeTooltip = this.constructor.volumeToTooltip(s.volume);
s.currentTime = this._formatTimestamp(sound.playing ? sound.sound.currentTime : s.pausedTime);
s.durationTime = this._formatTimestamp(sound.sound.duration);
this._playingSounds.push(sound);
this._playingSoundsData.push(s);
}
sounds.push(s);
}
p.sounds = sounds;
return p;
}
/* -------------------------------------------- */
/**
* Get the icon used to represent the "play/stop" icon for the PlaylistSound
* @param {PlaylistSound} sound The sound being rendered
* @returns {string} The icon that should be used
* @private
*/
_getPlayIcon(sound) {
if ( !sound.playing ) return sound.pausedTime ? "fas fa-play-circle" : "fas fa-play";
else return "fas fa-square";
}
/* -------------------------------------------- */
/**
* Get the icon used to represent the pause/loading icon for the PlaylistSound
* @param {PlaylistSound} sound The sound being rendered
* @returns {string} The icon that should be used
* @private
*/
_getPauseIcon(sound) {
return (sound.playing && !sound.sound?.loaded) ? "fas fa-spinner fa-spin" : "fas fa-pause";
}
/* -------------------------------------------- */
/**
* Given a constant playback mode, provide the FontAwesome icon used to display it
* @param {number} mode
* @returns {string}
* @private
*/
_getModeIcon(mode) {
return {
[CONST.PLAYLIST_MODES.DISABLED]: '<i class="fas fa-ban"></i>',
[CONST.PLAYLIST_MODES.SEQUENTIAL]: '<i class="far fa-arrow-alt-circle-right"></i>',
[CONST.PLAYLIST_MODES.SHUFFLE]: '<i class="fas fa-random"></i>',
[CONST.PLAYLIST_MODES.SIMULTANEOUS]: '<i class="fas fa-compress-arrows-alt"></i>',
}[mode];
}
/* -------------------------------------------- */
/**
* Given a constant playback mode, provide the string tooltip used to describe it
* @param {number} mode
* @returns {string}
* @private
*/
_getModeTooltip(mode) {
return {
[CONST.PLAYLIST_MODES.DISABLED]: game.i18n.localize("PLAYLIST.ModeDisabled"),
[CONST.PLAYLIST_MODES.SEQUENTIAL]: game.i18n.localize("PLAYLIST.ModeSequential"),
[CONST.PLAYLIST_MODES.SHUFFLE]: game.i18n.localize("PLAYLIST.ModeShuffle"),
[CONST.PLAYLIST_MODES.SIMULTANEOUS]: game.i18n.localize("PLAYLIST.ModeSimultaneous")
}[mode];
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
// Volume sliders
html.find(".global-volume-slider").change(this._onGlobalVolume.bind(this));
html.find(".sound-volume").change(this._onSoundVolume.bind(this));
// Collapse/Expand
html.find("#global-volume .playlist-header").click(this._onVolumeCollapse.bind(this));
// Currently playing pinning
html.find("#currently-playing .pin").click(this._onPlayingPin.bind(this));
// All options below require a GM user
if (!game.user.isGM) return;
// Playlist Control Events
html.on("click", "a.sound-control", event => {
event.preventDefault();
const btn = event.currentTarget;
const action = btn.dataset.action;
if (!action || btn.classList.contains("disabled")) return;
// Delegate to Playlist and Sound control handlers
switch (action) {
case "playlist-mode":
return this._onPlaylistToggleMode(event);
case "playlist-play":
case "playlist-stop":
return this._onPlaylistPlay(event, action === "playlist-play");
case "playlist-forward":
case "playlist-backward":
return this._onPlaylistSkip(event, action);
case "sound-create":
return this._onSoundCreate(event);
case "sound-pause":
case "sound-play":
case "sound-stop":
return this._onSoundPlay(event, action);
case "sound-repeat":
return this._onSoundToggleMode(event);
}
});
}
/* -------------------------------------------- */
/**
* Handle global volume change for the playlist sidebar
* @param {MouseEvent} event The initial click event
* @private
*/
_onGlobalVolume(event) {
event.preventDefault();
const slider = event.currentTarget;
const volume = AudioHelper.inputToVolume(slider.value);
const tooltip = PlaylistDirectory.volumeToTooltip(volume);
slider.setAttribute("data-tooltip", tooltip);
game.tooltip.activate(slider, {text: tooltip});
return game.settings.set("core", slider.name, volume);
}
/* -------------------------------------------- */
/** @inheritdoc */
collapseAll() {
super.collapseAll();
const el = this.element[0];
for ( let p of el.querySelectorAll("li.playlist") ) {
this._collapse(p, true);
}
this._expanded.clear();
this._collapse(el.querySelector("#global-volume"), true);
this._volumeExpanded = false;
}
/* -------------------------------------------- */
/** @override */
_onClickEntryName(event) {
const li = event.currentTarget.closest(".playlist");
const playlistId = li.dataset.documentId;
const wasExpanded = this._expanded.has(playlistId);
this._collapse(li, wasExpanded);
if ( wasExpanded ) this._expanded.delete(playlistId);
else this._expanded.add(playlistId);
}
/* -------------------------------------------- */
/**
* Handle global volume control collapse toggle
* @param {MouseEvent} event The initial click event
* @private
*/
_onVolumeCollapse(event) {
event.preventDefault();
const div = event.currentTarget.parentElement;
this._volumeExpanded = !this._volumeExpanded;
this._collapse(div, !this._volumeExpanded);
}
/* -------------------------------------------- */
/**
* Helper method to render the expansion or collapse of playlists
* @private
*/
_collapse(el, collapse, speed = 250) {
const ol = el.querySelector(".playlist-sounds");
const icon = el.querySelector("i.collapse");
if (collapse) { // Collapse the sounds
$(ol).slideUp(speed, () => {
el.classList.add("collapsed");
icon.classList.replace("fa-angle-down", "fa-angle-up");
});
}
else { // Expand the sounds
$(ol).slideDown(speed, () => {
el.classList.remove("collapsed");
icon.classList.replace("fa-angle-up", "fa-angle-down");
});
}
}
/* -------------------------------------------- */
/**
* Handle Playlist playback state changes
* @param {MouseEvent} event The initial click event
* @param {boolean} playing Is the playlist now playing?
* @private
*/
_onPlaylistPlay(event, playing) {
const li = event.currentTarget.closest(".playlist");
const playlist = game.playlists.get(li.dataset.documentId);
if ( playing ) return playlist.playAll();
else return playlist.stopAll();
}
/* -------------------------------------------- */
/**
* Handle advancing the playlist to the next (or previous) sound
* @param {MouseEvent} event The initial click event
* @param {string} action The control action requested
* @private
*/
_onPlaylistSkip(event, action) {
const li = event.currentTarget.closest(".playlist");
const playlist = game.playlists.get(li.dataset.documentId);
return playlist.playNext(undefined, {direction: action === "playlist-forward" ? 1 : -1});
}
/* -------------------------------------------- */
/**
* Handle cycling the playback mode for a Playlist
* @param {MouseEvent} event The initial click event
* @private
*/
_onPlaylistToggleMode(event) {
const li = event.currentTarget.closest(".playlist");
const playlist = game.playlists.get(li.dataset.documentId);
return playlist.cycleMode();
}
/* -------------------------------------------- */
/**
* Handle Playlist track addition request
* @param {MouseEvent} event The initial click event
* @private
*/
_onSoundCreate(event) {
const li = $(event.currentTarget).parents('.playlist');
const playlist = game.playlists.get(li.data("documentId"));
const sound = new PlaylistSound({name: game.i18n.localize("SOUND.New")}, {parent: playlist});
sound.sheet.render(true, {top: li[0].offsetTop, left: window.innerWidth - 670});
}
/* -------------------------------------------- */
/**
* Modify the playback state of a Sound within a Playlist
* @param {MouseEvent} event The initial click event
* @param {string} action The sound control action performed
* @private
*/
_onSoundPlay(event, action) {
const li = event.currentTarget.closest(".sound");
const playlist = game.playlists.get(li.dataset.playlistId);
const sound = playlist.sounds.get(li.dataset.soundId);
switch ( action ) {
case "sound-play":
return playlist.playSound(sound);
case "sound-pause":
return sound.update({playing: false, pausedTime: sound.sound.currentTime});
case "sound-stop":
return playlist.stopSound(sound);
}
}
/* -------------------------------------------- */
/**
* Handle volume adjustments to sounds within a Playlist
* @param {Event} event The initial change event
* @private
*/
_onSoundVolume(event) {
event.preventDefault();
const slider = event.currentTarget;
const li = slider.closest(".sound");
const playlist = game.playlists.get(li.dataset.playlistId);
const playlistSound = playlist.sounds.get(li.dataset.soundId);
// Get the desired target volume
const volume = AudioHelper.inputToVolume(slider.value);
if ( volume === playlistSound.volume ) return;
// Immediately apply a local adjustment
playlistSound.updateSource({volume});
playlistSound.sound?.fade(playlistSound.effectiveVolume, {duration: PlaylistSound.VOLUME_DEBOUNCE_MS});
const tooltip = PlaylistDirectory.volumeToTooltip(volume);
slider.setAttribute("data-tooltip", tooltip);
game.tooltip.activate(slider, {text: tooltip});
// Debounce a change to the database
if ( playlistSound.isOwner ) playlistSound.debounceVolume(volume);
}
/* -------------------------------------------- */
/**
* Handle changes to the sound playback mode
* @param {Event} event The initial click event
* @private
*/
_onSoundToggleMode(event) {
event.preventDefault();
const li = event.currentTarget.closest(".sound");
const playlist = game.playlists.get(li.dataset.playlistId);
const sound = playlist.sounds.get(li.dataset.soundId);
return sound.update({repeat: !sound.repeat});
}
/* -------------------------------------------- */
_onPlayingPin() {
const location = this._playingLocation === "top" ? "bottom" : "top";
return game.settings.set("core", "playlist.playingLocation", location);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onSearchFilter(event, query, rgx, html) {
const isSearch = !!query;
const playlistIds = new Set();
const soundIds = new Set();
const folderIds = new Set();
const nameOnlySearch = (this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME);
// Match documents and folders
if ( isSearch ) {
let results = [];
if ( !nameOnlySearch ) results = this.collection.search({query: query});
// Match Playlists and Sounds
for ( let d of this.documents ) {
let matched = false;
for ( let s of d.sounds ) {
if ( s.playing || rgx.test(SearchFilter.cleanQuery(s.name)) ) {
soundIds.add(s._id);
matched = true;
}
}
if ( matched || d.playing || ( nameOnlySearch && rgx.test(SearchFilter.cleanQuery(d.name) )
|| results.some(r => r._id === d._id)) ) {
playlistIds.add(d._id);
if ( d.folder ) folderIds.add(d.folder._id);
}
}
// Include parent Folders
const folders = this.folders.sort((a, b) => b.depth - a.depth);
for ( let f of folders ) {
if ( folderIds.has(f.id) && f.folder ) folderIds.add(f.folder._id);
}
}
// Toggle each directory item
for ( let el of html.querySelectorAll(".directory-item") ) {
if ( el.classList.contains("global-volume") ) continue;
// Playlists
if ( el.classList.contains("document") ) {
const pid = el.dataset.documentId;
let playlistIsMatch = !isSearch || playlistIds.has(pid);
el.style.display = playlistIsMatch ? "flex" : "none";
// Sounds
const sounds = el.querySelector(".playlist-sounds");
for ( const li of sounds.children ) {
let soundIsMatch = !isSearch || soundIds.has(li.dataset.soundId);
li.style.display = soundIsMatch ? "flex" : "none";
if ( soundIsMatch ) {
playlistIsMatch = true;
}
}
const showExpanded = this._expanded.has(pid) || (isSearch && playlistIsMatch);
el.classList.toggle("collapsed", !showExpanded);
}
// Folders
else if ( el.classList.contains("folder") ) {
const hidden = isSearch && !folderIds.has(el.dataset.folderId);
el.style.display = hidden ? "none" : "flex";
const uuid = el.closest("li.folder").dataset.uuid;
const expanded = (isSearch && folderIds.has(el.dataset.folderId)) ||
(!isSearch && game.folders._expanded[uuid]);
el.classList.toggle("collapsed", !expanded);
}
}
}
/* -------------------------------------------- */
/**
* Update the displayed timestamps for all currently playing audio sources.
* Runs on an interval every 1000ms.
* @private
*/
_updateTimestamps() {
if ( !this._playingSounds.length ) return;
const playing = this.element.find("#currently-playing")[0];
if ( !playing ) return;
for ( let sound of this._playingSounds ) {
const li = playing.querySelector(`.sound[data-sound-id="${sound.id}"]`);
if ( !li ) continue;
// Update current and max playback time
const current = li.querySelector("span.current");
const ct = sound.playing ? sound.sound.currentTime : sound.pausedTime;
if ( current ) current.textContent = this._formatTimestamp(ct);
const max = li.querySelector("span.duration");
if ( max ) max.textContent = this._formatTimestamp(sound.sound.duration);
// Remove the loading spinner
const play = li.querySelector("a.pause i.fas");
if ( play.classList.contains("fa-spinner") ) {
play.classList.remove("fa-spin");
play.classList.replace("fa-spinner", "fa-pause");
}
}
}
/* -------------------------------------------- */
/**
* Format the displayed timestamp given a number of seconds as input
* @param {number} seconds The current playback time in seconds
* @returns {string} The formatted timestamp
* @private
*/
_formatTimestamp(seconds) {
if ( !Number.isFinite(seconds) ) return "∞";
seconds = seconds ?? 0;
let minutes = Math.floor(seconds / 60);
seconds = Math.round(seconds % 60);
return `${minutes}:${seconds.paddedString(2)}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
_contextMenu(html) {
super._contextMenu(html);
/**
* A hook event that fires when the context menu for a Sound in the PlaylistDirectory is constructed.
* @function getPlaylistDirectorySoundContext
* @memberof hookEvents
* @param {jQuery} html The HTML element to which the context options are attached
* @param {ContextMenuEntry[]} entryOptions The context menu entries
*/
ContextMenu.create(this, html, ".playlist .sound", this._getSoundContextOptions(), {hookName: "SoundContext"});
}
/* -------------------------------------------- */
/** @inheritdoc */
_getFolderContextOptions() {
const options = super._getFolderContextOptions();
options.findSplice(o => o.name === "OWNERSHIP.Configure");
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
options.findSplice(o => o.name === "OWNERSHIP.Configure");
options.unshift({
name: "PLAYLIST.Edit",
icon: '<i class="fas fa-edit"></i>',
callback: header => {
const li = header.closest(".directory-item");
const playlist = game.playlists.get(li.data("document-id"));
const sheet = playlist.sheet;
sheet.render(true, this.popOut ? {} : {
top: li[0].offsetTop - 24,
left: window.innerWidth - ui.sidebar.position.width - sheet.options.width - 10
});
}
});
return options;
}
/* -------------------------------------------- */
/**
* Get context menu options for individual sound effects
* @returns {Object} The context options for each sound
* @private
*/
_getSoundContextOptions() {
return [
{
name: "PLAYLIST.SoundEdit",
icon: '<i class="fas fa-edit"></i>',
callback: li => {
const playlistId = li.parents(".playlist").data("document-id");
const playlist = game.playlists.get(playlistId);
const sound = playlist.sounds.get(li.data("sound-id"));
const sheet = sound.sheet;
sheet.render(true, this.popOut ? {} : {
top: li[0].offsetTop - 24,
left: window.innerWidth - ui.sidebar.position.width - sheet.options.width - 10
});
}
},
{
name: "PLAYLIST.SoundPreload",
icon: '<i class="fas fa-download"></i>',
callback: li => {
const playlistId = li.parents(".playlist").data("document-id");
const playlist = game.playlists.get(playlistId);
const sound = playlist.sounds.get(li.data("sound-id"));
game.audio.preload(sound.path);
}
},
{
name: "PLAYLIST.SoundDelete",
icon: '<i class="fas fa-trash"></i>',
callback: li => {
const playlistId = li.parents(".playlist").data("document-id");
const playlist = game.playlists.get(playlistId);
const sound = playlist.sounds.get(li.data("sound-id"));
return sound.deleteDialog({
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720
});
}
}
];
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragStart(event) {
const target = event.currentTarget;
if ( target.classList.contains("sound-name") ) {
const sound = target.closest(".sound");
const document = game.playlists.get(sound.dataset.playlistId)?.sounds.get(sound.dataset.soundId);
event.dataTransfer.setData("text/plain", JSON.stringify(document.toDragData()));
}
else super._onDragStart(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
if ( data.type !== "PlaylistSound" ) return super._onDrop(event);
// Reference the target playlist and sound elements
const target = event.target.closest(".sound, .playlist");
if ( !target ) return false;
const sound = await PlaylistSound.implementation.fromDropData(data);
const playlist = sound.parent;
const otherPlaylistId = target.dataset.documentId || target.dataset.playlistId;
// Copying to another playlist.
if ( otherPlaylistId !== playlist.id ) {
const otherPlaylist = game.playlists.get(otherPlaylistId);
return PlaylistSound.implementation.create(sound.toObject(), {parent: otherPlaylist});
}
// If there's nothing to sort relative to, or the sound was dropped on itself, do nothing.
const targetId = target.dataset.soundId;
if ( !targetId || (targetId === sound.id) ) return false;
sound.sortRelative({
target: playlist.sounds.get(targetId),
siblings: playlist.sounds.filter(s => s.id !== sound.id)
});
}
}

View File

@@ -0,0 +1,29 @@
/**
* The sidebar directory which organizes and displays world-level RollTable documents.
* @extends {DocumentDirectory}
*/
class RollTableDirectory extends DocumentDirectory {
/** @override */
static documentName = "RollTable";
/* -------------------------------------------- */
/** @inheritdoc */
_getEntryContextOptions() {
let options = super._getEntryContextOptions();
// Add the "Roll" option
options = [
{
name: "TABLE.Roll",
icon: '<i class="fas fa-dice-d20"></i>',
callback: li => {
const table = game.tables.get(li.data("documentId"));
table.draw({roll: true, displayChat: true});
}
}
].concat(options);
return options;
}
}

View File

@@ -0,0 +1,122 @@
/**
* The sidebar directory which organizes and displays world-level Scene documents.
* @extends {DocumentDirectory}
*/
class SceneDirectory extends DocumentDirectory {
/** @override */
static documentName = "Scene";
/** @override */
static entryPartial = "templates/sidebar/scene-partial.html";
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
const options = super.defaultOptions;
options.renderUpdateKeys.push("background");
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
if ( !game.user.isGM ) return;
return super._render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
_getEntryContextOptions() {
let options = super._getEntryContextOptions();
options = [
{
name: "SCENES.View",
icon: '<i class="fas fa-eye"></i>',
condition: li => !canvas.ready || (li.data("documentId") !== canvas.scene.id),
callback: li => {
const scene = game.scenes.get(li.data("documentId"));
scene.view();
}
},
{
name: "SCENES.Activate",
icon: '<i class="fas fa-bullseye"></i>',
condition: li => game.user.isGM && !game.scenes.get(li.data("documentId")).active,
callback: li => {
const scene = game.scenes.get(li.data("documentId"));
scene.activate();
}
},
{
name: "SCENES.Configure",
icon: '<i class="fas fa-cogs"></i>',
callback: li => {
const scene = game.scenes.get(li.data("documentId"));
scene.sheet.render(true);
}
},
{
name: "SCENES.Notes",
icon: '<i class="fas fa-scroll"></i>',
condition: li => {
const scene = game.scenes.get(li.data("documentId"));
return !!scene.journal;
},
callback: li => {
const scene = game.scenes.get(li.data("documentId"));
const entry = scene.journal;
if ( entry ) {
const sheet = entry.sheet;
const options = {};
if ( scene.journalEntryPage ) options.pageId = scene.journalEntryPage;
sheet.render(true, options);
}
}
},
{
name: "SCENES.ToggleNav",
icon: '<i class="fas fa-compass"></i>',
condition: li => {
const scene = game.scenes.get(li.data("documentId"));
return game.user.isGM && ( !scene.active );
},
callback: li => {
const scene = game.scenes.get(li.data("documentId"));
scene.update({navigation: !scene.navigation});
}
},
{
name: "SCENES.GenerateThumb",
icon: '<i class="fas fa-image"></i>',
condition: li => {
const scene = game.scenes.get(li[0].dataset.documentId);
return (scene.background.src || scene.tiles.size) && !game.settings.get("core", "noCanvas");
},
callback: li => {
const scene = game.scenes.get(li[0].dataset.documentId);
scene.createThumbnail().then(data => {
scene.update({thumb: data.thumb}, {diff: false});
ui.notifications.info(game.i18n.format("SCENES.GenerateThumbSuccess", {name: scene.name}));
}).catch(err => ui.notifications.error(err.message));
}
}
].concat(options);
// Remove the ownership entry
options.findSplice(o => o.name === "OWNERSHIP.Configure");
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getFolderContextOptions() {
const options = super._getFolderContextOptions();
options.findSplice(o => o.name === "OWNERSHIP.Configure");
return options;
}
}

View File

@@ -0,0 +1,185 @@
/**
* The sidebar tab which displays various game settings, help messages, and configuration options.
* The Settings sidebar is the furthest-to-right using a triple-cogs icon.
* @extends {SidebarTab}
*/
class Settings extends SidebarTab {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "settings",
template: "templates/sidebar/settings.html",
title: "Settings"
});
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const context = await super.getData(options);
// Check for core update
let coreUpdate;
if ( game.user.isGM && game.data.coreUpdate.hasUpdate ) {
coreUpdate = game.i18n.format("SETUP.UpdateAvailable", {
type: game.i18n.localize("Software"),
channel: game.data.coreUpdate.channel,
version: game.data.coreUpdate.version
});
}
// Check for system update
let systemUpdate;
if ( game.user.isGM && game.data.systemUpdate.hasUpdate ) {
systemUpdate = game.i18n.format("SETUP.UpdateAvailable", {
type: game.i18n.localize("System"),
channel: game.data.system.title,
version: game.data.systemUpdate.version
});
}
const issues = CONST.DOCUMENT_TYPES.reduce((count, documentName) => {
const collection = CONFIG[documentName].collection.instance;
return count + collection.invalidDocumentIds.size;
}, 0) + Object.values(game.issues.packageCompatibilityIssues).reduce((count, {error}) => {
return count + error.length;
}, 0) + Object.keys(game.issues.usabilityIssues).length;
// Return rendering context
const isDemo = game.data.demoMode;
return foundry.utils.mergeObject(context, {
system: game.system,
release: game.data.release,
versionDisplay: game.release.display,
canConfigure: game.user.can("SETTINGS_MODIFY") && !isDemo,
canEditWorld: game.user.hasRole("GAMEMASTER") && !isDemo,
canManagePlayers: game.user.isGM && !isDemo,
canReturnSetup: game.user.hasRole("GAMEMASTER") && !isDemo,
modules: game.modules.reduce((n, m) => n + (m.active ? 1 : 0), 0),
issues,
isDemo,
coreUpdate,
systemUpdate
});
}
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
html.find("button[data-action]").click(this._onSettingsButton.bind(this));
html.find(".notification-pip.update").click(this._onUpdateNotificationClick.bind(this));
}
/* -------------------------------------------- */
/**
* Delegate different actions for different settings buttons
* @param {MouseEvent} event The originating click event
* @private
*/
_onSettingsButton(event) {
event.preventDefault();
const button = event.currentTarget;
switch (button.dataset.action) {
case "configure":
game.settings.sheet.render(true);
break;
case "modules":
new ModuleManagement().render(true);
break;
case "world":
new WorldConfig(game.world).render(true);
break;
case "players":
return ui.menu.items.players.onClick();
case "setup":
return game.shutDown();
case "support":
new SupportDetails().render(true);
break;
case "controls":
new KeybindingsConfig().render(true);
break;
case "tours":
new ToursManagement().render(true);
break;
case "docs":
new FrameViewer("https://foundryvtt.com/kb", {
title: "SIDEBAR.Documentation"
}).render(true);
break;
case "wiki":
new FrameViewer("https://foundryvtt.wiki/", {
title: "SIDEBAR.Wiki"
}).render(true);
break;
case "invitations":
new InvitationLinks().render(true);
break;
case "logout":
return ui.menu.items.logout.onClick();
}
}
/* -------------------------------------------- */
/**
* Executes with the update notification pip is clicked
* @param {MouseEvent} event The originating click event
* @private
*/
_onUpdateNotificationClick(event) {
event.preventDefault();
const key = event.target.dataset.action === "core-update" ? "CoreUpdateInstructions" : "SystemUpdateInstructions";
ui.notifications.notify(game.i18n.localize(`SETUP.${key}`));
}
}
/* -------------------------------------------- */
/**
* A simple window application which shows the built documentation pages within an iframe
* @type {Application}
*/
class FrameViewer extends Application {
constructor(url, options) {
super(options);
this.url = url;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
const options = super.defaultOptions;
const h = window.innerHeight * 0.9;
const w = Math.min(window.innerWidth * 0.9, 1200);
options.height = h;
options.width = w;
options.top = (window.innerHeight - h) / 2;
options.left = (window.innerWidth - w) / 2;
options.id = "documentation";
options.template = "templates/apps/documentation.html";
return options;
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
return {
src: this.url
};
}
/* -------------------------------------------- */
/** @override */
async close(options) {
this.element.find("#docs").remove();
return super.close(options);
}
}

View File

@@ -0,0 +1,595 @@
/* -------------------------------------------- */
/* HTML Template Loading */
/* -------------------------------------------- */
// Global template cache
_templateCache = {};
/**
* Get a template from the server by fetch request and caching the retrieved result
* @param {string} path The web-accessible HTML template URL
* @param {string} [id] An ID to register the partial with.
* @returns {Promise<Function>} A Promise which resolves to the compiled Handlebars template
*/
async function getTemplate(path, id) {
if ( !_templateCache.hasOwnProperty(path) ) {
await new Promise((resolve, reject) => {
game.socket.emit("template", path, resp => {
if ( resp.error ) return reject(new Error(resp.error));
const compiled = Handlebars.compile(resp.html);
Handlebars.registerPartial(id ?? path, compiled);
_templateCache[path] = compiled;
console.log(`Foundry VTT | Retrieved and compiled template ${path}`);
resolve(compiled);
});
});
}
return _templateCache[path];
}
/* -------------------------------------------- */
/**
* Load and cache a set of templates by providing an Array of paths
* @param {string[]|Object<string>} paths An array of template file paths to load, or an object of Handlebars partial
* IDs to paths.
* @returns {Promise<Function[]>}
*
* @example Loading a list of templates.
* ```js
* await loadTemplates(["templates/apps/foo.html", "templates/apps/bar.html"]);
* ```
* ```hbs
* <!-- Include a pre-loaded template as a partial -->
* {{> "templates/apps/foo.html" }}
* ```
*
* @example Loading an object of templates.
* ```js
* await loadTemplates({
* foo: "templates/apps/foo.html",
* bar: "templates/apps/bar.html"
* });
* ```
* ```hbs
* <!-- Include a pre-loaded template as a partial -->
* {{> foo }}
* ```
*/
async function loadTemplates(paths) {
let promises;
if ( foundry.utils.getType(paths) === "Object" ) promises = Object.entries(paths).map(([k, p]) => getTemplate(p, k));
else promises = paths.map(p => getTemplate(p));
return Promise.all(promises);
}
/* -------------------------------------------- */
/**
* Get and render a template using provided data and handle the returned HTML
* Support asynchronous file template file loading with a client-side caching layer
*
* Allow resolution of prototype methods and properties since this all occurs within the safety of the client.
* @see {@link https://handlebarsjs.com/api-reference/runtime-options.html#options-to-control-prototype-access}
*
* @param {string} path The file path to the target HTML template
* @param {Object} data A data object against which to compile the template
*
* @returns {Promise<string>} Returns the compiled and rendered template as a string
*/
async function renderTemplate(path, data) {
const template = await getTemplate(path);
return template(data || {}, {
allowProtoMethodsByDefault: true,
allowProtoPropertiesByDefault: true
});
}
/* -------------------------------------------- */
/* Handlebars Template Helpers */
/* -------------------------------------------- */
// Register Handlebars Extensions
HandlebarsIntl.registerWith(Handlebars);
/**
* A collection of Handlebars template helpers which can be used within HTML templates.
*/
class HandlebarsHelpers {
/**
* For checkboxes, if the value of the checkbox is true, add the "checked" property, otherwise add nothing.
* @returns {string}
*
* @example
* ```hbs
* <label>My Checkbox</label>
* <input type="checkbox" name="myCheckbox" {{checked myCheckbox}}>
* ```
*/
static checked(value) {
return Boolean(value) ? "checked" : "";
}
/* -------------------------------------------- */
/**
* For use in form inputs. If the supplied value is truthy, add the "disabled" property, otherwise add nothing.
* @returns {string}
*
* @example
* ```hbs
* <button type="submit" {{disabled myValue}}>Submit</button>
* ```
*/
static disabled(value) {
return value ? "disabled" : "";
}
/* -------------------------------------------- */
/**
* Concatenate a number of string terms into a single string.
* This is useful for passing arguments with variable names.
* @param {string[]} values The values to concatenate
* @returns {Handlebars.SafeString}
*
* @example Concatenate several string parts to create a dynamic variable
* ```hbs
* {{filePicker target=(concat "faces." i ".img") type="image"}}
* ```
*/
static concat(...values) {
const options = values.pop();
const join = options.hash?.join || "";
return new Handlebars.SafeString(values.join(join));
}
/* -------------------------------------------- */
/**
* Render a pair of inputs for selecting a color.
* @param {object} options Helper options
* @param {string} [options.name] The name of the field to create
* @param {string} [options.value] The current color value
* @param {string} [options.default] A default color string if a value is not provided
* @returns {Handlebars.SafeString}
*
* @example
* ```hbs
* {{colorPicker name="myColor" value=myColor default="#000000"}}
* ```
*/
static colorPicker(options) {
let {name, value} = options.hash;
name = name || "color";
value = value || "";
const safeValue = Color.from(value || options.hash.default || "#000000").css;
const html =
`<input class="color" type="text" name="${name}" value="${value}"/>
<input type="color" value="${safeValue}" data-edit="${name}"/>`;
return new Handlebars.SafeString(html);
}
/* -------------------------------------------- */
/**
* @typedef {object} TextEditorOptions
* @property {string} [target] The named target data element
* @property {boolean} [button] Include a button used to activate the editor later?
* @property {string} [class] A specific CSS class to add to the editor container
* @property {boolean} [editable=true] Is the text editor area currently editable?
* @property {string} [engine=tinymce] The editor engine to use, see {@link TextEditor.create}.
* @property {boolean} [collaborate=false] Whether to turn on collaborative editing features for ProseMirror.
*
* The below options are deprecated since v10 and should be avoided.
* @property {boolean} [owner] Is the current user an owner of the data?
* @property {boolean} [documents=true] Replace dynamic document links?
* @property {Object|Function} [rollData] The data object providing context for inline rolls
* @property {string} [content=""] The original HTML content as a string
*/
/**
* Construct an editor element for rich text editing with TinyMCE or ProseMirror.
* @param {[string, TextEditorOptions]} args The content to display and edit, followed by handlebars options.
* @returns {Handlebars.SafeString}
*
* @example
* ```hbs
* {{editor world.description target="description" button=false engine="prosemirror" collaborate=false}}
* ```
*/
static editor(...args) {
const options = args.pop();
let content = args.pop() ?? "";
const target = options.hash.target;
if ( !target ) throw new Error("You must define the name of a target field.");
const button = Boolean(options.hash.button);
const editable = "editable" in options.hash ? Boolean(options.hash.editable) : true;
/**
* @deprecated since v10
*/
if ( "content" in options.hash ) {
foundry.utils.logCompatibilityWarning("The content option for the editor handlebars helper has been deprecated. "
+ "Please pass the content in as the first option to the helper and ensure it has already been enriched by "
+ "TextEditor.enrichHTML if necessary", {since: 10, until: 12});
// Enrich the content
const documents = options.hash.documents !== false;
const owner = Boolean(options.hash.owner);
const rollData = options.hash.rollData;
content = TextEditor.enrichHTML(options.hash.content, {secrets: owner, documents, rollData, async: false});
}
// Construct the HTML
const editorClasses = ["editor-content", options.hash.class ?? null].filterJoin(" ");
let editorHTML = '<div class="editor">';
if ( button && editable ) editorHTML += '<a class="editor-edit"><i class="fas fa-edit"></i></a>';
let dataset = {
engine: options.hash.engine || "tinymce",
collaborate: !!options.hash.collaborate
};
if ( editable ) dataset.edit = target;
dataset = Object.entries(dataset).map(([k, v]) => `data-${k}="${v}"`).join(" ");
editorHTML += `<div class="${editorClasses}" ${dataset}>${content}</div></div>`;
return new Handlebars.SafeString(editorHTML);
}
/* -------------------------------------------- */
/**
* Render a file-picker button linked to an `<input>` field
* @param {object} options Helper options
* @param {string} [options.type] The type of FilePicker instance to display
* @param {string} [options.target] The field name in the target data
* @returns {Handlebars.SafeString|string}
*
* @example
* ```hbs
* {{filePicker type="image" target="img"}}
* ```
*/
static filePicker(options) {
const type = options.hash.type;
const target = options.hash.target;
if ( !target ) throw new Error("You must define the name of the target field.");
// Do not display the button for users who do not have browse permission
if ( game.world && !game.user.can("FILES_BROWSE" ) ) return "";
// Construct the HTML
const tooltip = game.i18n.localize("FILES.BrowseTooltip");
return new Handlebars.SafeString(`
<button type="button" class="file-picker" data-type="${type}" data-target="${target}" title="${tooltip}" tabindex="-1">
<i class="fas fa-file-import fa-fw"></i>
</button>`);
}
/* -------------------------------------------- */
/**
* A ternary expression that allows inserting A or B depending on the value of C.
* @param {boolean} criteria The test criteria
* @param {string} ifTrue The string to output if true
* @param {string} ifFalse The string to output if false
* @returns {string} The ternary result
*
* @example Ternary if-then template usage
* ```hbs
* {{ifThen true "It is true" "It is false"}}
* ```
*/
static ifThen(criteria, ifTrue, ifFalse) {
return criteria ? ifTrue : ifFalse;
}
/* -------------------------------------------- */
/**
* Translate a provided string key by using the loaded dictionary of localization strings.
* @returns {string}
*
* @example Translate a provided localization string, optionally including formatting parameters
* ```hbs
* <label>{{localize "ACTOR.Create"}}</label> <!-- "Create Actor" -->
* <label>{{localize "CHAT.InvalidCommand" command=foo}}</label> <!-- "foo is not a valid chat message command." -->
* ```
*/
static localize(value, options) {
if ( value instanceof Handlebars.SafeString ) value = value.toString();
const data = options.hash;
return foundry.utils.isEmpty(data) ? game.i18n.localize(value) : game.i18n.format(value, data);
}
/* -------------------------------------------- */
/**
* A string formatting helper to display a number with a certain fixed number of decimals and an explicit sign.
* @param {number} value A numeric value to format
* @param {object} options Additional options which customize the resulting format
* @param {number} [options.decimals=0] The number of decimal places to include in the resulting string
* @param {boolean} [options.sign=false] Whether to include an explicit "+" sign for positive numbers *
* @returns {Handlebars.SafeString} The formatted string to be included in a template
*
* @example
* ```hbs
* {{formatNumber 5.5}} <!-- 5.5 -->
* {{formatNumber 5.5 decimals=2}} <!-- 5.50 -->
* {{formatNumber 5.5 decimals=2 sign=true}} <!-- +5.50 -->
* ```
*/
static numberFormat(value, options) {
const dec = options.hash['decimals'] ?? 0;
const sign = options.hash['sign'] || false;
value = parseFloat(value).toFixed(dec);
if (sign ) return ( value >= 0 ) ? "+"+value : value;
return value;
}
/* --------------------------------------------- */
/**
* Render a form input field of type number with value appropriately rounded to step size.
* @returns {Handlebars.SafeString}
*
* @example
* ```hbs
* {{numberInput value name="numberField" step=1 min=0 max=10}}
* ```
*/
static numberInput(value, options) {
const properties = [];
for ( let k of ["class", "name", "placeholder", "min", "max"] ) {
if ( k in options.hash ) properties.push(`${k}="${options.hash[k]}"`);
}
const step = options.hash.step ?? "any";
properties.unshift(`step="${step}"`);
if ( options.hash.disabled === true ) properties.push("disabled");
if ( options.hash.readonly === true ) properties.push("readonly");
let safe = Number.isNumeric(value) ? Number(value) : "";
if ( Number.isNumeric(step) && (typeof safe === "number") ) safe = safe.toNearest(Number(step));
return new Handlebars.SafeString(`<input type="number" value="${safe}" ${properties.join(" ")}>`);
}
/* -------------------------------------------- */
/**
* A helper to create a set of radio checkbox input elements in a named set.
* The provided keys are the possible radio values while the provided values are human readable labels.
*
* @param {string} name The radio checkbox field name
* @param {object} choices A mapping of radio checkbox values to human readable labels
* @param {object} options Options which customize the radio boxes creation
* @param {string} options.checked Which key is currently checked?
* @param {boolean} options.localize Pass each label through string localization?
* @returns {Handlebars.SafeString}
*
* @example The provided input data
* ```js
* let groupName = "importantChoice";
* let choices = {a: "Choice A", b: "Choice B"};
* let chosen = "a";
* ```
*
* @example The template HTML structure
* ```hbs
* <div class="form-group">
* <label>Radio Group Label</label>
* <div class="form-fields">
* {{radioBoxes groupName choices checked=chosen localize=true}}
* </div>
* </div>
* ```
*/
static radioBoxes(name, choices, options) {
const checked = options.hash['checked'] || null;
const localize = options.hash['localize'] || false;
let html = "";
for ( let [key, label] of Object.entries(choices) ) {
if ( localize ) label = game.i18n.localize(label);
const isChecked = checked === key;
html += `<label class="checkbox"><input type="radio" name="${name}" value="${key}" ${isChecked ? "checked" : ""}> ${label}</label>`;
}
return new Handlebars.SafeString(html);
}
/* -------------------------------------------- */
/**
* Render a pair of inputs for selecting a value in a range.
* @param {object} options Helper options
* @param {string} [options.name] The name of the field to create
* @param {number} [options.value] The current range value
* @param {number} [options.min] The minimum allowed value
* @param {number} [options.max] The maximum allowed value
* @param {number} [options.step] The allowed step size
* @returns {Handlebars.SafeString}
*
* @example
* ```hbs
* {{rangePicker name="foo" value=bar min=0 max=10 step=1}}
* ```
*/
static rangePicker(options) {
let {name, value, min, max, step} = options.hash;
name = name || "range";
value = value ?? "";
if ( Number.isNaN(value) ) value = "";
const html =
`<input type="range" name="${name}" value="${value}" min="${min}" max="${max}" step="${step}"/>
<span class="range-value">${value}</span>`;
return new Handlebars.SafeString(html);
}
/* -------------------------------------------- */
/**
* A helper to assign an `<option>` within a `<select>` block as selected based on its value
* Escape the string as handlebars would, then escape any regexp characters in it
* @param {string} value The value of the option
* @returns {Handlebars.SafeString}
*
* @example
* ```hbs
* <select>
* {{#select selected}}
* <option value="a">Choice A</option>
* <option value="b">Choice B</option>
* {{/select}}
* </select>
*/
static select(selected, options) {
const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected));
const rgx = new RegExp(' value=[\"\']' + escapedValue + '[\"\']');
const html = options.fn(this);
return html.replace(rgx, "$& selected");
}
/* -------------------------------------------- */
/**
* A helper to create a set of &lt;option> elements in a &lt;select> block based on a provided dictionary.
* The provided keys are the option values while the provided values are human readable labels.
* This helper supports both single-select as well as multi-select input fields.
*
* @param {object|Array<object>>} choices A mapping of radio checkbox values to human-readable labels
* @param {object} options Helper options
* @param {string|string[]} [options.selected] Which key or array of keys that are currently selected?
* @param {boolean} [options.localize=false] Pass each label through string localization?
* @param {string} [options.blank] Add a blank option as the first option with this label
* @param {boolean} [options.sort] Sort the options by their label after localization
* @param {string} [options.nameAttr] Look up a property in the choice object values to use as the option value
* @param {string} [options.labelAttr] Look up a property in the choice object values to use as the option label
* @param {boolean} [options.inverted=false] Use the choice object value as the option value, and the key as the label
* instead of vice-versa
* @returns {Handlebars.SafeString}
*
* @example The provided input data
* ```js
* let choices = {a: "Choice A", b: "Choice B"};
* let value = "a";
* ```
* The template HTML structure
* ```hbs
* <select name="importantChoice">
* {{selectOptions choices selected=value localize=true}}
* </select>
* ```
* The resulting HTML
* ```html
* <select name="importantChoice">
* <option value="a" selected>Choice A</option>
* <option value="b">Choice B</option>
* </select>
* ```
*
* @example Using inverted choices
* ```js
* let choices = {"Choice A": "a", "Choice B": "b"};
* let value = "a";
* ```
* The template HTML structure
* ```hbs
* <select name="importantChoice">
* {{selectOptions choices selected=value inverted=true}}
* </select>
* ```
*
* @example Using nameAttr and labelAttr with objects
* ```js
* let choices = {foo: {key: "a", label: "Choice A"}, bar: {key: "b", label: "Choice B"}};
* let value = "b";
* ```
* The template HTML structure
* ```hbs
* <select name="importantChoice">
* {{selectOptions choices selected=value nameAttr="key" labelAttr="label"}}
* </select>
* ```
*
* @example Using nameAttr and labelAttr with arrays
* ```js
* let choices = [{key: "a", label: "Choice A"}, {key: "b", label: "Choice B"}];
* let value = "b";
* ```
* The template HTML structure
* ```hbs
* <select name="importantChoice">
* {{selectOptions choices selected=value nameAttr="key" labelAttr="label"}}
* </select>
* ```
*/
static selectOptions(choices, options) {
let {localize=false, selected=null, blank=null, sort=false, nameAttr, labelAttr, inverted} = options.hash;
selected = selected instanceof Array ? selected.map(String) : [String(selected)];
// Prepare the choices as an array of objects
const selectOptions = [];
if ( choices instanceof Array ) {
for ( const choice of choices ) {
const name = String(choice[nameAttr]);
let label = choice[labelAttr];
if ( localize ) label = game.i18n.localize(label);
selectOptions.push({name, label});
}
}
else {
for ( const choice of Object.entries(choices) ) {
const [key, value] = inverted ? choice.reverse() : choice;
const name = String(nameAttr ? value[nameAttr] : key);
let label = labelAttr ? value[labelAttr] : value;
if ( localize ) label = game.i18n.localize(label);
selectOptions.push({name, label});
}
}
// Sort the array of options
if ( sort ) selectOptions.sort((a, b) => a.label.localeCompare(b.label));
// Prepend a blank option
if ( blank !== null ) {
const label = localize ? game.i18n.localize(blank) : blank;
selectOptions.unshift({name: "", label});
}
// Create the HTML
let html = "";
for ( const option of selectOptions ) {
const label = Handlebars.escapeExpression(option.label);
const isSelected = selected.includes(option.name);
html += `<option value="${option.name}" ${isSelected ? "selected" : ""}>${label}</option>`;
}
return new Handlebars.SafeString(html);
}
}
// Register all handlebars helpers
Handlebars.registerHelper({
checked: HandlebarsHelpers.checked,
disabled: HandlebarsHelpers.disabled,
colorPicker: HandlebarsHelpers.colorPicker,
concat: HandlebarsHelpers.concat,
editor: HandlebarsHelpers.editor,
filePicker: HandlebarsHelpers.filePicker,
ifThen: HandlebarsHelpers.ifThen,
numberFormat: HandlebarsHelpers.numberFormat,
numberInput: HandlebarsHelpers.numberInput,
localize: HandlebarsHelpers.localize,
radioBoxes: HandlebarsHelpers.radioBoxes,
rangePicker: HandlebarsHelpers.rangePicker,
select: HandlebarsHelpers.select,
selectOptions: HandlebarsHelpers.selectOptions,
timeSince: foundry.utils.timeSince,
eq: (v1, v2) => v1 === v2,
ne: (v1, v2) => v1 !== v2,
lt: (v1, v2) => v1 < v2,
gt: (v1, v2) => v1 > v2,
lte: (v1, v2) => v1 <= v2,
gte: (v1, v2) => v1 >= v2,
not: pred => !pred,
and() {return Array.prototype.every.call(arguments, Boolean);},
or() {return Array.prototype.slice.call(arguments, 0, -1).some(Boolean);}
});

View File

@@ -0,0 +1,624 @@
/**
* A helper class to provide common functionality for working with the Web Audio API.
* https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API
* A singleton instance of this class is available as game#audio.
* @see Game#audio
*/
class AudioHelper {
constructor() {
if ( game.audio instanceof this.constructor ) {
throw new Error("You may not re-initialize the singleton AudioHelper. Use game.audio instead.");
}
/**
* The primary Audio Context used to play client-facing sounds.
* The context is undefined until the user's first gesture is observed.
* @type {AudioContext}
*/
this.context = undefined;
/**
* The set of AudioBuffer objects which are cached for different audio paths
* @type {Map<string,{buffer: AudioBuffer, lastAccessed: number, playing: boolean, size: number}>}
*/
this.buffers = new Map();
/**
* The set of singleton Sound instances which are cached for different audio paths
* @type {Map<string,Sound>}
*/
this.sounds = new Map();
/**
* Get a map of the Sound objects which are currently playing.
* @type {Map<number,Sound>}
*/
this.playing = new Map();
/**
* A user gesture must be registered before audio can be played.
* This Array contains the Sound instances which are requested for playback prior to a gesture.
* Once a gesture is observed, we begin playing all elements of this Array.
* @type {Function[]}
* @see Sound
*/
this.pending = [];
/**
* A flag for whether video playback is currently locked by awaiting a user gesture
* @type {boolean}
*/
this.locked = true;
/**
* Audio Context singleton used for analysing audio levels of each stream
* Only created if necessary to listen to audio streams.
*
* @type {AudioContext}
* @private
*/
this._audioContext = null;
/**
* Map of all streams that we listen to for determining the decibel levels.
* Used for analyzing audio levels of each stream.
* Format of the object stored is :
* {id:
* {
* stream: MediaStream,
* analyser: AudioAnalyser,
* interval: Number,
* callback: Function
* }
* }
*
* @type {Object}
* @private
*/
this._analyserStreams = {};
/**
* Interval ID as returned by setInterval for analysing the volume of streams
* When set to 0, means no timer is set.
* @type {number}
* @private
*/
this._analyserInterval = 0;
/**
* Fast Fourier Transform Array.
* Used for analysing the decibel level of streams. The array is allocated only once
* then filled by the analyser repeatedly. We only generate it when we need to listen to
* a stream's level, so we initialize it to null.
* @type {Float32Array}
* @private
*/
this._fftArray = null;
/**
* A Promise which resolves once the game audio API is unlocked and ready to use.
* @type {Promise<AudioContext>}
*/
this.unlock = this.awaitFirstGesture();
}
/**
* The Native interval for the AudioHelper to analyse audio levels from streams
* Any interval passed to startLevelReports() would need to be a multiple of this value.
* @type {number}
*/
static levelAnalyserNativeInterval = 50;
/**
* The cache size threshold after which audio buffers will be expired from the cache to make more room.
* 1 gigabyte, by default.
*/
static THRESHOLD_CACHE_SIZE_BYTES = Math.pow(1024, 3);
/**
* An internal tracker for the total size of the buffers cache.
* @type {number}
* @private
*/
#cacheSize = 0;
/* -------------------------------------------- */
/**
* Register client-level settings for global volume overrides
*/
static registerSettings() {
// Playlist Volume
game.settings.register("core", "globalPlaylistVolume", {
name: "Global Playlist Volume",
hint: "Define a global playlist volume modifier",
scope: "client",
config: false,
default: 1.0,
type: Number,
onChange: v => {
for ( let p of game.playlists ) {
for ( let s of p.sounds ) {
if ( s.playing ) s.sync();
}
}
game.audio._onChangeGlobalVolume("globalPlaylistVolume", v);
}
});
// Ambient Volume
game.settings.register("core", "globalAmbientVolume", {
name: "Global Ambient Volume",
hint: "Define a global ambient volume modifier",
scope: "client",
config: false,
default: 1.0,
type: Number,
onChange: v => {
if ( canvas.ready ) {
canvas.sounds.refresh({fade: 0});
for ( const mesh of canvas.primary.videoMeshes ) {
if ( mesh.object instanceof Tile ) mesh.sourceElement.volume = mesh.object.volume;
else mesh.sourceElement.volume = v;
}
}
game.audio._onChangeGlobalVolume("globalAmbientVolume", v);
}
});
// Interface Volume
game.settings.register("core", "globalInterfaceVolume", {
name: "Global Interface Volume",
hint: "Define a global interface volume modifier",
scope: "client",
config: false,
default: 0.5,
type: Number,
onChange: v => game.audio._onChangeGlobalVolume("globalInterfaceVolume", v)
});
}
/* -------------------------------------------- */
/**
* Create a Sound instance for a given audio source URL
* @param {object} options Audio creation options
* @param {string} options.src The source URL for the audio file
* @param {boolean} [options.singleton=true] Reuse an existing Sound for this source?
* @param {boolean} [options.preload=false] Begin loading the audio immediately?
* @param {boolean} [options.autoplay=false] Begin playing the audio as soon as it is ready?
* @param {object} [options.autoplayOptions={}] Additional options passed to the play method if autoplay is true
* @returns {Sound}
*/
create({src, singleton=true, preload=false, autoplay=false, autoplayOptions={}} = {}) {
let sound;
if ( singleton ) {
if ( this.sounds.has(src) ) sound = this.sounds.get(src);
else {
sound = new Sound(src);
this.sounds.set(src, sound);
}
} else {
sound = new Sound(src);
}
if ( preload ) sound.load({autoplay, autoplayOptions});
return sound;
}
/* -------------------------------------------- */
/**
* Test whether a source file has a supported audio extension type
* @param {string} src A requested audio source path
* @returns {boolean} Does the filename end with a valid audio extension?
*/
static hasAudioExtension(src) {
let rgx = new RegExp(`(\\.${Object.keys(CONST.AUDIO_FILE_EXTENSIONS).join("|\\.")})(\\?.*)?`, "i");
return rgx.test(src);
}
/* -------------------------------------------- */
/**
* Given an input file path, determine a default name for the sound based on the filename
* @param {string} src An input file path
* @returns {string} A default sound name for the path
*/
static getDefaultSoundName(src) {
const parts = src.split("/").pop().split(".");
parts.pop();
let name = decodeURIComponent(parts.join("."));
return name.replace(/[-_.]/g, " ").titleCase();
}
/* -------------------------------------------- */
/**
* Play a single Sound by providing its source.
* @param {string} src The file path to the audio source being played
* @param {object} [options] Additional options passed to Sound#play
* @returns {Promise<Sound>} The created Sound which is now playing
*/
async play(src, options) {
const sound = new Sound(src);
await sound.load();
sound.play(options);
return sound;
}
/* -------------------------------------------- */
/**
* Register an event listener to await the first mousemove gesture and begin playback once observed.
* @returns {Promise<AudioContext>} The unlocked audio context
*/
async awaitFirstGesture() {
if ( !this.locked ) return this.context;
await new Promise(resolve => {
for ( let eventName of ["contextmenu", "auxclick", "pointerdown", "pointerup", "keydown"] ) {
document.addEventListener(eventName, event => this._onFirstGesture(event, resolve), {once: true});
}
});
return this.context;
}
/* -------------------------------------------- */
/**
* Request that other connected clients begin preloading a certain sound path.
* @param {string} src The source file path requested for preload
* @returns {Promise<Sound>} A Promise which resolves once the preload is complete
*/
preload(src) {
if ( !src || !AudioHelper.hasAudioExtension(src) ) {
throw new Error(`Invalid audio source path ${src} provided for preload request`);
}
game.socket.emit("preloadAudio", src);
return this.constructor.preloadSound(src);
}
/* -------------------------------------------- */
/* Buffer Caching */
/* -------------------------------------------- */
/**
* Retrieve an AudioBuffer from the buffers cache, if it is available
* @param {string} src The buffer audio source path
* @returns {AudioBuffer} The AudioBuffer instance if cached, otherwise undefined
*/
getCache(src) {
const cache = this.buffers.get(src);
if ( cache ) {
cache.lastAccessed = Date.now();
return cache.buffer;
}
}
/* -------------------------------------------- */
/**
* Update the last accessed time and playing status of a cached buffer.
* @param {string} src The buffer audio source path
* @param {boolean} playing Is the buffer currently playing?
*/
updateCache(src, playing=false) {
const buffer = this.buffers.get(src);
if ( !buffer ) return;
buffer.playing = playing;
buffer.lastAccessed = Date.now();
}
/* -------------------------------------------- */
/**
* Insert an AudioBuffer into the buffers cache.
* See https://padenot.github.io/web-audio-perf/#memory-profiling
* @param {string} src The buffer audio source path
* @param {AudioBuffer} buffer The AudioBuffer instance
*/
setCache(src, buffer) {
const existing = this.buffers.get(src);
if ( existing ) return existing.lastAccessed = Date.now();
const size = buffer.length * buffer.numberOfChannels * 4;
this.buffers.set(src, {buffer, lastAccessed: Date.now(), playing: false, size});
this.#cacheSize += size;
this.#expireCache();
}
/* -------------------------------------------- */
/**
* Expire buffers from the cache when the total cache size exceeds a specified threshold.
* Buffers which were least recently accessed are removed first, provided they are not currently playing.
* @private
*/
#expireCache() {
if ( this.#cacheSize < this.constructor.THRESHOLD_CACHE_SIZE_BYTES ) return;
const entries = Array.from(this.buffers.entries());
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed); // Oldest to newest
for ( const [key, entry] of entries ) {
if ( entry.playing ) continue; // Don't expire buffers which are currently playing
console.debug(`Expiring AudioBuffer for ${key}`);
this.buffers.delete(key);
this.#cacheSize -= entry.size;
if ( this.#cacheSize < this.constructor.THRESHOLD_CACHE_SIZE_BYTES ) break;
}
}
/* -------------------------------------------- */
/* Socket Listeners and Handlers */
/* -------------------------------------------- */
/**
* Open socket listeners which transact ChatMessage data
* @param socket
*/
static _activateSocketListeners(socket) {
socket.on("playAudio", audioData => this.play(audioData, false));
socket.on("preloadAudio", src => this.preloadSound(src));
}
/* -------------------------------------------- */
/**
* Play a one-off sound effect which is not part of a Playlist
*
* @param {Object} data An object configuring the audio data to play
* @param {string} data.src The audio source file path, either a public URL or a local path relative to the public directory
* @param {number} data.volume The volume level at which to play the audio, between 0 and 1.
* @param {boolean} data.autoplay Begin playback of the audio effect immediately once it is loaded.
* @param {boolean} data.loop Loop the audio effect and continue playing it until it is manually stopped.
* @param {object|boolean} socketOptions Options which only apply when emitting playback over websocket.
* As a boolean, emits (true) or does not emit (false) playback to all other clients
* As an object, can configure which recipients should receive the event.
* @param {string[]} [socketOptions.recipients] An array of user IDs to push audio playback to. All users by default.
*
* @returns {Sound} A Sound instance which controls audio playback.
*
* @example Play the sound of a locked door for all players
* ```js
* AudioHelper.play({src: "sounds/lock.wav", volume: 0.8, loop: false}, true);
* ```
*/
static play(data, socketOptions) {
const audioData = foundry.utils.mergeObject({
src: null,
volume: 1.0,
loop: false
}, data, {insertKeys: true});
// Push the sound to other clients
const push = socketOptions && (socketOptions !== false);
if ( push ) {
socketOptions = foundry.utils.getType(socketOptions) === "Object" ? socketOptions : {};
if ( "recipients" in socketOptions && !Array.isArray(socketOptions.recipients)) {
throw new Error("Socket recipients must be an array of User IDs");
}
game.socket.emit("playAudio", audioData, socketOptions);
}
// Backwards compatibility, if autoplay was passed as false take no further action
if ( audioData.autoplay === false ) return;
// Play the sound locally
return game.audio.play(audioData.src, {
volume: (audioData.volume ?? 1) * game.settings.get("core", "globalInterfaceVolume"),
loop: audioData.loop
});
}
/* -------------------------------------------- */
/**
* Begin loading the sound for a provided source URL adding its
* @param {string} src The audio source path to preload
* @returns {Promise<Sound>} The created and loaded Sound ready for playback
*/
static async preloadSound(src) {
const sound = game.audio.create({
src: src,
preload: true,
singleton: true
});
return sound.load();
}
/* -------------------------------------------- */
/**
* Returns the volume value based on a range input volume control's position.
* This is using an exponential approximation of the logarithmic nature of audio level perception
* @param {number|string} value Value between [0, 1] of the range input
* @param {number} [order=1.5] The exponent of the curve
* @returns {number}
*/
static inputToVolume(value, order=1.5) {
return Math.pow(parseFloat(value), order);
}
/* -------------------------------------------- */
/**
* Counterpart to inputToVolume()
* Returns the input range value based on a volume
* @param {number} volume Value between [0, 1] of the volume level
* @param {number} [order=1.5] The exponent of the curve
* @returns {number}
*/
static volumeToInput(volume, order=1.5) {
return Math.pow(volume, 1 / order);
}
/* -------------------------------------------- */
/* Audio Stream Analysis */
/* -------------------------------------------- */
/**
* Returns a singleton AudioContext if one can be created.
* An audio context may not be available due to limited resources or browser compatibility
* in which case null will be returned
*
* @returns {AudioContext} A singleton AudioContext or null if one is not available
*/
getAudioContext() {
if ( this._audioContext ) return this._audioContext;
try {
// Use one Audio Context for all the analysers.
return new (AudioContext || webkitAudioContext)();
} catch(err) {
console.log("Could not create AudioContext. Will not be able to analyse stream volumes.");
}
return null;
}
/* -------------------------------------------- */
/**
* Registers a stream for periodic reports of audio levels.
* Once added, the callback will be called with the maximum decibel level of
* the audio tracks in that stream since the last time the event was fired.
* The interval needs to be a multiple of AudioHelper.levelAnalyserNativeInterval which defaults at 50ms
*
* @param {string} id An id to assign to this report. Can be used to stop reports
* @param {MediaStream} stream The MediaStream instance to report activity on.
* @param {Function} callback The callback function to call with the decibel level. `callback(dbLevel)`
* @param {number} interval (optional) The interval at which to produce reports.
* @param {number} smoothing (optional) The smoothingTimeConstant to set on the audio analyser. Refer to AudioAnalyser API docs.
* @returns {boolean} Returns whether or not listening to the stream was successful
*/
startLevelReports(id, stream, callback, interval = 50, smoothing = 0.1) {
if ( !stream || !id ) return;
let audioContext = this.getAudioContext();
if (audioContext === null) return false;
// Clean up any existing report with the same ID
this.stopLevelReports(id);
// Make sure this stream has audio tracks, otherwise we can't connect the analyser to it
if (stream.getAudioTracks().length === 0) return false;
// Create the analyser
let analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = smoothing;
// Connect the analyser to the MediaStreamSource
audioContext.createMediaStreamSource(stream).connect(analyser);
this._analyserStreams[id] = {
stream,
analyser,
interval,
callback,
// Used as a counter of 50ms increments in case the interval is more than 50
_lastEmit: 0
};
// Ensure the analyser timer is started as we have at least one valid stream to listen to
this._ensureAnalyserTimer();
return true;
}
/* -------------------------------------------- */
/**
* Stop sending audio level reports
* This stops listening to a stream and stops sending reports.
* If we aren't listening to any more streams, cancel the global analyser timer.
* @param {string} id The id of the reports that passed to startLevelReports.
*/
stopLevelReports(id) {
delete this._analyserStreams[id];
if ( foundry.utils.isEmpty(this._analyserStreams) ) this._cancelAnalyserTimer();
}
/* -------------------------------------------- */
/**
* Ensures the global analyser timer is started
*
* We create only one timer that runs every 50ms and only create it if needed, this is meant to optimize things
* and avoid having multiple timers running if we want to analyse multiple streams at the same time.
* I don't know if it actually helps much with performance but it's expected that limiting the number of timers
* running at the same time is good practice and with JS itself, there's a potential for a timer congestion
* phenomenon if too many are created.
* @private
*/
_ensureAnalyserTimer() {
if (this._analyserInterval === 0) {
this._analyserInterval = setInterval(this._emitVolumes.bind(this), AudioHelper.levelAnalyserNativeInterval);
}
}
/* -------------------------------------------- */
/**
* Cancel the global analyser timer
* If the timer is running and has become unnecessary, stops it.
* @private
*/
_cancelAnalyserTimer() {
if (this._analyserInterval !== 0) {
clearInterval(this._analyserInterval);
this._analyserInterval = 0;
}
}
/* -------------------------------------------- */
/**
* Capture audio level for all speakers and emit a webrtcVolumes custom event with all the volume levels
* detected since the last emit.
* The event's detail is in the form of {userId: decibelLevel}
* @private
*/
_emitVolumes() {
for (let id in this._analyserStreams) {
const analyserStream = this._analyserStreams[id];
if (++analyserStream._lastEmit < analyserStream.interval / AudioHelper.levelAnalyserNativeInterval) continue;
// Create the Fast Fourier Transform Array only once. Assume all analysers use the same fftSize
if (this._fftArray === null) this._fftArray = new Float32Array(analyserStream.analyser.frequencyBinCount);
// Fill the array
analyserStream.analyser.getFloatFrequencyData(this._fftArray);
let maxDecibel = Math.max(...this._fftArray);
analyserStream.callback(maxDecibel, this._fftArray);
analyserStream._lastEmit = 0;
}
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/**
* Handle the first observed user gesture
* @param {Event} event The mouse-move event which enables playback
* @param {Function} resolve The Promise resolution function
* @private
*/
_onFirstGesture(event, resolve) {
if ( this.locked === false ) return resolve();
this.context = new AudioContext();
this.locked = false;
if ( this.pending.length ) {
console.log(`${vtt} | Activating pending audio playback with user gesture.`);
this.pending.forEach(fn => fn());
this.pending = [];
}
return resolve();
}
/* -------------------------------------------- */
/**
* Additional standard callback events that occur whenever a global volume slider is adjusted
* @param {string} key The setting key
* @param {number} volume The new volume level
* @private
*/
_onChangeGlobalVolume(key, volume) {
Hooks.callAll(`${key}Changed`, volume);
}
}

View File

@@ -0,0 +1,333 @@
/**
* An AudioSourceNode container which handles the strategy of node type to use for playback.
* Used by the Sound interface which controls playback.
* This class is for internal use only and should not be used by external callers.
*/
class AudioContainer {
constructor(src) {
/**
* The audio source path
* @type {string}
*/
this.src = src;
}
/**
* The Audio Node used to control this sound
* @type {AudioBufferSourceNode|MediaElementAudioSourceNode}
*/
sourceNode = undefined;
/**
* The GainNode used to control volume
* @type {GainNode}
*/
gainNode = undefined;
/**
* Is this container using an AudioBuffer?
* @type {boolean}
*/
isBuffer = false;
/**
* Whether we have attempted to load the audio node or not, and whether it failed.
* @see {LOAD_STATES}
* @type {number}
*/
loadState = AudioContainer.LOAD_STATES.NONE;
/**
* Is the audio source currently playing?
* @type {boolean}
*/
playing = false;
/**
* Should the audio source loop?
* @type {boolean}
* @private
*/
_loop = false;
get loop() {
return this._loop;
}
set loop(looping) {
this._loop = looping;
if ( !this.sourceNode ) return;
if ( this.isBuffer ) this.sourceNode.loop = looping;
}
/**
* The maximum duration, in seconds, for which an AudioBuffer will be used.
* Otherwise, a streaming media element will be used.
* @type {number}
*/
static MAX_BUFFER_DURATION = 10 * 60; // 10 Minutes
/**
* The sequence of container loading states.
* @enum {number}
*/
static LOAD_STATES = {
FAILED: -1,
NONE: 0,
LOADING: 1,
LOADED: 2
};
/**
* Has the audio file been loaded either fully or for streaming.
* @type {boolean}
*/
get loaded() {
return this.loadState === AudioContainer.LOAD_STATES.LOADED;
}
/**
* Did the audio file fail to load.
* @type {boolean}
*/
get failed() {
return this.loadState === AudioContainer.LOAD_STATES.FAILED;
}
/* -------------------------------------------- */
/* Container Attributes */
/* -------------------------------------------- */
/**
* A reference to the AudioBuffer if the sourceNode is a AudioBufferSourceNode.
* @returns {AudioBuffer}
*/
get buffer() {
return this.sourceNode.buffer;
}
/* -------------------------------------------- */
/**
* The game audio context used throughout the application.
* @returns {AudioContext}
*/
get context() {
return game.audio.context;
}
/* -------------------------------------------- */
/**
* The total duration of the audio source in seconds
* @type {number}
*/
get duration() {
if ( !this.loaded || this.failed ) return undefined;
if ( this.isBuffer ) return this.buffer.duration;
else return this.element.duration;
}
/* -------------------------------------------- */
/**
* A reference to the HTMLMediaElement, if the sourceNode is a MediaElementAudioSourceNode.
* @returns {HTMLMediaElement}
*/
get element() {
return this.sourceNode.mediaElement;
}
/* -------------------------------------------- */
/* Constructor Methods */
/* -------------------------------------------- */
/**
* Load the source node required for playback of this audio source
* @returns {Promise<void>}
*/
async load() {
this.loadState = AudioContainer.LOAD_STATES.LOADING;
this.sourceNode = await this._createNode();
if ( !this.sourceNode ) {
this.loadState = AudioContainer.LOAD_STATES.FAILED;
return;
}
this.gainNode = this.context.createGain();
this.sourceNode.connect(this.gainNode);
this.gainNode.connect(this.context.destination);
this.loadState = AudioContainer.LOAD_STATES.LOADED;
}
/* -------------------------------------------- */
/**
* Create the initial audio node used for playback.
* Determine the node type to use based on cached state and sound duration.
* @returns {AudioBufferSourceNode|MediaElementAudioSourceNode}
* @private
*/
async _createNode() {
// If an audio buffer is cached, use an AudioBufferSourceNode
const cached = game.audio.getCache(this.src);
if ( cached ) return this._createAudioBufferSourceNode(cached);
// Otherwise, check the element duration using HTML5 audio
let element;
try {
element = await this._createAudioElement();
} catch(err) {
console.error(`${vtt} | Failed to load audio node:`, err);
return;
}
const isShort = element.duration && (element.duration <= this.constructor.MAX_BUFFER_DURATION);
// For short sounds create and cache the audio buffer and use an AudioBufferSourceNode
if ( isShort ) {
const buffer = await this.createAudioBuffer(this.src);
console.debug(`${vtt} | Constructing audio buffer source node - ${this.src}`);
return this._createAudioBufferSourceNode(buffer);
}
// For long or streamed sounds, use a MediaElementAudioSourceNode
console.debug(`${vtt} | Constructing audio element source node - ${this.src}`);
return this._createMediaElementAudioSourceNode(element);
}
/* -------------------------------------------- */
/**
* Create an Audio source node using a buffered array.
* @param {string} src The source URL from which to create the buffer
* @returns {Promise<AudioBuffer>} The created and decoded buffer
*/
async createAudioBuffer(src) {
console.debug(`${vtt} | Loading audio buffer - ${src}`);
const response = await foundry.utils.fetchWithTimeout(src);
const arrayBuffer = await response.arrayBuffer();
return this.context.decodeAudioData(arrayBuffer);
}
/* -------------------------------------------- */
/**
* Create a AudioBufferSourceNode using a provided AudioBuffer
* @private
*/
_createAudioBufferSourceNode(buffer) {
this.isBuffer = true;
game.audio.setCache(this.src, buffer);
return new AudioBufferSourceNode(this.context, {buffer});
}
/* -------------------------------------------- */
/**
* Create an HTML5 Audio element which has loaded the metadata for the provided source.
* @returns {Promise<HTMLAudioElement>}
* @private
*/
async _createAudioElement() {
console.debug(`${vtt} | Loading audio element - ${this.src}`);
return new Promise((resolve, reject) => {
const element = new Audio();
element.autoplay = false;
element.crossOrigin = "anonymous";
element.onloadedmetadata = () => resolve(element);
element.onload = () => resolve(element);
element.onerror = reject;
element.src = this.src;
});
}
/* -------------------------------------------- */
/**
* Create a MediaElementAudioSourceNode using a provided HTMLAudioElement
* @private
*/
_createMediaElementAudioSourceNode(element) {
this.isBuffer = false;
return new MediaElementAudioSourceNode(this.context, {mediaElement: element});
}
/* -------------------------------------------- */
/* Playback Methods */
/* -------------------------------------------- */
/**
* Begin playback for the source node.
* @param {number} [offset] The desired start time
* @param {Function} [onended] A callback function for when playback concludes naturally
*/
play(offset=0, onended=undefined) {
if ( this.isBuffer ) {
this.sourceNode.onended = () => this._onEnd(onended);
this.sourceNode.start(0, offset);
game.audio.updateCache(this.src, true);
}
else {
this.element.currentTime = offset;
this.element.onended = () => this._onEnd(onended);
this.element.play();
}
this.playing = true;
}
/* -------------------------------------------- */
/**
* Terminate playback for the source node.
*/
stop() {
this.playing = false;
if ( this.isBuffer ) {
this.sourceNode.onended = undefined;
this.sourceNode.stop(0);
game.audio.updateCache(this.src, false);
}
this._unloadMediaNode();
}
/* -------------------------------------------- */
/**
* Perform cleanup actions when the sound has finished playing. For
* MediaElementAudioSourceNodes, this also means optionally restarting if
* the sound is supposed to loop.
* @param {Function} onended A callback provided by the owner of the container that gets fired when the sound ends.
* @private
*/
_onEnd(onended) {
if ( !this.isBuffer && this._loop ) return this.play(0, onended);
onended();
this.stop();
}
/* -------------------------------------------- */
/**
* Unload the MediaElementAudioSourceNode to terminate any ongoing
* connections.
* @private
*/
_unloadMediaNode() {
console.debug(`${vtt} | Unloading audio element - ${this.src}`);
const element = this.element;
// Deconstruct the audio pipeline
this.sourceNode.disconnect(this.gainNode);
this.gainNode.disconnect(this.context.destination);
this.loadState = AudioContainer.LOAD_STATES.NONE;
this.sourceNode = this.gainNode = undefined;
// Unload media streams
if ( !this.isBuffer ) {
element.onended = undefined;
element.pause();
element.src = "";
element.remove();
}
}
}

View File

@@ -0,0 +1,471 @@
/**
* The Sound class is used to control the playback of audio sources using the Web Audio API.
*/
class Sound {
constructor(src, {container}={}) {
/**
* The numeric identifier for accessing this node
* @type {number}
*/
this.id = ++Sound._nodeId;
/**
* The audio source path
* @type {string}
*/
this.src = src;
/**
* The AudioContainer which controls playback
* @type {AudioContainer}
*/
this.container = container || new AudioContainer(this.src);
}
/* -------------------------------------------- */
/**
* The time in seconds at which playback was started
* @type {number}
*/
startTime = undefined;
/**
* The time in seconds at which playback was paused
* @type {number}
*/
pausedTime = undefined;
/**
* Registered event callbacks
* @type {{stop: {}, start: {}, end: {}, pause: {}, load: {}}}
*/
events = {
end: {},
pause: {},
start: {},
stop: {},
load: {}
};
/**
* The registered event handler id for this Sound.
* Incremented each time a callback is registered.
* @type {number}
* @private
*/
_eventHandlerId = 1;
/**
* If this Sound source is currently in the process of loading, this attribute contains a Promise that will resolve
* when the loading process completes.
* @type {Promise}
*/
loading = undefined;
/**
* A collection of scheduled events recorded as window timeout IDs
* @type {Set<number>}
* @private
*/
_scheduledEvents = new Set();
/**
* A global audio node ID used to quickly reference a specific audio node
* @type {number}
* @private
*/
static _nodeId = 0;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* A convenience reference to the sound context used by the application
* @returns {AudioContext}
*/
get context() {
return game.audio.context;
}
/**
* A reference to the audio source node being used by the AudioContainer
* @returns {AudioBufferSourceNode|MediaElementAudioSourceNode}
*/
get node() {
return this.container.sourceNode;
}
/**
* A reference to the GainNode parameter which controls volume
* @type {AudioParam}
*/
get gain() {
return this.container.gainNode?.gain;
}
/**
* The current playback time of the sound
* @returns {number}
*/
get currentTime() {
if ( !this.playing ) return undefined;
if ( this.pausedTime ) return this.pausedTime;
let time = this.context.currentTime - this.startTime;
if ( Number.isFinite(this.duration) ) time %= this.duration;
return time;
}
/**
* The total sound duration, in seconds
* @type {number}
*/
get duration() {
return this.container.duration;
}
/**
* Is the contained audio node loaded and ready for playback?
* @type {boolean}
*/
get loaded() {
return this.container.loaded;
}
/**
* Did the contained audio node fail to load?
* @type {boolean}
*/
get failed() {
return this.container.failed;
}
/**
* Is the audio source currently playing?
* @type {boolean}
*/
get playing() {
return this.container.playing;
}
/**
* Is the Sound current looping?
* @type {boolean}
*/
get loop() {
return this.container.loop;
}
set loop(looping) {
this.container.loop = looping;
}
/**
* The volume at which the Sound is playing
* @returns {number}
*/
get volume() {
return this.gain?.value;
}
set volume(value) {
if ( !this.node || !Number.isNumeric(value) ) return;
const ct = this.context.currentTime;
this.gain.cancelScheduledValues(ct);
this.gain.setValueAtTime(this.gain.value = value, ct); // Important - immediately "schedule" the current value
}
/* -------------------------------------------- */
/* Control Methods */
/* -------------------------------------------- */
/**
* Fade the volume for this sound between its current level and a desired target volume
* @param {number} volume The desired target volume level between 0 and 1
* @param {object} [options={}] Additional options that configure the fade operation
* @param {number} [options.duration=1000] The duration of the fade effect in milliseconds
* @param {number} [options.from] A volume level to start from, the current volume by default
* @param {string} [options.type=linear] The type of fade easing, "linear" or "exponential"
* @returns {Promise<void>} A Promise that resolves after the requested fade duration
*/
async fade(volume, {duration=1000, from, type="linear"}={}) {
if ( !this.gain ) return;
const ramp = this.gain[`${type}RampToValueAtTime`];
if ( !ramp ) throw new Error(`Invalid fade type ${type} requested`);
const ct = this.context.currentTime;
// Schedule the fade
this.gain.cancelScheduledValues(ct); // Cancel any existing transition
this.gain.setValueAtTime(from ?? this.gain.value, ct); // Important - immediately "schedule" the current value
ramp.call(this.gain, volume, ct + (duration / 1000));
return new Promise(resolve => window.setTimeout(resolve, duration));
}
/* -------------------------------------------- */
/**
* Load the audio source, creating an AudioBuffer.
* Audio loading is idempotent, it can be requested multiple times but only the first load request will be honored.
* @param {object} [options={}] Additional options which affect resource loading
* @param {boolean} [options.autoplay=false] Automatically begin playback of the audio source once loaded
* @param {object} [options.autoplayOptions] Additional options passed to the play method when loading is complete
* @returns {Promise<Sound>} The Sound once its source audio buffer is loaded
*/
async load({autoplay=false, autoplayOptions={}}={}) {
// Delay audio loading until after an observed user gesture
if ( game.audio.locked ) {
console.log(`${vtt} | Delaying load of sound ${this.src} until after first user gesture`);
await new Promise(resolve => game.audio.pending.push(resolve));
}
// Currently loading
if ( this.loading instanceof Promise ) await this.loading;
// If loading is required, cache the promise for idempotency
if ( !this.container || this.container.loadState === AudioContainer.LOAD_STATES.NONE ) {
this.loading = this.container.load();
await this.loading;
this.loading = undefined;
}
// Trigger automatic playback actions
if ( autoplay ) this.play(autoplayOptions);
return this;
}
/* -------------------------------------------- */
/**
* Begin playback for the sound node
* @param {object} [options={}] Options which configure playback
* @param {boolean} [options.loop=false] Whether to loop the audio automatically
* @param {number} [options.offset] A specific offset in seconds at which to begin playback
* @param {number} [options.volume] The desired volume at which to begin playback
* @param {number} [options.fade=0] Fade volume changes over a desired duration in milliseconds
*/
play({loop=false, offset, volume, fade=0}={}) {
if ( this.failed ) {
this._onEnd();
return;
}
if ( !this.loaded ) {
return console.warn(`You cannot play Sound ${this.src} before it has loaded`);
}
// If we are still awaiting the first user interaction, add this playback to a pending queue
if ( game.audio.locked ) {
console.log(`${vtt} | Delaying playback of sound ${this.src} until after first user gesture`);
return game.audio.pending.push(() => this.play({loop, offset, volume}));
}
// Adjust volume and looping
const adjust = () => {
this.loop = loop;
if ( (volume !== undefined) && (volume !== this.volume) ) {
if ( fade ) return this.fade(volume, {duration: fade});
else this.volume = volume;
}
};
// If the sound is already playing, and a specific offset is not provided, do nothing
if ( this.playing ) {
if ( offset === undefined ) return adjust();
this.stop();
}
// Configure playback
offset = offset ?? this.pausedTime ?? 0;
if ( Number.isFinite(this.duration) ) offset %= this.duration;
this.startTime = this.context.currentTime - offset;
this.pausedTime = undefined;
// Start playback
this.volume = 0; // Start volume at 0
this.container.play(offset, this._onEnd.bind(this));
adjust(); // Adjust to the desired volume
this._onStart();
}
/* -------------------------------------------- */
/**
* Pause playback, remembering the playback position in order to resume later.
*/
pause() {
this.pausedTime = this.currentTime;
this.startTime = undefined;
this.container.stop();
this._onPause();
}
/* -------------------------------------------- */
/**
* Stop playback, fully resetting the Sound to a non-playing state.
*/
stop() {
if ( this.playing === false ) return;
this.pausedTime = undefined;
this.startTime = undefined;
this.container.stop();
this._onStop();
}
/* -------------------------------------------- */
/**
* Schedule a function to occur at the next occurrence of a specific playbackTime for this Sound.
* @param {Function} fn A function that will be called with this Sound as its single argument
* @param {number} playbackTime The desired playback time at which the function should be called
* @returns {Promise<null>} A Promise which resolves once the scheduled function has been called
*
* @example Schedule audio playback changes
* ```js
* sound.schedule(() => console.log("Do something exactly 30 seconds into the track"), 30);
* sound.schedule(() => console.log("Do something next time the track loops back to the beginning"), 0);
* sound.schedule(() => console.log("Do something 5 seconds before the end of the track"), sound.duration - 5);
* ```
*/
schedule(fn, playbackTime) {
const now = this.currentTime;
playbackTime = Math.clamped(playbackTime, 0, this.duration);
if ( (playbackTime < now) && Number.isFinite(duration) ) playbackTime += this.duration;
const deltaMS = Math.max(0, (playbackTime - now) * 1000);
return new Promise(resolve => {
const timeoutId = setTimeout(() => {
this._scheduledEvents.delete(timeoutId);
fn(this);
return resolve();
}, deltaMS);
this._scheduledEvents.add(timeoutId);
});
}
/* -------------------------------------------- */
/* Event Emitter */
/* -------------------------------------------- */
/**
* Trigger registered callback functions for a specific event name.
* @param {string} eventName The event name being emitted
*/
emit(eventName) {
const events = this.events[eventName]
if ( !events ) return;
for ( let [fnId, callback] of Object.entries(events) ) {
callback.fn(this);
if ( callback.once ) delete events[fnId];
}
}
/* -------------------------------------------- */
/**
* Deactivate an event handler which was previously registered for a specific event
* @param {string} eventName The event name being deactivated
* @param {number|Function} fn The callback ID or callback function being un-registered
*/
off(eventName, fn) {
const events = this.events[eventName];
if ( !events ) return;
if ( Number.isNumeric(fn) ) delete events[fn];
for ( let [id, f] of Object.entries(events) ) {
if ( f === fn ) {
delete events[id];
break;
}
}
}
/* -------------------------------------------- */
/**
* Register an event handler to take actions for a certain Sound event.
* @param {string} eventName The event name being deactivated
* @param {Function} fn The callback function to trigger when the event occurs
* @param {object} [options={}] Additional options that affect callback registration
* @param {boolean} [options.once=false] Trigger the callback once only and automatically un-register it
*/
on(eventName, fn, {once=false}={}) {
return this._registerForEvent(eventName, {fn, once});
}
/* -------------------------------------------- */
/**
* Register a new callback function for a certain event. For internal use only.
* @private
*/
_registerForEvent(eventName, callback) {
const events = this.events[eventName];
if ( !events ) return;
const fnId = this._eventHandlerId++;
events[fnId] = callback;
return fnId;
}
/* -------------------------------------------- */
/**
* Cancel all pending scheduled events.
* @private
*/
_clearEvents() {
for ( let timeoutId of this._scheduledEvents ) {
window.clearTimeout(timeoutId);
}
this._scheduledEvents.clear();
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/**
* Called when playback concludes naturally
* @protected
*/
_onEnd() {
this._clearEvents();
game.audio.playing.delete(this.id);
this.emit("end");
}
/**
* Called when the audio buffer is first loaded
* @protected
*/
_onLoad() {
this.emit("load");
}
/**
* Called when playback is paused
* @protected
*/
_onPause() {
this._clearEvents();
this.emit("pause");
}
/**
* Called when the sound begins playing
* @protected
*/
_onStart() {
game.audio.playing.set(this.id, this);
this.emit("start");
}
/**
* Called when playback is stopped (prior to naturally reaching the end)
* @protected
*/
_onStop() {
this._clearEvents();
game.audio.playing.delete(this.id);
this.emit("stop");
}
}

View File

@@ -0,0 +1,264 @@
/**
* An interface for an Audio/Video client which is extended to provide broadcasting functionality.
* @interface
* @param {AVMaster} master The master orchestration instance
* @param {AVSettings} settings The audio/video settings being used
*/
class AVClient {
constructor(master, settings) {
/**
* The master orchestration instance
* @type {AVMaster}
*/
this.master = master;
/**
* The active audio/video settings being used
* @type {AVSettings}
*/
this.settings = settings;
}
/* -------------------------------------------- */
/**
* Is audio broadcasting push-to-talk enabled?
* @returns {boolean}
*/
get isVoicePTT() {
return this.settings.client.voice.mode === "ptt";
}
/**
* Is audio broadcasting always enabled?
* @returns {boolean}
*/
get isVoiceAlways() {
return this.settings.client.voice.mode === "always";
}
/**
* Is audio broadcasting voice-activation enabled?
* @returns {boolean}
*/
get isVoiceActivated() {
return this.settings.client.voice.mode === "activity";
}
/**
* Is the current user muted?
* @returns {boolean}
*/
get isMuted() {
return this.settings.client.users[game.user.id]?.muted;
}
/* -------------------------------------------- */
/* Connection */
/* -------------------------------------------- */
/**
* One-time initialization actions that should be performed for this client implementation.
* This will be called only once when the Game object is first set-up.
* @returns {Promise<void>}
*/
async initialize() {
throw Error("The initialize() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Connect to any servers or services needed in order to provide audio/video functionality.
* Any parameters needed in order to establish the connection should be drawn from the settings object.
* This function should return a boolean for whether the connection attempt was successful.
* @returns {Promise<boolean>} Was the connection attempt successful?
*/
async connect() {
throw Error("The connect() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Disconnect from any servers or services which are used to provide audio/video functionality.
* This function should return a boolean for whether a valid disconnection occurred.
* @returns {Promise<boolean>} Did a disconnection occur?
*/
async disconnect() {
throw Error("The disconnect() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/* Device Discovery */
/* -------------------------------------------- */
/**
* Provide an Object of available audio sources which can be used by this implementation.
* Each object key should be a device id and the key should be a human-readable label.
* @returns {Promise<{object}>}
*/
async getAudioSinks() {
return this._getSourcesOfType("audiooutput");
}
/* -------------------------------------------- */
/**
* Provide an Object of available audio sources which can be used by this implementation.
* Each object key should be a device id and the key should be a human-readable label.
* @returns {Promise<{object}>}
*/
async getAudioSources() {
return this._getSourcesOfType("audioinput");
}
/* -------------------------------------------- */
/**
* Provide an Object of available video sources which can be used by this implementation.
* Each object key should be a device id and the key should be a human-readable label.
* @returns {Promise<{object}>}
*/
async getVideoSources() {
return this._getSourcesOfType("videoinput");
}
/* -------------------------------------------- */
/**
* Obtain a mapping of available device sources for a given type.
* @param {string} kind The type of device source being requested
* @returns {Promise<{object}>}
* @private
*/
async _getSourcesOfType(kind) {
if ( !("mediaDevices" in navigator) ) return {};
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.reduce((obj, device) => {
if ( device.kind === kind ) {
obj[device.deviceId] = device.label || game.i18n.localize("WEBRTC.UnknownDevice");
}
return obj;
}, {});
}
/* -------------------------------------------- */
/* Track Manipulation */
/* -------------------------------------------- */
/**
* Return an array of Foundry User IDs which are currently connected to A/V.
* The current user should also be included as a connected user in addition to all peers.
* @returns {string[]} The connected User IDs
*/
getConnectedUsers() {
throw Error("The getConnectedUsers() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Provide a MediaStream instance for a given user ID
* @param {string} userId The User id
* @returns {MediaStream|null} The MediaStream for the user, or null if the user does not have one
*/
getMediaStreamForUser(userId) {
throw Error("The getMediaStreamForUser() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Provide a MediaStream for monitoring a given user's voice volume levels.
* @param {string} userId The User ID.
* @returns {MediaStream|null} The MediaStream for the user, or null if the user does not have one.
*/
getLevelsStreamForUser(userId) {
throw new Error("An AVClient subclass must define the getLevelsStreamForUser method");
}
/* -------------------------------------------- */
/**
* Is outbound audio enabled for the current user?
* @returns {boolean}
*/
isAudioEnabled() {
throw Error("The isAudioEnabled() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Is outbound video enabled for the current user?
* @returns {boolean}
*/
isVideoEnabled() {
throw Error("The isVideoEnabled() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Set whether the outbound audio feed for the current game user is enabled.
* This method should be used when the user marks themselves as muted or if the gamemaster globally mutes them.
* @param {boolean} enable Whether the outbound audio track should be enabled (true) or disabled (false)
*/
toggleAudio(enable) {
throw Error("The toggleAudio() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Set whether the outbound audio feed for the current game user is actively broadcasting.
* This can only be true if audio is enabled, but may be false if using push-to-talk or voice activation modes.
* @param {boolean} broadcast Whether outbound audio should be sent to connected peers or not?
*/
toggleBroadcast(broadcast) {
throw Error("The toggleBroadcast() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Set whether the outbound video feed for the current game user is enabled.
* This method should be used when the user marks themselves as hidden or if the gamemaster globally hides them.
* @param {boolean} enable Whether the outbound video track should be enabled (true) or disabled (false)
*/
toggleVideo(enable) {
throw Error("The toggleVideo() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/**
* Set the Video Track for a given User ID to a provided VideoElement
* @param {string} userId The User ID to set to the element
* @param {HTMLVideoElement} videoElement The HTMLVideoElement to which the video should be set
*/
async setUserVideo(userId, videoElement) {
throw Error("The setUserVideo() method must be defined by an AVClient subclass.");
}
/* -------------------------------------------- */
/* Settings and Configuration */
/* -------------------------------------------- */
/**
* Handle changes to A/V configuration settings.
* @param {object} changed The settings which have changed
*/
onSettingsChanged(changed) {}
/* -------------------------------------------- */
/**
* Replace the local stream for each connected peer with a re-generated MediaStream.
*/
async updateLocalStream() {
throw Error("The updateLocalStream() method must be defined by an AVClient subclass.");
}
}

View File

@@ -0,0 +1,499 @@
/**
* An implementation of the AVClient which uses the simple-peer library and the Foundry socket server for signaling.
* Credit to bekit#4213 for identifying simple-peer as a viable technology and providing a POC implementation.
* @extends {AVClient}
*/
class SimplePeerAVClient extends AVClient {
/**
* The local Stream which captures input video and audio
* @type {MediaStream}
*/
localStream = null;
/**
* The dedicated audio stream used to measure volume levels for voice activity detection.
* @type {MediaStream}
*/
levelsStream = null;
/**
* A mapping of connected peers
* @type {Map}
*/
peers = new Map();
/**
* A mapping of connected remote streams
* @type {Map}
*/
remoteStreams = new Map();
/**
* Has the client been successfully initialized?
* @type {boolean}
* @private
*/
_initialized = false;
/**
* Is outbound broadcast of local audio enabled?
* @type {boolean}
*/
audioBroadcastEnabled = false;
/**
* The polling interval ID for connected users that might have unexpectedly dropped out of our peer network.
* @type {number|null}
*/
_connectionPoll = null;
/* -------------------------------------------- */
/* Required AVClient Methods */
/* -------------------------------------------- */
/** @override */
async connect() {
await this._connect();
clearInterval(this._connectionPoll);
this._connectionPoll = setInterval(this._connect.bind(this), CONFIG.WebRTC.connectedUserPollIntervalS * 1000);
return true;
}
/* -------------------------------------------- */
/**
* Try to establish a peer connection with each user connected to the server.
* @private
*/
_connect() {
const promises = [];
for ( let user of game.users ) {
if ( user.isSelf || !user.active ) continue;
promises.push(this.initializePeerStream(user.id));
}
return Promise.all(promises);
}
/* -------------------------------------------- */
/** @override */
async disconnect() {
clearInterval(this._connectionPoll);
this._connectionPoll = null;
await this.disconnectAll();
return true;
}
/* -------------------------------------------- */
/** @override */
async initialize() {
if ( this._initialized ) return;
console.debug(`Initializing SimplePeer client connection`);
// Initialize the local stream
await this.initializeLocalStream();
// Set up socket listeners
this.activateSocketListeners();
// Register callback to close peer connections when the window is closed
window.addEventListener("beforeunload", ev => this.disconnectAll());
// Flag the client as initialized
this._initialized = true;
}
/* -------------------------------------------- */
/** @override */
getConnectedUsers() {
return [...Array.from(this.peers.keys()), game.userId];
}
/* -------------------------------------------- */
/** @override */
getMediaStreamForUser(userId) {
return userId === game.user.id ? this.localStream : this.remoteStreams.get(userId);
}
/* -------------------------------------------- */
/** @override */
getLevelsStreamForUser(userId) {
return userId === game.userId ? this.levelsStream : this.getMediaStreamForUser(userId);
}
/* -------------------------------------------- */
/** @override */
isAudioEnabled() {
return !!this.localStream?.getAudioTracks().length;
}
/* -------------------------------------------- */
/** @override */
isVideoEnabled() {
return !!this.localStream?.getVideoTracks().length;
}
/* -------------------------------------------- */
/** @override */
toggleAudio(enabled) {
const stream = this.localStream;
if ( !stream ) return;
// If "always on" broadcasting is not enabled, don't proceed
if ( !this.audioBroadcastEnabled || this.isVoicePTT ) return;
// Enable active broadcasting
return this.toggleBroadcast(enabled);
}
/* -------------------------------------------- */
/** @override */
toggleBroadcast(enabled) {
const stream = this.localStream;
if ( !stream ) return;
console.debug(`[SimplePeer] Toggling broadcast of outbound audio: ${enabled}`);
this.audioBroadcastEnabled = enabled;
for ( let t of stream.getAudioTracks() ) {
t.enabled = enabled;
}
}
/* -------------------------------------------- */
/** @override */
toggleVideo(enabled) {
const stream = this.localStream;
if ( !stream ) return;
console.debug(`[SimplePeer] Toggling broadcast of outbound video: ${enabled}`);
for (const track of stream.getVideoTracks()) {
track.enabled = enabled;
}
}
/* -------------------------------------------- */
/** @override */
async setUserVideo(userId, videoElement) {
const stream = this.getMediaStreamForUser(userId);
// Set the stream as the video element source
if ("srcObject" in videoElement) videoElement.srcObject = stream;
else videoElement.src = window.URL.createObjectURL(stream); // for older browsers
// Forward volume to the configured audio sink
if ( videoElement.sinkId === undefined ) {
return console.warn(`[SimplePeer] Your web browser does not support output audio sink selection`);
}
const requestedSink = this.settings.get("client", "audioSink");
await videoElement.setSinkId(requestedSink).catch(err => {
console.warn(`[SimplePeer] An error occurred when requesting the output audio device: ${requestedSink}`);
})
}
/* -------------------------------------------- */
/* Local Stream Management */
/* -------------------------------------------- */
/**
* Initialize a local media stream for the current user
* @returns {Promise<MediaStream>}
*/
async initializeLocalStream() {
console.debug(`[SimplePeer] Initializing local media stream for current User`);
// If there is already an existing local media stream, terminate it
if ( this.localStream ) this.localStream.getTracks().forEach(t => t.stop());
this.localStream = null;
if ( this.levelsStream ) this.levelsStream.getTracks().forEach(t => t.stop());
this.levelsStream = null;
// Determine whether the user can send audio
const audioSrc = this.settings.get("client", "audioSrc");
const canBroadcastAudio = this.master.canUserBroadcastAudio(game.user.id);
const audioParams = (audioSrc && (audioSrc !== "disabled") && canBroadcastAudio) ? {
deviceId: { ideal: audioSrc }
} : false;
// Configure whether the user can send video
const videoSrc = this.settings.get("client", "videoSrc");
const canBroadcastVideo = this.master.canUserBroadcastVideo(game.user.id);
const videoParams = (videoSrc && (videoSrc !== "disabled") && canBroadcastVideo) ? {
deviceId: { ideal: videoSrc },
width: { ideal: 320 },
height: { ideal: 240 }
} : false;
// FIXME: Firefox does not allow you to request a specific device, you can only use whatever the browser allows
// https://bugzilla.mozilla.org/show_bug.cgi?id=1443294#c7
if ( navigator.userAgent.match(/Firefox/) ) {
delete videoParams["deviceId"];
}
if ( !videoParams && !audioParams ) return null;
let stream = await this._createMediaStream({video: videoParams, audio: audioParams});
if ( (videoParams && audioParams) && (stream instanceof Error) ) {
// Even if the game is set to both audio and video, the user may not have one of those devices, or they might have
// blocked access to one of them. In those cases we do not want to prevent A/V loading entirely, so we must try
// each of them separately to see what is available.
if ( audioParams ) stream = await this._createMediaStream({video: false, audio: audioParams});
if ( (stream instanceof Error) && videoParams ) {
stream = await this._createMediaStream({video: videoParams, audio: false});
}
}
if ( stream instanceof Error ) {
const error = new Error(`[SimplePeer] Unable to acquire user media stream: ${stream.message}`);
error.stack = stream.stack;
console.error(error);
return null;
}
this.localStream = stream;
this.levelsStream = stream.clone();
this.levelsStream.getVideoTracks().forEach(t => this.levelsStream.removeTrack(t));
return stream;
}
/* -------------------------------------------- */
/**
* Attempt to create local media streams.
* @param {{video: object, audio: object}} params Parameters for the getUserMedia request.
* @returns {Promise<MediaStream|Error>} The created MediaStream or an error.
* @private
*/
async _createMediaStream(params) {
try {
return await navigator.mediaDevices.getUserMedia(params);
} catch(err) {
return err;
}
}
/* -------------------------------------------- */
/* Peer Stream Management */
/* -------------------------------------------- */
/**
* Listen for Audio/Video updates on the av socket to broker connections between peers
*/
activateSocketListeners() {
game.socket.on("av", (request, userId) => {
if ( request.userId !== game.user.id ) return; // The request is not for us, this shouldn't happen
switch ( request.action ) {
case "peer-signal":
if ( request.activity ) this.master.settings.handleUserActivity(userId, request.activity);
return this.receiveSignal(userId, request.data);
case "peer-close":
return this.disconnectPeer(userId);
}
});
}
/* -------------------------------------------- */
/**
* Initialize a stream connection with a new peer
* @param {string} userId The Foundry user ID for which the peer stream should be established
* @returns {Promise<SimplePeer>} A Promise which resolves once the peer stream is initialized
*/
async initializePeerStream(userId) {
const peer = this.peers.get(userId);
if ( peer?.connected || peer?._connecting ) return peer;
return this.connectPeer(userId, true);
}
/* -------------------------------------------- */
/**
* Receive a request to establish a peer signal with some other User id
* @param {string} userId The Foundry user ID who is requesting to establish a connection
* @param {object} data The connection details provided by SimplePeer
*/
receiveSignal(userId, data) {
console.debug(`[SimplePeer] Receiving signal from User [${userId}] to establish initial connection`);
let peer = this.peers.get(userId);
if ( !peer ) peer = this.connectPeer(userId, false);
peer.signal(data);
}
/* -------------------------------------------- */
/**
* Connect to a peer directly, either as the initiator or as the receiver
* @param {string} userId The Foundry user ID with whom we are connecting
* @param {boolean} isInitiator Is the current user initiating the connection, or responding to it?
* @returns {SimplePeer} The constructed and configured SimplePeer instance
*/
connectPeer(userId, isInitiator=false) {
// Create the SimplePeer instance for this connection
const peer = this._createPeerConnection(userId, isInitiator);
this.peers.set(userId, peer);
// Signal to request that a remote user establish a connection with us
peer.on("signal", data => {
console.debug(`[SimplePeer] Sending signal to User [${userId}] to establish initial connection`);
game.socket.emit("av", {
action: "peer-signal",
userId: userId,
data: data,
activity: this.master.settings.getUser(game.userId)
}, {recipients: [userId]});
});
// Receive a stream provided by a peer
peer.on("stream", stream => {
console.debug(`[SimplePeer] Received media stream from User [${userId}]`);
this.remoteStreams.set(userId, stream);
this.master.render();
});
// Close a connection with a current peer
peer.on("close", () => {
console.debug(`[SimplePeer] Closed connection with remote User [${userId}]`);
return this.disconnectPeer(userId);
});
// Handle errors
peer.on("error", err => {
if ( err.code !== "ERR_DATA_CHANNEL" ) {
const error = new Error(`[SimplePeer] An unexpected error occurred with User [${userId}]: ${err.message}`);
error.stack = err.stack;
console.error(error);
}
if ( peer.connected ) return this.disconnectPeer(userId);
});
this.master.render();
return peer;
}
/* -------------------------------------------- */
/**
* Create the SimplePeer instance for the desired peer connection.
* Modules may implement more advanced connection strategies by overriding this method.
* @param {string} userId The Foundry user ID with whom we are connecting
* @param {boolean} isInitiator Is the current user initiating the connection, or responding to it?
* @private
*/
_createPeerConnection(userId, isInitiator) {
const options = {
initiator: isInitiator,
stream: this.localStream
};
this._setupCustomTURN(options);
return new SimplePeer(options);
}
/* -------------------------------------------- */
/**
* Setup the custom TURN relay to be used in subsequent calls if there is one configured.
* TURN credentials are mandatory in WebRTC.
* @param {object} options The SimplePeer configuration object.
* @private
*/
_setupCustomTURN(options) {
const { url, type, username, password } = this.settings.world.turn;
if ( (type !== "custom") || !url || !username || !password ) return;
const iceServer = { username, urls: url, credential: password };
options.config = { iceServers: [iceServer] };
}
/* -------------------------------------------- */
/**
* Disconnect from a peer by stopping current stream tracks and destroying the SimplePeer instance
* @param {string} userId The Foundry user ID from whom we are disconnecting
* @returns {Promise<void>} A Promise which resolves once the disconnection is complete
*/
async disconnectPeer(userId) {
// Stop audio and video tracks from the remote stream
const remoteStream = this.remoteStreams.get(userId);
if ( remoteStream ) {
this.remoteStreams.delete(userId);
for ( let track of remoteStream.getTracks() ) {
await track.stop();
}
}
// Remove the peer
const peer = this.peers.get(userId);
if ( peer ) {
this.peers.delete(userId);
await peer.destroy();
}
// Re-render the UI on disconnection
this.master.render();
}
/* -------------------------------------------- */
/**
* Disconnect from all current peer streams
* @returns {Promise<Array>} A Promise which resolves once all peers have been disconnected
*/
async disconnectAll() {
const promises = [];
for ( let userId of this.peers.keys() ) {
promises.push(this.disconnectPeer(userId));
}
return Promise.all(promises);
}
/* -------------------------------------------- */
/* Settings and Configuration */
/* -------------------------------------------- */
/** @override */
async onSettingsChanged(changed) {
const keys = new Set(Object.keys(foundry.utils.flattenObject(changed)));
// Change audio or video sources
const sourceChange = ["client.videoSrc", "client.audioSrc"].some(k => keys.has(k));
if ( sourceChange ) await this.updateLocalStream();
// Change voice broadcasting mode
const modeChange = ["client.voice.mode", `client.users.${game.user.id}.muted`].some(k => keys.has(k));
if ( modeChange ) {
const isAlways = this.settings.client.voice.mode === "always";
this.toggleAudio(isAlways && this.master.canUserShareAudio(game.user.id));
this.master.broadcast(isAlways);
this.master._initializeUserVoiceDetection(changed.client.voice?.mode);
ui.webrtc.setUserIsSpeaking(game.user.id, this.master.broadcasting);
}
// Re-render the AV camera view
const renderChange = ["client.audioSink", "client.muteAll", "client.disableVideo"].some(k => keys.has(k));
if ( sourceChange || renderChange ) this.master.render();
}
/* -------------------------------------------- */
/** @inheritdoc */
async updateLocalStream() {
const oldStream = this.localStream;
await this.initializeLocalStream();
for ( let peer of this.peers.values() ) {
if ( oldStream ) peer.removeStream(oldStream);
if ( this.localStream ) peer.addStream(this.localStream);
}
// FIXME: This is a cheat, should be handled elsewhere
this.master._initializeUserVoiceDetection(this.settings.client.voice.mode);
}
}

View File

@@ -0,0 +1,467 @@
/**
* The master Audio/Video controller instance.
* This is available as the singleton game.webrtc
*
* @param {AVSettings} settings The Audio/Video settings to use
*/
class AVMaster {
constructor() {
this.settings = new AVSettings();
this.config = new AVConfig(this);
/**
* The Audio/Video client class
* @type {AVClient}
*/
this.client = new CONFIG.WebRTC.clientClass(this, this.settings);
/**
* A flag to track whether the current user is actively broadcasting their microphone.
* @type {boolean}
*/
this.broadcasting = false;
/**
* Flag to determine if we are connected to the signalling server or not.
* This is required for synchronization between connection and reconnection attempts.
* @type {boolean}
*/
this._connected = false;
/**
* The cached connection promise.
* This is required to prevent re-triggering a connection while one is already in progress.
* @type {Promise<boolean>|null}
* @private
*/
this._connecting = null;
/**
* A flag to track whether the A/V system is currently in the process of reconnecting.
* This occurs if the connection is lost or interrupted.
* @type {boolean}
* @private
*/
this._reconnecting = false;
// Other internal flags
this._speakingData = {speaking: false, volumeHistories: []};
this._pttMuteTimeout = 0;
}
/* -------------------------------------------- */
get mode() {
return this.settings.world.mode;
}
/* -------------------------------------------- */
/* Initialization */
/* -------------------------------------------- */
/**
* Connect to the Audio/Video client.
* @return {Promise<boolean>} Was the connection attempt successful?
*/
async connect() {
if ( this._connecting ) return this._connecting;
const connect = async () => {
// Disconnect from any existing session
await this.disconnect();
// Activate the connection
if ( this.mode === AVSettings.AV_MODES.DISABLED ) return false;
// Initialize Client state
await this.client.initialize();
// Connect to the client
const connected = await this.client.connect();
if ( !connected ) return false;
console.log(`${vtt} | Connected to the ${this.client.constructor.name} Audio/Video client.`);
// Initialize local broadcasting
this._initialize();
return this._connected = connected;
};
return this._connecting = connect().finally(() => this._connecting = null);
}
/* -------------------------------------------- */
/**
* Disconnect from the Audio/Video client.
* @return {Promise<boolean>} Whether an existing connection was terminated?
*/
async disconnect() {
if ( !this._connected ) return false;
this._connected = this._reconnecting = false;
await this.client.disconnect();
console.log(`${vtt} | Disconnected from the ${this.client.constructor.name} Audio/Video client.`);
return true;
}
/* -------------------------------------------- */
/**
* Callback actions to take when the user becomes disconnected from the server.
* @return {Promise<void>}
*/
async reestablish() {
if ( !this._connected ) return;
ui.notifications.warn("WEBRTC.ConnectionLostWarning", {localize: true});
await this.disconnect();
// Attempt to reconnect
while ( this._reconnecting ) {
await this.connect();
if ( this._connected ) {
this._reconnecting = true;
break;
}
await new Promise(resolve => setTimeout(resolve, this._reconnectPeriodMS));
}
}
/* -------------------------------------------- */
/**
* Initialize the local broadcast state.
* @private
*/
_initialize() {
const client = this.settings.client;
const voiceMode = client.voice.mode;
// Initialize voice detection
this._initializeUserVoiceDetection(voiceMode);
// Reset the speaking history for the user
this._resetSpeakingHistory(game.user.id);
// Set the initial state of outbound audio and video streams
const isAlways = voiceMode === "always";
this.client.toggleAudio(isAlways && client.audioSrc && this.canUserShareAudio(game.user.id));
this.client.toggleVideo(client.videoSrc && this.canUserShareVideo(game.user.id));
this.broadcast(isAlways);
// Update the display of connected A/V
ui.webrtc.render();
}
/* -------------------------------------------- */
/* Permissions */
/* -------------------------------------------- */
/**
* A user can broadcast audio if the AV mode is compatible and if they are allowed to broadcast.
* @param {string} userId
* @return {boolean}
*/
canUserBroadcastAudio(userId) {
if ( [AVSettings.AV_MODES.DISABLED, AVSettings.AV_MODES.VIDEO].includes(this.mode) ) return false;
const user = this.settings.getUser(userId);
return user && user.canBroadcastAudio;
}
/* -------------------------------------------- */
/**
* A user can share audio if they are allowed to broadcast and if they have not muted themselves or been blocked.
* @param {string} userId
* @return {boolean}
*/
canUserShareAudio(userId) {
if ( [AVSettings.AV_MODES.DISABLED, AVSettings.AV_MODES.VIDEO].includes(this.mode) ) return false;
const user = this.settings.getUser(userId);
return user && user.canBroadcastAudio && !(user.muted || user.blocked);
}
/* -------------------------------------------- */
/**
* A user can broadcast video if the AV mode is compatible and if they are allowed to broadcast.
* @param {string} userId
* @return {boolean}
*/
canUserBroadcastVideo(userId) {
if ( [AVSettings.AV_MODES.DISABLED, AVSettings.AV_MODES.AUDIO].includes(this.mode) ) return false;
const user = this.settings.getUser(userId);
return user && user.canBroadcastVideo;
}
/* -------------------------------------------- */
/**
* A user can share video if they are allowed to broadcast and if they have not hidden themselves or been blocked.
* @param {string} userId
* @return {boolean}
*/
canUserShareVideo(userId) {
if ( [AVSettings.AV_MODES.DISABLED, AVSettings.AV_MODES.AUDIO].includes(this.mode) ) return false;
const user = this.settings.getUser(userId);
return user && user.canBroadcastVideo && !(user.hidden || user.blocked);
}
/* -------------------------------------------- */
/* Broadcasting */
/* -------------------------------------------- */
/**
* Trigger a change in the audio broadcasting state when using a push-to-talk workflow.
* @param {boolean} intent The user's intent to broadcast. Whether an actual broadcast occurs will depend
* on whether or not the user has muted their audio feed.
*/
broadcast(intent) {
this.broadcasting = intent && this.canUserShareAudio(game.user.id);
this.client.toggleBroadcast(this.broadcasting);
const activity = this.settings.activity[game.user.id];
if ( activity.speaking !== this.broadcasting ) game.user.broadcastActivity({av: {speaking: this.broadcasting}});
activity.speaking = this.broadcasting;
return ui.webrtc.setUserIsSpeaking(game.user.id, this.broadcasting);
}
/* -------------------------------------------- */
/**
* Set up audio level listeners to handle voice activation detection workflow.
* @param {string} mode The currently selected voice broadcasting mode
* @private
*/
_initializeUserVoiceDetection(mode) {
// Deactivate prior detection
game.audio.stopLevelReports(game.user.id);
if ( !["always", "activity"].includes(mode) ) return;
// Activate voice level detection for always-on and activity-based broadcasting
const stream = this.client.getLevelsStreamForUser(game.user.id);
const ms = mode === "activity" ? CONFIG.WebRTC.detectSelfVolumeInterval : CONFIG.WebRTC.detectPeerVolumeInterval;
this.activateVoiceDetection(stream, ms);
}
/* -------------------------------------------- */
/**
* Activate voice detection tracking for a userId on a provided MediaStream.
* Currently only a MediaStream is supported because MediaStreamTrack processing is not yet supported cross-browser.
* @param {MediaStream} stream The MediaStream which corresponds to that User
* @param {number} [ms] A number of milliseconds which represents the voice activation volume interval
*/
activateVoiceDetection(stream, ms) {
this.deactivateVoiceDetection();
if ( !stream || !stream.getAudioTracks().some(t => t.enabled) ) return;
ms = ms || CONFIG.WebRTC.detectPeerVolumeInterval;
const handler = this._onAudioLevel.bind(this);
game.audio.startLevelReports(game.userId, stream, handler, ms);
}
/* -------------------------------------------- */
/**
* Actions which the orchestration layer should take when a peer user disconnects from the audio/video service.
*/
deactivateVoiceDetection() {
this._resetSpeakingHistory();
game.audio.stopLevelReports(game.userId);
}
/* -------------------------------------------- */
/**
* Periodic notification of user audio level
*
* This function uses the audio level (in dB) of the audio stream to determine if the user is speaking or not and
* notifies the UI of such changes.
*
* The User is considered speaking if they are above the decibel threshold in any of the history values.
* This marks them as speaking as soon as they have a high enough volume, and marks them as not speaking only after
* they drop below the threshold in all histories (last 4 volumes = for 200 ms).
*
* There can be more optimal ways to do this and which uses whether the user was already considered speaking before
* or not, in order to eliminate short bursts of audio (coughing for example).
*
* @param {number} dbLevel The audio level in decibels of the user within the last 50ms
* @private
*/
_onAudioLevel(dbLevel) {
const voice = this.settings.client.voice;
const speakingData = this._speakingData;
const wasSpeaking = speakingData.speaking;
// Add the current volume to the history of the user and keep the list below the history length config.
if (speakingData.volumeHistories.push(dbLevel) > CONFIG.WebRTC.speakingHistoryLength) {
speakingData.volumeHistories.shift();
}
// Count the number and total decibels of speaking events which exceed an activity threshold
const [count, max, total] = speakingData.volumeHistories.reduce((totals, vol) => {
if ( vol >= voice.activityThreshold ) {
totals[0] += 1;
totals[1] = Math.min(totals[1], vol);
totals[2] += vol;
}
return totals;
}, [0, 0, 0]);
// The user is classified as currently speaking if they exceed a certain threshold of speaking events
const isSpeaking = (count > (wasSpeaking ? 0 : CONFIG.WebRTC.speakingThresholdEvents)) && !this.client.isMuted;
speakingData.speaking = isSpeaking;
// Take further action when a change in the speaking state has occurred
if ( isSpeaking === wasSpeaking ) return;
if ( this.client.isVoiceActivated ) return this.broadcast(isSpeaking); // Declare broadcast intent
}
/* -------------------------------------------- */
/* Push-To-Talk Controls */
/* -------------------------------------------- */
/**
* Resets the speaking history of a user
* If the user was considered speaking, then mark them as not speaking
*/
_resetSpeakingHistory() {
if ( ui.webrtc ) ui.webrtc.setUserIsSpeaking(game.userId, false);
this._speakingData.speaking = false;
this._speakingData.volumeHistories = [];
}
/* -------------------------------------------- */
/**
* Handle activation of a push-to-talk key or button.
* @param {KeyboardEventContext} context The context data of the event
*/
_onPTTStart(context) {
if ( !this._connected ) return false;
const voice = this.settings.client.voice;
// Case 1: Push-to-Talk (begin broadcasting immediately)
if ( voice.mode === "ptt" ) {
if (this._pttMuteTimeout > 0) clearTimeout(this._pttMuteTimeout);
this._pttMuteTimeout = 0;
this.broadcast(true);
}
// Case 2: Push-to-Mute (disable broadcasting on a timeout)
else this._pttMuteTimeout = setTimeout(() => this.broadcast(false), voice.pttDelay);
return true;
}
/* -------------------------------------------- */
/**
* Handle deactivation of a push-to-talk key or button.
* @param {KeyboardEventContext} context The context data of the event
*/
_onPTTEnd(context) {
if ( !this._connected ) return false;
const voice = this.settings.client.voice;
// Case 1: Push-to-Talk (disable broadcasting on a timeout)
if ( voice.mode === "ptt" ) {
this._pttMuteTimeout = setTimeout(() => this.broadcast(false), voice.pttDelay);
}
// Case 2: Push-to-Mute (re-enable broadcasting immediately)
else {
if (this._pttMuteTimeout > 0) clearTimeout(this._pttMuteTimeout);
this._pttMuteTimeout = 0;
this.broadcast(true);
}
return true;
}
/* -------------------------------------------- */
/* User Interface Controls */
/* -------------------------------------------- */
render() {
return ui.webrtc.render();
}
/* -------------------------------------------- */
/**
* Render the audio/video streams to the CameraViews UI.
* Assign each connected user to the correct video frame element.
*/
onRender() {
const users = this.client.getConnectedUsers();
for ( let u of users ) {
const videoElement = ui.webrtc.getUserVideoElement(u);
if ( !videoElement ) continue;
const isSpeaking = this.settings.activity[u]?.speaking || false;
this.client.setUserVideo(u, videoElement);
ui.webrtc.setUserIsSpeaking(u, isSpeaking);
}
// Determine the players list position based on the user's settings.
const dockPositions = AVSettings.DOCK_POSITIONS;
const isAfter = [dockPositions.RIGHT, dockPositions.BOTTOM].includes(this.settings.client.dockPosition);
const iface = document.getElementById("interface");
const cameraViews = ui.webrtc.element[0];
ui.players.render(true);
if ( this.settings.client.hideDock || ui.webrtc.hidden ) {
cameraViews?.style.removeProperty("width");
cameraViews?.style.removeProperty("height");
}
document.body.classList.toggle("av-horizontal-dock", !this.settings.verticalDock);
// Change the dock position based on the user's settings.
if ( cameraViews ) {
if ( isAfter && (iface.nextElementSibling !== cameraViews) ) document.body.insertBefore(iface, cameraViews);
else if ( !isAfter && (cameraViews.nextElementSibling !== iface) ) document.body.insertBefore(cameraViews, iface);
}
}
/* -------------------------------------------- */
/* Events Handlers and Callbacks */
/* -------------------------------------------- */
/**
* Respond to changes which occur to AV Settings.
* Changes are handled in descending order of impact.
* @param {object} changed The object of changed AV settings
*/
onSettingsChanged(changed) {
const keys = Object.keys(flattenObject(changed));
// Change the server configuration (full AV re-connection)
if ( keys.includes("world.turn") ) return this.connect();
// Change audio and video visibility at a user level
const sharing = getProperty(changed, `client.users.${game.userId}`) || {};
if ( "hidden" in sharing ) this.client.toggleVideo(this.canUserShareVideo(game.userId));
if ( "muted" in sharing ) this.client.toggleAudio(this.canUserShareAudio(game.userId));
// Restore stored dock width when switching to a vertical dock position.
const isVertical =
[AVSettings.DOCK_POSITIONS.LEFT, AVSettings.DOCK_POSITIONS.RIGHT].includes(changed.client?.dockPosition);
const dockWidth = changed.client?.dockWidth ?? this.settings.client.dockWidth ?? 240;
if ( isVertical ) ui.webrtc.position.width = dockWidth;
// Switch resize direction if docked to the right.
if ( keys.includes("client.dockPosition") ) {
ui.webrtc.options.resizable.rtl = changed.client.dockPosition === AVSettings.DOCK_POSITIONS.RIGHT;
}
// Requires re-render.
const rerender = ["client.borderColors", "client.dockPosition", "client.nameplates"].some(k => keys.includes(k));
if ( rerender ) ui.webrtc.render(true);
// Call client specific setting handling
this.client.onSettingsChanged(changed);
}
/* -------------------------------------------- */
debug(message) {
if ( this.settings.debug ) console.debug(message);
}
}

View File

@@ -0,0 +1,254 @@
/**
* @typedef {object} AVSettingsData
* @property {boolean} [muted] Whether this user has muted themselves.
* @property {boolean} [hidden] Whether this user has hidden their video.
* @property {boolean} [speaking] Whether the user is broadcasting audio.
*/
class AVSettings {
constructor() {
this.initialize();
this._set = debounce((key, value) => game.settings.set("core", key, value), 100);
this._change = debounce(this._onSettingsChanged.bind(this), 100);
this.activity[game.userId] = {};
}
/* -------------------------------------------- */
/**
* WebRTC Mode, Disabled, Audio only, Video only, Audio & Video
* @enum {number}
*/
static AV_MODES = {
DISABLED: 0,
AUDIO: 1,
VIDEO: 2,
AUDIO_VIDEO: 3
};
/* -------------------------------------------- */
/**
* Voice modes: Always-broadcasting, voice-level triggered, push-to-talk.
* @enum {string}
*/
static VOICE_MODES = {
ALWAYS: "always",
ACTIVITY: "activity",
PTT: "ptt"
};
/* -------------------------------------------- */
/**
* Displayed nameplate options: Off entirely, animate between player and character name, player name only, character
* name only.
* @enum {number}
*/
static NAMEPLATE_MODES = {
OFF: 0,
BOTH: 1,
PLAYER_ONLY: 2,
CHAR_ONLY: 3
};
/* -------------------------------------------- */
/**
* AV dock positions.
* @enum {string}
*/
static DOCK_POSITIONS = {
TOP: "top",
RIGHT: "right",
BOTTOM: "bottom",
LEFT: "left"
};
/* -------------------------------------------- */
/**
* Default client AV settings.
* @type {object}
*/
static DEFAULT_CLIENT_SETTINGS = {
videoSrc: "default",
audioSrc: "default",
audioSink: "default",
dockPosition: AVSettings.DOCK_POSITIONS.LEFT,
hidePlayerList: false,
hideDock: false,
muteAll: false,
disableVideo: false,
borderColors: false,
dockWidth: 240,
nameplates: AVSettings.NAMEPLATE_MODES.BOTH,
voice: {
mode: AVSettings.VOICE_MODES.PTT,
pttName: "`",
pttDelay: 100,
activityThreshold: -45
},
users: {}
};
/* -------------------------------------------- */
/**
* Default world-level AV settings.
* @type {object}
*/
static DEFAULT_WORLD_SETTINGS = {
mode: AVSettings.AV_MODES.DISABLED,
turn: {
type: "server",
url: "",
username: "",
password: ""
}
};
/* -------------------------------------------- */
/**
* Default client settings for each connected user.
* @type {object}
*/
static DEFAULT_USER_SETTINGS = {
popout: false,
x: 100,
y: 100,
z: 0,
width: 320,
volume: 1.0,
muted: false,
hidden: false,
blocked: false
};
/* -------------------------------------------- */
/**
* Stores the transient AV activity data received from other users.
* @type {Object<string, AVSettingsData>}
*/
activity = {};
/* -------------------------------------------- */
initialize() {
this.client = game.settings.get("core", "rtcClientSettings");
this.world = game.settings.get("core", "rtcWorldSettings");
this._original = foundry.utils.deepClone({client: this.client, world: this.world});
const {muted, hidden} = this._getUserSettings(game.user);
game.user.broadcastActivity({av: {muted, hidden}});
}
/* -------------------------------------------- */
changed() {
return this._change();
}
/* -------------------------------------------- */
get(scope, setting) {
return getProperty(this[scope], setting);
}
/* -------------------------------------------- */
getUser(userId) {
const user = game.users.get(userId);
if ( !user ) return null;
return this._getUserSettings(user);
}
/* -------------------------------------------- */
set(scope, setting, value) {
setProperty(this[scope], setting, value);
this._set(`rtc${scope.titleCase()}Settings`, this[scope]);
}
/* -------------------------------------------- */
/**
* Return a mapping of AV settings for each game User.
* @type {object}
*/
get users() {
const users = {};
for ( let u of game.users ) {
users[u.id] = this._getUserSettings(u);
}
return users;
}
/* -------------------------------------------- */
/**
* A helper to determine if the dock is configured in a vertical position.
*/
get verticalDock() {
const positions = this.constructor.DOCK_POSITIONS;
return [positions.LEFT, positions.RIGHT].includes(this.client.dockPosition ?? positions.LEFT);
}
/* -------------------------------------------- */
/**
* Prepare a standardized object of user settings data for a single User
* @private
*/
_getUserSettings(user) {
const clientSettings = this.client.users[user.id] || {};
const activity = this.activity[user.id] || {};
const settings = foundry.utils.mergeObject(AVSettings.DEFAULT_USER_SETTINGS, clientSettings, {inplace: false});
settings.canBroadcastAudio = user.can("BROADCAST_AUDIO");
settings.canBroadcastVideo = user.can("BROADCAST_VIDEO");
if ( user.isSelf ) {
settings.muted ||= !game.webrtc?.client.isAudioEnabled();
settings.hidden ||= !game.webrtc?.client.isVideoEnabled();
} else {
// Either we have muted or hidden them, or they have muted or hidden themselves.
settings.muted ||= !!activity.muted;
settings.hidden ||= !!activity.hidden;
}
settings.speaking = activity.speaking;
return settings;
}
/* -------------------------------------------- */
/**
* Handle setting changes to either rctClientSettings or rtcWorldSettings.
* @private
*/
_onSettingsChanged() {
const original = this._original;
this.initialize();
const changed = foundry.utils.diffObject(original, this._original);
game.webrtc.onSettingsChanged(changed);
Hooks.callAll("rtcSettingsChanged", this, changed);
}
/* -------------------------------------------- */
/**
* Handle another connected user changing their AV settings.
* @param {string} userId
* @param {AVSettingsData} settings
*/
handleUserActivity(userId, settings) {
const current = this.activity[userId] || {};
this.activity[userId] = foundry.utils.mergeObject(current, settings, {inplace: false});
if ( !ui.webrtc ) return;
const hiddenChanged = ("hidden" in settings) && (current.hidden !== settings.hidden);
const mutedChanged = ("muted" in settings) && (current.muted !== settings.muted);
if ( (hiddenChanged || mutedChanged) && ui.webrtc.getUserVideoElement(userId) ) ui.webrtc._refreshView(userId);
if ( "speaking" in settings ) ui.webrtc.setUserIsSpeaking(userId, settings.speaking);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
/**
* A helper class to manage requesting clipboard permissions and provide common functionality for working with the
* clipboard.
*/
class ClipboardHelper {
constructor() {
if ( game.clipboard instanceof this.constructor ) {
throw new Error("You may not re-initialize the singleton ClipboardHelper. Use game.clipboard instead.");
}
}
/* -------------------------------------------- */
/**
* Copies plain text to the clipboard in a cross-browser compatible way.
* @param {string} text The text to copy.
* @returns {Promise<void>}
*/
async copyPlainText(text) {
// The clipboard-write permission name is not supported in Firefox.
try {
const result = await navigator.permissions.query({name: "clipboard-write"});
if ( ["granted", "prompt"].includes(result.state) ) {
return navigator.clipboard.writeText(text);
}
} catch(err) {}
// Fallback to deprecated execCommand here if writeText is not supported in this browser or security context.
document.addEventListener("copy", event => {
event.clipboardData.setData("text/plain", text);
event.preventDefault();
}, {once: true});
document.execCommand("copy");
}
}

View File

@@ -0,0 +1,320 @@
/**
* A data structure for quickly retrieving objects by a string prefix.
* Note that this works well for languages with alphabets (latin, cyrillic, korean, etc.), but may need more nuanced
* handling for languages that compose characters and letters.
*/
class WordTree {
/**
* A leaf entry in the tree.
* @typedef {object} WordTreeEntry
* @property {Document|object} entry An object that this entry represents.
* @property {string} documentName The document type.
* @property {string} uuid The document's UUID.
* @property {string} [pack] The pack ID.
*/
/**
* A word tree node consists of zero or more 1-character keys, and a leaves property that contains any objects that
* terminate at the current string prefix.
* @typedef {object} WordTreeNode
* @property {WordTreeEntry[]} leaves Any leaves at this node.
*/
/**
* The tree's root.
* @type {WordTreeNode}
* @private
*/
#root = this.node;
/* -------------------------------------------- */
/**
* Create a new node.
* @returns {WordTreeNode}
*/
get node() {
return {leaves: []};
}
/* -------------------------------------------- */
/**
* Insert an entry into the tree.
* @param {string} string The string key for the entry.
* @param {WordTreeEntry} entry The entry to store.
* @returns {WordTreeNode} The node the entry was added to.
*/
addLeaf(string, entry) {
let node = this.#root;
string = string.toLocaleLowerCase(game.i18n.lang);
// Use Array.from here to make sure the string is split up along UTF-8 codepoints rather than individual UTF-16
// chunks.
for ( const c of Array.from(string) ) {
node[c] ??= this.node;
node = node[c];
}
// Once we've traversed the tree, we add our entry.
node.leaves.push(entry);
return node;
}
/* -------------------------------------------- */
/**
* Return entries that match the given string prefix.
* @param {string} prefix The prefix.
* @param {object} [options] Additional options to configure behaviour.
* @param {number} [options.limit=10] The maximum number of items to retrieve. It is important to set this value as
* very short prefixes will naturally match large numbers of entries.
* @returns {WordTreeEntry[]} A number of entries that have the given prefix.
*/
lookup(prefix, {limit=10}={}) {
const entries = [];
const node = this.nodeAtPrefix(prefix);
if ( !node ) return []; // No matching entries.
const queue = [node];
while ( queue.length ) {
if ( entries.length >= limit ) break;
this._breadthFirstSearch(queue.shift(), entries, queue, {limit});
}
return entries;
}
/* -------------------------------------------- */
/**
* Returns the node at the given prefix.
* @param {string} prefix The prefix.
* @returns {WordTreeNode}
*/
nodeAtPrefix(prefix) {
prefix = prefix.toLocaleLowerCase(game.i18n.lang);
let node = this.#root;
for ( const c of Array.from(prefix) ) {
node = node[c];
if ( !node ) return;
}
return node;
}
/* -------------------------------------------- */
/**
* Perform a breadth-first search starting from the given node and retrieving any entries along the way, until we
* reach the limit.
* @param {WordTreeNode} node The starting node.
* @param {WordTreeEntry[]} entries The accumulated entries.
* @param {WordTreeNode[]} queue The working queue of nodes to search.
* @param {object} [options] Additional options for the search.
* @param {number} [options.limit=10] The maximum number of entries to retrieve before stopping.
* @protected
*/
_breadthFirstSearch(node, entries, queue, {limit=10}={}) {
// Retrieve the entries at this node.
entries.push(...node.leaves);
if ( entries.length >= limit ) return;
// Push this node's children onto the end of the queue.
for ( const c of Object.keys(node) ) {
if ( c === "leaves" ) continue;
queue.push(node[c]);
}
}
}
/**
* This class is responsible for indexing all documents available in the world and storing them in a word tree structure
* that allows for fast searching.
*/
class DocumentIndex {
constructor() {
/**
* A collection of WordTree structures for each document type.
* @type {Object<WordTree>}
*/
Object.defineProperty(this, "trees", {value: {}});
/**
* A reverse-lookup of a document's UUID to its parent node in the word tree.
* @type {Object<WordTreeNode>}
*/
Object.defineProperty(this, "uuids", {value: {}});
}
/**
* While we are indexing, we store a Promise that resolves when the indexing is complete.
* @type {Promise<void>|null}
* @private
*/
#ready = null;
/* -------------------------------------------- */
/**
* Returns a Promise that resolves when the indexing process is complete.
* @returns {Promise<void>|null}
*/
get ready() {
return this.#ready;
}
/* -------------------------------------------- */
/**
* Index all available documents in the world and store them in a word tree.
* @returns {Promise<void>}
*/
async index() {
// Conclude any existing indexing.
await this.#ready;
const indexedCollections = CONST.DOCUMENT_TYPES.filter(c => CONFIG[c].documentClass.metadata.indexed);
// TODO: Consider running this process in a web worker.
const start = performance.now();
return this.#ready = new Promise(resolve => {
for ( const documentName of indexedCollections ) {
this._indexWorldCollection(documentName);
}
for ( const pack of game.packs ) {
if ( !indexedCollections.includes(pack.documentName) ) continue;
this._indexCompendium(pack);
}
resolve();
console.debug(`${vtt} | Document indexing complete in ${performance.now() - start}ms.`);
});
}
/* -------------------------------------------- */
/**
* Return entries that match the given string prefix.
* @param {string} prefix The prefix.
* @param {object} [options] Additional options to configure behaviour.
* @param {string[]} [options.documentTypes] Optionally provide an array of document types. Only entries of that type
* will be searched for.
* @param {number} [options.limit=10] The maximum number of items per document type to retrieve. It is
* important to set this value as very short prefixes will naturally match
* large numbers of entries.
* @returns {Object<WordTreeEntry[]>} A number of entries that have the given prefix, grouped by document
* type.
*/
lookup(prefix, {limit=10, documentTypes=[]}={}) {
const types = documentTypes.length ? documentTypes : Object.keys(this.trees);
const results = {};
for ( const type of types ) {
results[type] = [];
const tree = this.trees[type];
if ( !tree ) continue;
results[type].push(...tree.lookup(prefix, {limit}));
}
return results;
}
/* -------------------------------------------- */
/**
* Add an entry to the index.
* @param {Document} doc The document entry.
*/
addDocument(doc) {
if ( doc.pack ) {
if ( doc.isEmbedded ) return; // Only index primary documents inside compendium packs
const pack = game.packs.get(doc.pack);
const index = pack.index.get(doc.id);
if ( index ) this._addLeaf(index, {pack});
}
else this._addLeaf(doc);
}
/* -------------------------------------------- */
/**
* Remove an entry from the index.
* @param {Document} doc The document entry.
*/
removeDocument(doc) {
const node = this.uuids[doc.uuid];
if ( !node ) return;
node.leaves.findSplice(e => e.uuid === doc.uuid);
delete this.uuids[doc.uuid];
}
/* -------------------------------------------- */
/**
* Replace an entry in the index with an updated one.
* @param {Document} doc The document entry.
*/
replaceDocument(doc) {
this.removeDocument(doc);
this.addDocument(doc);
}
/* -------------------------------------------- */
/**
* Add a leaf node to the word tree index.
* @param {Document|object} doc The document or compendium index entry to add.
* @param {object} [options] Additional information for indexing.
* @param {CompendiumCollection} [options.pack] The compendium that the index belongs to.
* @protected
*/
_addLeaf(doc, {pack}={}) {
const entry = {entry: doc, documentName: doc.documentName, uuid: doc.uuid};
if ( pack ) foundry.utils.mergeObject(entry, {
documentName: pack.documentName,
uuid: `Compendium.${pack.collection}.${doc._id}`,
pack: pack.collection
});
const tree = this.trees[entry.documentName] ??= new WordTree();
this.uuids[entry.uuid] = tree.addLeaf(doc.name, entry);
}
/* -------------------------------------------- */
/**
* Aggregate the compendium index and add it to the word tree index.
* @param {CompendiumCollection} pack The compendium pack.
* @protected
*/
_indexCompendium(pack) {
for ( const entry of pack.index ) {
this._addLeaf(entry, {pack});
}
}
/* -------------------------------------------- */
/**
* Add all of a parent document's embedded documents to the index.
* @param {Document} parent The parent document.
* @protected
*/
_indexEmbeddedDocuments(parent) {
const embedded = parent.constructor.metadata.embedded;
for ( const embeddedName of Object.keys(embedded) ) {
if ( !CONFIG[embeddedName].documentClass.metadata.indexed ) continue;
for ( const doc of parent[embedded[embeddedName]] ) {
this._addLeaf(doc);
}
}
}
/* -------------------------------------------- */
/**
* Aggregate all documents and embedded documents in a world collection and add them to the index.
* @param {string} documentName The name of the documents to index.
* @protected
*/
_indexWorldCollection(documentName) {
const cls = CONFIG[documentName].documentClass;
const collection = cls.metadata.collection;
for ( const doc of game[collection] ) {
this._addLeaf(doc);
this._indexEmbeddedDocuments(doc);
}
}
}

View File

@@ -0,0 +1,147 @@
/**
* Management class for Gamepad events
*/
class GamepadManager {
constructor() {
this._gamepadPoller = null;
/**
* The connected Gamepads
* @type {Map<string, ConnectedGamepad>}
* @private
*/
this._connectedGamepads = new Map();
}
/**
* How often Gamepad polling should check for button presses
* @type {number}
*/
static GAMEPAD_POLLER_INTERVAL_MS = 100;
/* -------------------------------------------- */
/**
* Begin listening to gamepad events.
* @internal
*/
_activateListeners() {
window.addEventListener("gamepadconnected", this._onGamepadConnect.bind(this));
window.addEventListener("gamepaddisconnected", this._onGamepadDisconnect.bind(this));
}
/* -------------------------------------------- */
/**
* Handles a Gamepad Connection event, adding its info to the poll list
* @param {GamepadEvent} event The originating Event
* @private
*/
_onGamepadConnect(event) {
if ( CONFIG.debug.gamepad ) console.log(`Gamepad ${event.gamepad.id} connected`);
this._connectedGamepads.set(event.gamepad.id, {
axes: new Map(),
activeButtons: new Set()
});
if ( !this._gamepadPoller ) this._gamepadPoller = setInterval(() => {
this._pollGamepads()
}, GamepadManager.GAMEPAD_POLLER_INTERVAL_MS);
// Immediately poll to try and capture the action that connected the Gamepad
this._pollGamepads();
}
/* -------------------------------------------- */
/**
* Handles a Gamepad Disconnect event, removing it from consideration for polling
* @param {GamepadEvent} event The originating Event
* @private
*/
_onGamepadDisconnect(event) {
if ( CONFIG.debug.gamepad ) console.log(`Gamepad ${event.gamepad.id} disconnected`);
this._connectedGamepads.delete(event.gamepad.id);
if ( this._connectedGamepads.length === 0 ) {
clearInterval(this._gamepadPoller);
this._gamepadPoller = null;
}
}
/* -------------------------------------------- */
/**
* Polls all Connected Gamepads for updates. If they have been updated, checks status of Axis and Buttons,
* firing off Keybinding Contexts as appropriate
* @private
*/
_pollGamepads() {
// Joysticks are not very precise and range from -1 to 1, so we need to ensure we avoid drift due to low (but not zero) values
const AXIS_PRECISION = 0.15;
const MAX_AXIS = 1;
for ( let gamepad of navigator.getGamepads() ) {
if ( !gamepad || !this._connectedGamepads.has(gamepad?.id) ) continue;
const id = gamepad.id;
let gamepadData = this._connectedGamepads.get(id);
// Check Active Axis
for ( let x = 0; x < gamepad.axes.length; x++ ) {
let axisValue = gamepad.axes[x];
// Verify valid input and handle inprecise values
if ( Math.abs(axisValue) > MAX_AXIS ) continue;
if ( Math.abs(axisValue) <= AXIS_PRECISION ) axisValue = 0;
// Store Axis data per Joystick as Numbers
const joystickId = `${id}_AXIS${x}`;
const priorValue = gamepadData.axes.get(joystickId) ?? 0;
// An Axis exists from -1 to 1, with 0 being the center.
// We split an Axis into Negative and Positive zones to differentiate pressing it left / right and up / down
if ( axisValue !== 0 ) {
const sign = Math.sign(axisValue);
const repeat = sign === Math.sign(priorValue);
const emulatedKey = `${joystickId}_${sign > 0 ? "POSITIVE" : "NEGATIVE"}`;
this._handleGamepadInput(emulatedKey, false, repeat);
}
else if ( priorValue !== 0 ) {
const sign = Math.sign(priorValue);
const emulatedKey = `${joystickId}_${sign > 0 ? "POSITIVE" : "NEGATIVE"}`;
this._handleGamepadInput(emulatedKey, true);
}
// Update value
gamepadData.axes.set(joystickId, axisValue);
}
// Check Pressed Buttons
for ( let x = 0; x < gamepad.buttons.length; x++ ) {
const button = gamepad.buttons[x];
const buttonId = `${id}_BUTTON${x}_PRESSED`;
if ( button.pressed ) {
const repeat = gamepadData.activeButtons.has(buttonId);
if ( !repeat ) gamepadData.activeButtons.add(buttonId);
this._handleGamepadInput(buttonId, false, repeat);
}
else if ( gamepadData.activeButtons.has(buttonId) ) {
gamepadData.activeButtons.delete(buttonId);
this._handleGamepadInput(buttonId, true);
}
}
}
}
/* -------------------------------------------- */
/**
* Converts a Gamepad Input event into a KeyboardEvent, then fires it
* @param {string} gamepadId The string representation of the Gamepad Input
* @param {boolean} up True if the Input is pressed or active
* @param {boolean} repeat True if the Input is being held
* @private
*/
_handleGamepadInput(gamepadId, up, repeat = false) {
const key = gamepadId.replaceAll(" ", "").toUpperCase().trim();
const event = new KeyboardEvent(`key${up ? "up" : "down"}`, {code: key, bubbles: true});
window.dispatchEvent(event);
$(".binding-input:focus").get(0)?.dispatchEvent(event);
}
}

View File

@@ -0,0 +1,195 @@
/**
* @typedef {object} HookedFunction
* @property {string} hook
* @property {number} id
* @property {Function} fn
* @property {boolean} once
*/
/**
* A simple event framework used throughout Foundry Virtual Tabletop.
* When key actions or events occur, a "hook" is defined where user-defined callback functions can execute.
* This class manages the registration and execution of hooked callback functions.
*/
class Hooks {
/**
* A mapping of hook events which have functions registered to them.
* @type {Object<HookedFunction[]>}
*/
static get events() {
return this.#events;
}
/**
* @type {Object<HookedFunction[]>}
* @private
* @ignore
*/
static #events = {};
/**
* A mapping of hooked functions by their assigned ID
* @type {Map<number, HookedFunction>}
*/
static #ids = new Map();
/**
* An incrementing counter for assigned hooked function IDs
* @type {number}
*/
static #id = 1;
/* -------------------------------------------- */
/**
* Register a callback handler which should be triggered when a hook is triggered.
* @param {string} hook The unique name of the hooked event
* @param {Function} fn The callback function which should be triggered when the hook event occurs
* @param {object} options Options which customize hook registration
* @param {boolean} options.once Only trigger the hooked function once
* @returns {number} An ID number of the hooked function which can be used to turn off the hook later
*/
static on(hook, fn, {once=false}={}) {
console.debug(`${vtt} | Registered callback for ${hook} hook`);
const id = this.#id++;
if ( !(hook in this.#events) ) {
Object.defineProperty(this.#events, hook, {value: [], writable: false});
}
const entry = {hook, id, fn, once};
this.#events[hook].push(entry);
this.#ids.set(id, entry);
return id;
}
/* -------------------------------------------- */
/**
* Register a callback handler for an event which is only triggered once the first time the event occurs.
* An alias for Hooks.on with {once: true}
* @param {string} hook The unique name of the hooked event
* @param {Function} fn The callback function which should be triggered when the hook event occurs
* @returns {number} An ID number of the hooked function which can be used to turn off the hook later
*/
static once(hook, fn) {
return this.on(hook, fn, {once: true});
}
/* -------------------------------------------- */
/**
* Unregister a callback handler for a particular hook event
* @param {string} hook The unique name of the hooked event
* @param {Function|number} fn The function, or ID number for the function, that should be turned off
*/
static off(hook, fn) {
let entry;
// Provided an ID
if ( typeof fn === "number" ) {
const id = fn;
entry = this.#ids.get(id);
if ( !entry ) return;
this.#ids.delete(id);
const event = this.#events[entry.hook];
event.findSplice(h => h.id === id);
}
// Provided a Function
else {
const event = this.#events[hook];
const entry = event.findSplice(h => h.fn === fn);
if ( !entry ) return;
this.#ids.delete(entry.id);
}
console.debug(`${vtt} | Unregistered callback for ${hook} hook`);
}
/* -------------------------------------------- */
/**
* Call all hook listeners in the order in which they were registered
* Hooks called this way can not be handled by returning false and will always trigger every hook callback.
*
* @param {string} hook The hook being triggered
* @param {...*} args Arguments passed to the hook callback functions
* @returns {boolean} Were all hooks called without execution being prevented?
*/
static callAll(hook, ...args) {
if ( CONFIG.debug.hooks ) {
console.log(`DEBUG | Calling ${hook} hook with args:`);
console.log(args);
}
if ( !(hook in this.#events) ) return true;
for ( const entry of Array.from(this.#events[hook]) ) {
this.#call(entry, args);
}
return true;
}
/* -------------------------------------------- */
/**
* Call hook listeners in the order in which they were registered.
* Continue calling hooks until either all have been called or one returns false.
*
* Hook listeners which return false denote that the original event has been adequately handled and no further
* hooks should be called.
*
* @param {string} hook The hook being triggered
* @param {...*} args Arguments passed to the hook callback functions
* @returns {boolean} Were all hooks called without execution being prevented?
*/
static call(hook, ...args) {
if ( CONFIG.debug.hooks ) {
console.log(`DEBUG | Calling ${hook} hook with args:`);
console.log(args);
}
if ( !(hook in this.#events) ) return true;
for ( const entry of Array.from(this.#events[hook]) ) {
let callAdditional = this.#call(entry, args);
if ( callAdditional === false ) return false;
}
return true;
}
/* -------------------------------------------- */
/**
* Call a hooked function using provided arguments and perhaps unregister it.
* @param {HookedFunction} entry The hooked function entry
* @param {any[]} args Arguments to be passed
* @private
*/
static #call(entry, args) {
const {hook, id, fn, once} = entry;
if ( once ) this.off(hook, id);
try {
return entry.fn(...args);
} catch(err) {
const msg = `Error thrown in hooked function '${fn?.name}' for hook '${hook}'`;
console.warn(`${vtt} | ${msg}`);
if ( hook !== "error" ) this.onError("Hooks.#call", err, {msg, hook, fn, log: "error"});
}
}
/* --------------------------------------------- */
/**
* Notify subscribers that an error has occurred within foundry.
* @param {string} location The method where the error was caught.
* @param {Error} error The error.
* @param {object} [options={}] Additional options to configure behaviour.
* @param {string} [options.msg=""] A message which should prefix the resulting error or notification.
* @param {?string} [options.log=null] The level at which to log the error to console (if at all).
* @param {?string} [options.notify=null] The level at which to spawn a notification in the UI (if at all).
* @param {object} [options.data={}] Additional data to pass to the hook subscribers.
*/
static onError(location, error, {msg="", notify=null, log=null, ...data}={}) {
if ( !(error instanceof Error) ) return;
if ( msg ) error.message = `${msg}. ${error.message}`;
if ( log ) console[log]?.(error);
if ( notify ) ui.notifications[notify]?.(msg || error.message);
Hooks.callAll("error", location, error, data);
}
}

View File

@@ -0,0 +1,200 @@
/**
* A helper class to provide common functionality for working with Image objects
*/
class ImageHelper {
/**
* Create thumbnail preview for a provided image path.
* @param {string|PIXI.DisplayObject} src The URL or display object of the texture to render to a thumbnail
* @param {object} options Additional named options passed to the compositeCanvasTexture function
* @param {number} [options.width] The desired width of the resulting thumbnail
* @param {number} [options.height] The desired height of the resulting thumbnail
* @param {number} [options.tx] A horizontal transformation to apply to the provided source
* @param {number} [options.ty] A vertical transformation to apply to the provided source
* @param {boolean} [options.center] Whether to center the object within the thumbnail
* @param {string} [options.format] The desired output image format
* @param {number} [options.quality] The desired output image quality
* @returns {Promise<object>} The parsed and converted thumbnail data
*/
static async createThumbnail(src, {width, height, tx, ty, center, format, quality}) {
if ( !src ) return null;
// Load the texture and create a Sprite
let object = src;
if ( !(src instanceof PIXI.DisplayObject) ) {
const texture = await loadTexture(src);
object = PIXI.Sprite.from(texture);
}
// Reduce to the smaller thumbnail texture
if ( !canvas.ready && canvas.initializing ) await canvas.initializing;
const reduced = this.compositeCanvasTexture(object, {width, height, tx, ty, center});
const thumb = await this.textureToImage(reduced, {format, quality});
reduced.destroy(true);
// Return the image data
return { src, texture: reduced, thumb, width: object.width, height: object.height };
}
/* -------------------------------------------- */
/**
* Test whether a source file has a supported image extension type
* @param {string} src A requested image source path
* @returns {boolean} Does the filename end with a valid image extension?
*/
static hasImageExtension(src) {
return foundry.data.validators.hasFileExtension(src, Object.keys(CONST.IMAGE_FILE_EXTENSIONS));
}
/* -------------------------------------------- */
/**
* Composite a canvas object by rendering it to a single texture
*
* @param {PIXI.DisplayObject} object The object to render to a texture
* @param {object} [options] Options which configure the resulting texture
* @param {number} [options.width] The desired width of the output texture
* @param {number} [options.height] The desired height of the output texture
* @param {number} [options.tx] A horizontal translation to apply to the object
* @param {number} [options.ty] A vertical translation to apply to the object
* @param {boolean} [options.center] Center the texture in the rendered frame?
*
* @returns {PIXI.Texture} The composite Texture object
*/
static compositeCanvasTexture(object, {width, height, tx=0, ty=0, center=true}={}) {
if ( !canvas.app?.renderer ) throw new Error("Unable to compose texture because there is no game canvas");
width = width ?? object.width;
height = height ?? object.height;
// Downscale the object to the desired thumbnail size
const currentRatio = object.width / object.height;
const targetRatio = width / height;
const s = currentRatio > targetRatio ? (height / object.height) : (width / object.width);
// Define a transform matrix
const transform = PIXI.Matrix.IDENTITY.clone();
transform.scale(s, s);
// Translate position
if ( center ) {
tx = (width - (object.width * s)) / 2;
ty = (height - (object.height * s)) / 2;
} else {
tx *= s;
ty *= s;
}
transform.translate(tx, ty);
// Create and render a texture with the desired dimensions
const renderTexture = PIXI.RenderTexture.create({
width: width,
height: height,
scaleMode: PIXI.SCALE_MODES.LINEAR,
resolution: canvas.app.renderer.resolution
});
canvas.app.renderer.render(object, {
renderTexture,
transform
});
return renderTexture;
}
/* -------------------------------------------- */
/**
* Extract a texture to a base64 PNG string
* @param {PIXI.Texture} texture The texture object to extract
* @param {object} options
* @param {string} [options.format] Image format, e.g. "image/jpeg" or "image/webp".
* @param {number} [options.quality] JPEG or WEBP compression from 0 to 1. Default is 0.92.
* @returns {Promise<string>} A base64 png string of the texture
*/
static async textureToImage(texture, {format, quality}={}) {
const s = new PIXI.Sprite(texture);
return canvas.app.renderer.extract.base64(s, format, quality);
}
/* -------------------------------------------- */
/**
* Asynchronously convert a DisplayObject container to base64 using Canvas#toBlob and FileReader
* @param {PIXI.DisplayObject} target A PIXI display object to convert
* @param {string} type The requested mime type of the output, default is image/png
* @param {number} quality A number between 0 and 1 for image quality if image/jpeg or image/webp
* @returns {Promise<string>} A processed base64 string
*/
static async pixiToBase64(target, type, quality) {
const extracted = canvas.app.renderer.extract.canvas(target);
return this.canvasToBase64(extracted, type, quality);
}
/* -------------------------------------------- */
/**
* Asynchronously convert a canvas element to base64.
* @param {HTMLCanvasElement} canvas
* @param {string} [type="image/png"]
* @param {number} [quality]
* @returns {Promise<string>} The base64 string of the canvas.
*/
static async canvasToBase64(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
}, type, quality);
});
}
/* -------------------------------------------- */
/**
* Upload a base64 image string to a persisted data storage location
* @param {string} base64 The base64 string
* @param {string} fileName The file name to upload
* @param {string} filePath The file path where the file should be uploaded
* @param {object} [options] Additional options which affect uploading
* @param {string} [options.storage=data] The data storage location to which the file should be uploaded
* @param {string} [options.type] The MIME type of the file being uploaded
* @param {boolean} [options.notify=true] Display a UI notification when the upload is processed.
* @returns {Promise<object>} A promise which resolves to the FilePicker upload response
*/
static async uploadBase64(base64, fileName, filePath, {storage="data", type, notify=true}={}) {
type ||= base64.split(";")[0].split("data:")[1];
const blob = await fetch(base64).then(r => r.blob());
const file = new File([blob], fileName, {type});
return FilePicker.upload(storage, filePath, file, {}, { notify });
}
/* -------------------------------------------- */
/**
* Create a canvas element containing the pixel data.
* @param {Uint8ClampedArray} pixels Buffer used to create the image data.
* @param {number} width Buffered image width.
* @param {number} height Buffered image height.
* @param {object} options
* @param {HTMLCanvasElement} [options.element] The element to use.
* @param {number} [options.ew] Specified width for the element (default to buffer image width).
* @param {number} [options.eh] Specified height for the element (default to buffer image height).
* @returns {HTMLCanvasElement}
*/
static pixelsToCanvas(pixels, width, height, {element, ew, eh}={}) {
// If an element is provided, use it. Otherwise, create a canvas element
element ??= document.createElement("canvas");
// Assign specific element width and height, if provided. Otherwise, assign buffered image dimensions
element.width = ew ?? width;
element.height = eh ?? height;
// Get the context and create a new image data with the buffer
const context = element.getContext("2d");
const imageData = new ImageData(pixels, width, height);
context.putImageData(imageData, 0, 0);
return element;
}
}

View File

@@ -0,0 +1,289 @@
/**
* An object structure of document types at the top level, with a count of different sub-types for that document type.
* @typedef {Object<Object<number>>} ModuleSubTypeCounts
*/
/**
* A class responsible for tracking issues in the current world.
*/
class ClientIssues {
/**
* Keep track of valid Documents in the world that are using module-provided sub-types.
* @type {Map<string, ModuleSubTypeCounts>}
*/
#moduleTypeMap = new Map();
/**
* Keep track of document validation failures.
* @type {object}
*/
#documentValidationFailures = {};
/**
* @typedef {object} UsabilityIssue
* @property {string} message The pre-localized message to display in relation to the usability issue.
* @property {string} severity The severity of the issue, either "error", "warning", or "info".
* @property {object} [params] Parameters to supply to the localization.
*/
/**
* Keep track of any usability issues related to browser or technology versions.
* @type {Object<UsabilityIssue>}
*/
#usabilityIssues = {};
/**
* The minimum supported resolution.
* @type {{WIDTH: number, HEIGHT: number}}
*/
static #MIN_RESOLUTION = {WIDTH: 1024, HEIGHT: 700};
/**
* @typedef {object} BrowserTest
* @property {number} minimum The minimum supported version for this browser.
* @property {RegExp} match A regular expression to match the browser against the user agent string.
* @property {string} message A message to display if the user's browser version does not meet the minimum.
*/
/**
* The minimum supported client versions.
* @type {Object<BrowserTest>}
*/
static #BROWSER_TESTS = {
Electron: {
minimum: 24,
match: /Electron\/(\d+)\./,
message: "ERROR.ElectronVersion"
},
Chromium: {
minimum: 92,
match: /Chrom(?:e|ium)\/(\d+)\./,
message: "ERROR.BrowserVersion"
},
Firefox: {
minimum: 90,
match: /Firefox\/(\d+)\./,
message: "ERROR.BrowserVersion"
},
Safari: {
minimum: 15.4,
match: /Version\/(\d+)\..*Safari\//,
message: "ERROR.BrowserVersion"
}
};
/* -------------------------------------------- */
/**
* Add a Document to the count of module-provided sub-types.
* @param {string} documentName The Document name.
* @param {string} subType The Document's sub-type.
* @param {object} [options]
* @param {boolean} [options.decrement=false] Decrement the counter rather than incrementing it.
*/
#countDocumentSubType(documentName, subType, {decrement=false}={}) {
if ( !((typeof subType === "string") && subType.includes(".")) ) return;
const [moduleId, ...rest] = subType.split(".");
subType = rest.join(".");
if ( !this.#moduleTypeMap.has(moduleId) ) this.#moduleTypeMap.set(moduleId, {});
const counts = this.#moduleTypeMap.get(moduleId);
const types = counts[documentName] ??= {};
types[subType] ??= 0;
if ( decrement ) types[subType] = Math.max(types[subType] - 1, 0);
else types[subType]++;
}
/* -------------------------------------------- */
/**
* Detect the user's browser and display a notification if it is below the minimum required version.
*/
#detectBrowserVersion() {
for ( const [browser, {minimum, match, message}] of Object.entries(ClientIssues.#BROWSER_TESTS) ) {
const [, version] = navigator.userAgent.match(match) ?? [];
if ( !Number.isNumeric(version) ) continue;
if ( Number(version) < minimum ) {
const err = game.i18n.format(message, {browser, version, minimum});
ui.notifications?.error(err, {permanent: true, console: true});
this.#usabilityIssues.browserVersionIncompatible = {
message,
severity: "error",
params: {browser, version, minimum}
};
}
break;
}
}
/* -------------------------------------------- */
/**
* Record a reference to a resolution notification ID so that we can remove it if the problem is remedied.
* @type {number}
*/
#resolutionTooLowNotification;
/**
* Detect the user's resolution and display a notification if it is too small.
*/
#detectResolution() {
const {WIDTH: reqWidth, HEIGHT: reqHeight} = ClientIssues.#MIN_RESOLUTION;
const {innerWidth: width, innerHeight: height} = window;
if ( (height < reqHeight) || (width < reqWidth) ) {
// Display a permanent error notification
if ( ui.notifications && !this.#resolutionTooLowNotification ) {
this.#resolutionTooLowNotification = ui.notifications.error(game.i18n.format("ERROR.LowResolution", {
width, reqWidth, height, reqHeight
}), {permanent: true});
}
// Record the usability issue
this.#usabilityIssues.resolutionTooLow = {
message: "ERROR.LowResolution",
severity: "error",
params: {width, reqWidth, height, reqHeight}
};
}
// Remove an error notification if present
else {
if ( this.#resolutionTooLowNotification ) {
this.#resolutionTooLowNotification = ui.notifications.remove(this.#resolutionTooLowNotification);
}
delete this.#usabilityIssues.resolutionTooLow;
}
}
/* -------------------------------------------- */
/**
* Detect and display warnings for known performance issues which may occur due to the user's hardware or browser
* configuration.
* @internal
*/
_detectWebGLIssues() {
const context = canvas.app.renderer.context;
try {
const rendererInfo = SupportDetails.getWebGLRendererInfo(context.gl);
if ( /swiftshader/i.test(rendererInfo) ) {
ui.notifications.warn("ERROR.NoHardwareAcceleration", {localize: true, permanent: true});
this.#usabilityIssues.hardwareAccel = {message: "ERROR.NoHardwareAcceleration", severity: "error"};
}
} catch ( err ) {
ui.notifications.warn("ERROR.RendererNotDetected", {localize: true, permanent: true});
this.#usabilityIssues.noRenderer = {message: "ERROR.RendererNotDetected", severity: "warning"};
}
// Verify that WebGL2 is being used.
if ( !canvas.supported.webGL2 ) {
ui.notifications.error("ERROR.NoWebGL2", {localize: true, permanent: true});
this.#usabilityIssues.webgl2 = {message: "ERROR.NoWebGL2", severity: "error"};
}
}
/* -------------------------------------------- */
/**
* Add an invalid Document to the module-provided sub-type counts.
* @param {string} documentName The Document name.
* @param {object} source The Document's source data.
* @param {object} [options]
* @param {boolean} [options.decrement=false] Decrement the counter rather than incrementing it.
* @internal
*/
_countDocumentSubType(documentName, source, options={}) {
const cls = getDocumentClass(documentName);
if ( cls.hasTypeData ) this.#countDocumentSubType(documentName, source.type, options);
for ( const [embeddedName, collection] of Object.entries(cls.metadata.embedded ?? {}) ) {
if ( !getDocumentClass(embeddedName).hasTypeData ) continue;
for ( const embedded of source[collection] ) this.#countDocumentSubType(embeddedName, embedded.type, options);
}
}
/* -------------------------------------------- */
/**
* Track a validation failure that occurred in a WorldCollection.
* @param {WorldCollection} collection The parent collection.
* @param {object} source The Document's source data.
* @param {DataModelValidationError} error The validation error.
* @internal
*/
_trackValidationFailure(collection, source, error) {
if ( !(collection instanceof WorldCollection) ) return;
if ( !(error instanceof foundry.data.validation.DataModelValidationError) ) return;
const documentName = collection.documentName;
this.#documentValidationFailures[documentName] ??= {};
this.#documentValidationFailures[documentName][source._id] = {name: source.name, error};
}
/* -------------------------------------------- */
/**
* Detect and record certain usability error messages which are likely to result in the user having a bad experience.
* @internal
*/
_detectUsabilityIssues() {
this.#detectResolution();
this.#detectBrowserVersion();
window.addEventListener("resize", foundry.utils.debounce(this.#detectResolution.bind(this), 250), {passive: true});
}
/* -------------------------------------------- */
/**
* Get the Document sub-type counts for a given module.
* @param {Module|string} module The module or its ID.
* @returns {ModuleSubTypeCounts}
*/
getSubTypeCountsFor(module) {
return this.#moduleTypeMap.get(module.id ?? module);
}
/* -------------------------------------------- */
/**
* Retrieve all sub-type counts in the world.
* @returns {Iterator<string, ModuleSubTypeCounts>}
*/
getAllSubTypeCounts() {
return this.#moduleTypeMap.entries();
}
/* -------------------------------------------- */
/**
* Retrieve the tracked validation failures.
* @returns {object}
*/
get validationFailures() {
return this.#documentValidationFailures;
}
/* -------------------------------------------- */
/**
* Retrieve the tracked usability issues.
* @returns {Object<UsabilityIssue>}
*/
get usabilityIssues() {
return this.#usabilityIssues;
}
/* -------------------------------------------- */
/**
* @typedef {object} PackageCompatibilityIssue
* @property {string[]} error Error messages.
* @property {string[]} warning Warning messages.
*/
/**
* Retrieve package compatibility issues.
* @returns {Object<PackageCompatibilityIssue>}
*/
get packageCompatibilityIssues() {
return game.data.packageWarnings;
}
}

Some files were not shown because too many files have changed in this diff Show More