Initial
This commit is contained in:
996
resources/app/client/apps/app.js
Normal file
996
resources/app/client/apps/app.js
Normal file
@@ -0,0 +1,996 @@
|
||||
/**
|
||||
* A namespace containing the user interface applications which are defined throughout the Foundry VTT ecosystem.
|
||||
* @namespace applications
|
||||
*/
|
||||
|
||||
let _appId = globalThis._appId = 0;
|
||||
let _maxZ = Number(getComputedStyle(document.body).getPropertyValue("--z-index-window") ?? 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
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 application ID is a unique incrementing integer which is used to identify every application window
|
||||
* drawn by the VTT
|
||||
* @type {number}
|
||||
*/
|
||||
appId;
|
||||
|
||||
/**
|
||||
* The sequence of rendering states that track the Application life-cycle.
|
||||
* @enum {number}
|
||||
*/
|
||||
static RENDER_STATES = Object.freeze({
|
||||
ERROR: -3,
|
||||
CLOSING: -2,
|
||||
CLOSED: -1,
|
||||
NONE: 0,
|
||||
RENDERING: 1,
|
||||
RENDERED: 2
|
||||
});
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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;
|
||||
this.appId = element.data("appid") ?? ++_appId;
|
||||
if ( this.popOut ) ui.windows[this.appId] = this;
|
||||
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);
|
||||
}
|
||||
|
||||
// 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
|
||||
this._callHooks("render", 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;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Call all hooks for all applications in the inheritance chain.
|
||||
* @param {string | (className: string) => string} hookName The hook being triggered, which formatted
|
||||
* with the Application class name
|
||||
* @param {...*} hookArgs The arguments passed to the hook calls
|
||||
* @protected
|
||||
* @internal
|
||||
*/
|
||||
_callHooks(hookName, ...hookArgs) {
|
||||
const formatHook = typeof hookName === "string" ? className => `${hookName}${className}` : hookName;
|
||||
for ( const cls of this.constructor._getInheritanceChain() ) {
|
||||
if ( !cls.name ) continue;
|
||||
Hooks.callAll(formatHook(cls.name), this, ...hookArgs);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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
|
||||
this.position.zIndex = Math.min(++_maxZ, 99999);
|
||||
html[0].style.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()
|
||||
}
|
||||
];
|
||||
this._callHooks(className => `get${className}HeaderButtons`, 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() {
|
||||
if ( ui.activeWindow === this ) return;
|
||||
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
|
||||
this._callHooks("close", 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.clamp(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.clamp(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.clamp(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.clamp(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();
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
207
resources/app/client/apps/av/av-config.js
Normal file
207
resources/app/client/apps/av/av-config.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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 foundry.utils.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,
|
||||
audioSourceOptions: this.#getDevices(audioSources, audioSrcUnavailable, "WEBRTC.DisableAudioSource"),
|
||||
audioSinkOptions: this.#getDevices(audioSinks, audioSinkUnavailable),
|
||||
videoSourceOptions: this.#getDevices(videoSources, videoSrcUnavailable, "WEBRTC.DisableVideoSource")
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an array of available devices which can be chosen.
|
||||
* @param {Record<string, string>} devices
|
||||
* @param {string} unavailableDevice
|
||||
* @param {string} disabledLabel
|
||||
* @returns {FormSelectOption[]}
|
||||
*/
|
||||
#getDevices(devices, unavailableDevice, disabledLabel) {
|
||||
const options = [];
|
||||
let hasDefault = false;
|
||||
for ( const [k, v] of Object.entries(devices) ) {
|
||||
if ( k === "default" ) hasDefault = true;
|
||||
options.push({value: k, label: v});
|
||||
}
|
||||
if ( !hasDefault ) {
|
||||
options.unshift({value: "default", label: game.i18n.localize("WEBRTC.DefaultSource")});
|
||||
}
|
||||
if ( disabledLabel ) {
|
||||
options.unshift({value: "disabled", label: game.i18n.localize(disabledLabel)});
|
||||
}
|
||||
if ( unavailableDevice ) {
|
||||
options.push({value: unavailableDevice, label: game.i18n.localize("WEBRTC.UnavailableDevice")});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* 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 = foundry.utils.expandObject(formData);
|
||||
|
||||
// Update world settings
|
||||
if ( game.user.isGM ) {
|
||||
if ( settings.world.mode !== update.world.mode ) SettingsConfig.reloadConfirm({world: true});
|
||||
const world = foundry.utils.mergeObject(settings.world, update.world);
|
||||
await game.settings.set("core", "rtcWorldSettings", world);
|
||||
}
|
||||
|
||||
// Update client settings
|
||||
const client = foundry.utils.mergeObject(settings.client, update.client);
|
||||
await game.settings.set("core", "rtcClientSettings", client);
|
||||
}
|
||||
}
|
||||
68
resources/app/client/apps/av/camera-popout.js
Normal file
68
resources/app/client/apps/av/camera-popout.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
557
resources/app/client/apps/av/cameras.js
Normal file
557
resources/app/client/apps/av/cameras.js
Normal 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("USER.GM") : charname,
|
||||
volume: foundry.audio.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 = foundry.audio.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));
|
||||
}
|
||||
}
|
||||
34
resources/app/client/apps/dialogs/folder-export.js
Normal file
34
resources/app/client/apps/dialogs/folder-export.js
Normal 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"
|
||||
}});
|
||||
}
|
||||
}
|
||||
38
resources/app/client/apps/dice/dice-config.js
Normal file
38
resources/app/client/apps/dice/dice-config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* An application responsible for configuring how dice are rolled and evaluated.
|
||||
*/
|
||||
class DiceConfig extends FormApplication {
|
||||
/** @inheritDoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "dice-config",
|
||||
template: "templates/dice/config.html",
|
||||
title: "DICE.CONFIG.Title",
|
||||
width: 500
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
getData(options={}) {
|
||||
const context = super.getData(options);
|
||||
const { methods, dice } = CONFIG.Dice.fulfillment;
|
||||
if ( !game.user.hasPermission("MANUAL_ROLLS") ) delete methods.manual;
|
||||
const config = game.settings.get("core", "diceConfiguration");
|
||||
context.methods = methods;
|
||||
context.dice = Object.entries(dice).map(([k, { label, icon }]) => {
|
||||
return { label, icon, denomination: k, method: config[k] || "" };
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
const config = game.settings.get("core", "diceConfiguration");
|
||||
foundry.utils.mergeObject(config, formData);
|
||||
return game.settings.set("core", "diceConfiguration", config);
|
||||
}
|
||||
}
|
||||
890
resources/app/client/apps/form.js
Normal file
890
resources/app/client/apps/form.js
Normal file
@@ -0,0 +1,890 @@
|
||||
/**
|
||||
* @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 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 {Record<string, object>}
|
||||
*/
|
||||
this.editors = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* An array of custom element tag names that should be listened to for changes.
|
||||
* @type {string[]}
|
||||
* @protected
|
||||
*/
|
||||
static _customElements = Object.values(foundry.applications.elements).reduce((arr, el) => {
|
||||
if ( el.tagName ) arr.push(el.tagName);
|
||||
return arr;
|
||||
}, []);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const changeElements = ["input", "select", "textarea"].concat(this.constructor._customElements);
|
||||
html.on("change", changeElements.join(","), this._onChangeInput.bind(this));
|
||||
html.find(".editor-content[data-edit]").each((i, div) => this._activateEditor(div));
|
||||
html.find("button.file-picker").click(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) {
|
||||
|
||||
// Saving a <prose-mirror> element
|
||||
if ( event.currentTarget.matches("prose-mirror") ) return this._onSubmit(event);
|
||||
|
||||
// Ignore inputs inside an editor environment
|
||||
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;
|
||||
input.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Legacy behavior to support TinyMCE.
|
||||
// We could remove this in the future if we drop official support for TinyMCE.
|
||||
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 {object} [options]
|
||||
* @param {boolean} [options.remove] Remove the editor after saving its content
|
||||
* @param {boolean} [options.preventRender] Prevent normal re-rendering of the sheet after saving.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async saveEditor(name, {remove=true, preventRender}={}) {
|
||||
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"), { preventRender });
|
||||
|
||||
// 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});
|
||||
|
||||
// Define the editor configuration
|
||||
const initial = foundry.utils.getProperty(this.object, name);
|
||||
const editor = this.editors[name] = {
|
||||
options,
|
||||
target: name,
|
||||
button: button,
|
||||
hasButton: hasButton,
|
||||
mce: null,
|
||||
instance: null,
|
||||
active: !hasButton,
|
||||
changed: false,
|
||||
initial
|
||||
};
|
||||
|
||||
// Activate the editor immediately, or upon button click
|
||||
const activate = () => {
|
||||
editor.initial = foundry.utils.getProperty(this.object, 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})
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* 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.length = 0;
|
||||
for ( const fp of this.element[0].querySelectorAll("file-picker") ) fp.picker?.close();
|
||||
|
||||
// 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 {Promise<FormApplication>} Return a self-reference for convenient method chaining
|
||||
*/
|
||||
async submit(options={}) {
|
||||
if ( this._submitting ) return this;
|
||||
const submitEvent = new Event("submit");
|
||||
await this._onSubmit(submitEvent, options);
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get filepickers() {
|
||||
foundry.utils.logCompatibilityWarning("FormApplication#filepickers is deprecated and replaced by the <file-picker>"
|
||||
+ "HTML element", {since: 12, until: 14, once: true});
|
||||
return this.#filepickers;
|
||||
}
|
||||
|
||||
#filepickers = [];
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
_activateFilePicker(event) {
|
||||
foundry.utils.logCompatibilityWarning("FormApplication#_activateFilePicker is deprecated without replacement",
|
||||
{since: 12, until: 14, once: true});
|
||||
event.preventDefault();
|
||||
const options = this._getFilePickerOptions(event);
|
||||
const fp = new FilePicker(options);
|
||||
this.#filepickers.push(fp);
|
||||
return fp.browse();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
_getFilePickerOptions(event) {
|
||||
foundry.utils.logCompatibilityWarning("FormApplication#_getFilePickerOptions is deprecated without replacement",
|
||||
{since: 12, until: 14, once: true});
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
_onSelectFile(selection, filePicker) {}
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @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 */
|
||||
async _render(force, options={}) {
|
||||
|
||||
// Verify user permission to view and edit
|
||||
if ( !this._canUserView(game.user) ) {
|
||||
if ( !force ) return;
|
||||
const err = game.i18n.format("SHEETS.DocumentSheetPrivate", {
|
||||
type: game.i18n.localize(this.object.constructor.metadata.label)
|
||||
});
|
||||
ui.notifications.warn(err);
|
||||
return;
|
||||
}
|
||||
options.editable = options.editable ?? this.object.isOwner;
|
||||
|
||||
// Parent class rendering workflow
|
||||
await super._render(force, options);
|
||||
|
||||
// Register the active Application with the referenced Documents
|
||||
this.object.apps[this.appId] = this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @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.ariaLabel = game.i18n.localize("SHEETS.CopyUuid");
|
||||
idLink.dataset.tooltip = `SHEETS.CopyUuid`;
|
||||
idLink.dataset.tooltipDirection = "UP";
|
||||
idLink.innerHTML = '<i class="fa-solid fa-passport"></i>';
|
||||
idLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
game.clipboard.copyPlainText(this.object.uuid);
|
||||
ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "uuid", id: this.object.uuid}));
|
||||
});
|
||||
idLink.addEventListener("contextmenu", event => {
|
||||
event.preventDefault();
|
||||
game.clipboard.copyPlainText(this.object.id);
|
||||
ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "id", id: this.object.id}));
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
317
resources/app/client/apps/forms/actor.js
Normal file
317
resources/app/client/apps/forms/actor.js
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* 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() {
|
||||
if ( !this.actor.isToken ) return this.actor.name;
|
||||
return `[${game.i18n.localize(TokenDocument.metadata.label)}] ${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) ) 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 ( "link" in event.target.dataset ) 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 ( effect.target === this.actor ) 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, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {DragEvent} event The concluding DragEvent which provided the drop data
|
||||
* @returns {Promise<Item[]>}
|
||||
* @private
|
||||
*/
|
||||
async _onDropItemCreate(itemData, event) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
524
resources/app/client/apps/forms/adventure-exporter.js
Normal file
524
resources/app/client/apps/forms/adventure-exporter.js
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* 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 {Record<string, AdventureContentTreeRoot>}
|
||||
*/
|
||||
contentTree = {};
|
||||
|
||||
/**
|
||||
* A mapping which allows convenient access to content tree nodes by their folder ID
|
||||
* @type {Record<string, AdventureContentTreeNode>}
|
||||
*/
|
||||
#treeNodes = {};
|
||||
|
||||
/**
|
||||
* Track data for content which has been added to the adventure.
|
||||
* @type {Record<string, 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 {Record<string, 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 {Record<string, 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
|
||||
|| adventureData.folders?.some(f => CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(f.type));
|
||||
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);
|
||||
}
|
||||
}
|
||||
183
resources/app/client/apps/forms/adventure-importer.js
Normal file
183
resources/app/client/apps/forms/adventure-importer.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
60
resources/app/client/apps/forms/base-sheet.js
Normal file
60
resources/app/client/apps/forms/base-sheet.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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, {
|
||||
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);
|
||||
}
|
||||
}
|
||||
73
resources/app/client/apps/forms/card-config.js
Normal file
73
resources/app/client/apps/forms/card-config.js
Normal 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});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
231
resources/app/client/apps/forms/cards-config.js
Normal file
231
resources/app/client/apps/forms/cards-config.js
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Don't sort on yourself.
|
||||
if ( card === target ) return;
|
||||
|
||||
// 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"
|
||||
});
|
||||
}
|
||||
}
|
||||
82
resources/app/client/apps/forms/combat-config.js
Normal file
82
resources/app/client/apps/forms/combat-config.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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 {
|
||||
canConfigure: game.user.can("SETTINGS_MODIFY"),
|
||||
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["core.combatTheme"]);
|
||||
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["core.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)];
|
||||
game.audio.play(src, {context: game.audio.interface});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onChangeInput(event) {
|
||||
if ( event.currentTarget.name === "core.combatTheme" ) this.#audioPreviewState = 0;
|
||||
return super._onChangeInput(event);
|
||||
}
|
||||
}
|
||||
35
resources/app/client/apps/forms/combatant-config.js
Normal file
35
resources/app/client/apps/forms/combatant-config.js
Normal 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});
|
||||
}
|
||||
}
|
||||
}
|
||||
70
resources/app/client/apps/forms/default-sheets-config.js
Normal file
70
resources/app/client/apps/forms/default-sheets-config.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 current = game.settings.get("core", "sheetClasses");
|
||||
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 && !current[documentName]?.[subType] ) 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});
|
||||
}
|
||||
}
|
||||
112
resources/app/client/apps/forms/effect-config.js
Normal file
112
resources/app/client/apps/forms/effect-config.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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, {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" : ""}`)
|
||||
}
|
||||
};
|
||||
|
||||
// Status Conditions
|
||||
const statuses = CONFIG.statusEffects.map(s => {
|
||||
return {
|
||||
id: s.id,
|
||||
label: game.i18n.localize(s.name ?? /** @deprecated since v12 */ s.label),
|
||||
selected: context.data.statuses.includes(s.id) ? "selected" : ""
|
||||
};
|
||||
});
|
||||
|
||||
// Return rendering context
|
||||
return foundry.utils.mergeObject(context, {
|
||||
labels,
|
||||
effect: this.object, // Backwards compatibility
|
||||
data: this.object,
|
||||
isActorEffect: this.object.parent.documentName === "Actor",
|
||||
isItemEffect: this.object.parent.documentName === "Item",
|
||||
submitText: "EFFECT.Submit",
|
||||
statuses,
|
||||
modes: Object.entries(CONST.ACTIVE_EFFECT_MODES).reduce((obj, e) => {
|
||||
obj[e[1]] = game.i18n.localize(`EFFECT.MODE_${e[0]}`);
|
||||
return obj;
|
||||
}, {})
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/** @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 || {}));
|
||||
data.statuses ??= [];
|
||||
return data;
|
||||
}
|
||||
}
|
||||
70
resources/app/client/apps/forms/folder-edit.js
Normal file
70
resources/app/client/apps/forms/folder-edit.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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();
|
||||
return {
|
||||
folder: folder,
|
||||
name: folder._id ? folder.name : "",
|
||||
newName: Folder.implementation.defaultName({pack: folder.pack}),
|
||||
safeColor: folder.color?.css ?? "#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({pack: doc.pack});
|
||||
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;
|
||||
}
|
||||
}
|
||||
385
resources/app/client/apps/forms/fonts.js
Normal file
385
resources/app/client/apps/forms/fonts.js
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @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}`})),
|
||||
styles: [{value: "normal", label: "Normal"}, {value: "italic", label: "Italic"}]
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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 {Record<string, 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 {Record<string, FontFamilyDefinition>[]}
|
||||
* @protected
|
||||
*/
|
||||
static _collectDefinitions() {
|
||||
return [CONFIG.fontDefinitions, game.settings.get("core", this.SETTING)];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
390
resources/app/client/apps/forms/grid-config.js
Normal file
390
resources/app/client/apps/forms/grid-config.js
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the bound key handler function
|
||||
* @type {Function}
|
||||
*/
|
||||
#keyHandler;
|
||||
|
||||
/**
|
||||
* A reference to the bound mousewheel handler function
|
||||
* @type {Function}
|
||||
*/
|
||||
#wheelHandler;
|
||||
|
||||
/**
|
||||
* The preview scene
|
||||
* @type {Scene}
|
||||
*/
|
||||
#scene = null;
|
||||
|
||||
/**
|
||||
* The container containing the preview background image and grid
|
||||
* @type {PIXI.Container|null}
|
||||
*/
|
||||
#preview = null;
|
||||
|
||||
/**
|
||||
* The background preview
|
||||
* @type {PIXI.Sprite|null}
|
||||
*/
|
||||
#background = null;
|
||||
|
||||
/**
|
||||
* The grid preview
|
||||
* @type {GridMesh|null}
|
||||
*/
|
||||
#grid = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _render(force, options) {
|
||||
const states = Application.RENDER_STATES;
|
||||
if ( force && [states.CLOSED, states.NONE].includes(this._state) ) {
|
||||
if ( !this.object.background.src ) {
|
||||
ui.notifications.warn("WARNING.GridConfigNoBG", {localize: true});
|
||||
}
|
||||
this.#scene = this.object.clone();
|
||||
}
|
||||
await super._render(force, options);
|
||||
await this.#createPreview();
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options={}) {
|
||||
const bg = getTexture(this.#scene.background.src);
|
||||
return {
|
||||
gridTypes: SceneConfig._getGridTypes(),
|
||||
scale: this.#scene.background.src ? this.object.width / bg.width : 1,
|
||||
scene: this.#scene
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_getSubmitData(updateData) {
|
||||
const formData = super._getSubmitData(updateData);
|
||||
const bg = getTexture(this.#scene.background.src);
|
||||
const tex = bg ? bg : {width: this.object.width, height: this.object.height};
|
||||
formData.width = tex.width * formData.scale;
|
||||
formData.height = tex.height * formData.scale;
|
||||
delete formData.scale;
|
||||
return formData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async close(options={}) {
|
||||
document.removeEventListener("keydown", this.#keyHandler);
|
||||
document.removeEventListener("wheel", this.#wheelHandler);
|
||||
this.#keyHandler = this.#wheelHandler = undefined;
|
||||
await this.sheet.maximize();
|
||||
|
||||
const states = Application.RENDER_STATES;
|
||||
if ( options.force || [states.RENDERED, states.ERROR].includes(this._state) ) {
|
||||
this.#scene = null;
|
||||
this.#destroyPreview();
|
||||
}
|
||||
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
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.#onReset.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle keyboard events.
|
||||
* @param {KeyboardEvent} event The original keydown event
|
||||
*/
|
||||
#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();
|
||||
const 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();
|
||||
const 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
|
||||
*/
|
||||
#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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle reset.
|
||||
*/
|
||||
#onReset() {
|
||||
if ( !this.#scene ) return;
|
||||
this.#scene = this.object.clone();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onChangeInput(event) {
|
||||
await super._onChangeInput(event);
|
||||
const previewData = this._getSubmitData();
|
||||
this.#previewChanges(previewData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
const changes = foundry.utils.flattenObject(
|
||||
foundry.utils.diffObject(this.object.toObject(), foundry.utils.expandObject(formData)));
|
||||
if ( ["width", "height", "padding", "background.offsetX", "background.offsetY", "grid.size", "grid.type"].some(k => k in changes) ) {
|
||||
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 this.object.update(formData, {fromSheet: true});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Previewing and Updating Functions */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create preview
|
||||
*/
|
||||
async #createPreview() {
|
||||
if ( !this.#scene ) return;
|
||||
if ( this.#preview ) this.#destroyPreview();
|
||||
this.#preview = canvas.stage.addChild(new PIXI.Container());
|
||||
this.#preview.eventMode = "none";
|
||||
const fill = this.#preview.addChild(new PIXI.Sprite(PIXI.Texture.WHITE));
|
||||
fill.tint = 0x000000;
|
||||
fill.eventMode = "static";
|
||||
fill.hitArea = canvas.app.screen;
|
||||
// Patching updateTransform to render the fill in screen space
|
||||
fill.updateTransform = function() {
|
||||
const screen = canvas.app.screen;
|
||||
this.width = screen.width;
|
||||
this.height = screen.height;
|
||||
this._boundsID++;
|
||||
this.transform.updateTransform(PIXI.Transform.IDENTITY);
|
||||
this.worldAlpha = this.alpha;
|
||||
};
|
||||
this.#background = this.#preview.addChild(new PIXI.Sprite());
|
||||
this.#background.eventMode = "none";
|
||||
if ( this.#scene.background.src ) {
|
||||
try {
|
||||
this.#background.texture = await loadTexture(this.#scene.background.src);
|
||||
} catch(e) {
|
||||
this.#background.texture = PIXI.Texture.WHITE;
|
||||
console.error(e);
|
||||
}
|
||||
} else {
|
||||
this.#background.texture = PIXI.Texture.WHITE;
|
||||
}
|
||||
this.#grid = this.#preview.addChild(new GridMesh().initialize({color: 0xFF0000}));
|
||||
this.#refreshPreview();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Preview changes to the Scene document as if they were true document updates.
|
||||
* @param {object} [change] A change to preview.
|
||||
*/
|
||||
#previewChanges(change) {
|
||||
if ( !this.#scene ) return;
|
||||
if ( change ) this.#scene.updateSource(change);
|
||||
this.#refreshPreview();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Refresh the preview
|
||||
*/
|
||||
#refreshPreview() {
|
||||
if ( !this.#scene || (this.#preview?.destroyed !== false) ) return;
|
||||
|
||||
// Update the background image
|
||||
const d = this.#scene.dimensions;
|
||||
this.#background.position.set(d.sceneX, d.sceneY);
|
||||
this.#background.width = d.sceneWidth;
|
||||
this.#background.height = d.sceneHeight;
|
||||
|
||||
// Update the grid
|
||||
this.#grid.initialize({
|
||||
type: this.#scene.grid.type,
|
||||
width: d.width,
|
||||
height: d.height,
|
||||
size: d.size
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Destroy the preview
|
||||
*/
|
||||
#destroyPreview() {
|
||||
if ( this.#preview?.destroyed === false ) this.#preview.destroy({children: true});
|
||||
this.#preview = null;
|
||||
this.#background = null;
|
||||
this.#grid = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Scale the background size relative to the grid size
|
||||
* @param {number} delta The directional change in background size
|
||||
*/
|
||||
#scaleBackgroundSize(delta) {
|
||||
const scale = (parseFloat(this.form.scale.value) + (delta * 0.001)).toNearest(0.001);
|
||||
this.form.scale.value = Math.clamp(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
|
||||
*/
|
||||
#scaleGridSize(delta) {
|
||||
const gridSize = this.form.elements["grid.size"];
|
||||
gridSize.value = Math.clamp(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=0] The number of pixels to shift in the x-direction
|
||||
* @param {number} [position.deltaY=0] The number of pixels to shift in the y-direction
|
||||
*/
|
||||
#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}));
|
||||
}
|
||||
}
|
||||
261
resources/app/client/apps/forms/image-popout.js
Normal file
261
resources/app/client/apps/forms/image-popout.js
Normal 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;
|
||||
}
|
||||
}
|
||||
58
resources/app/client/apps/forms/item.js
Normal file
58
resources/app/client/apps/forms/item.js
Normal 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;
|
||||
}
|
||||
}
|
||||
613
resources/app/client/apps/forms/journal-page-sheet.js
Normal file
613
resources/app/client/apps/forms/journal-page-sheet.js
Normal file
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* 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 {Record<string, 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;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A method called by the journal sheet when the view mode of the page sheet is closed.
|
||||
* @internal
|
||||
*/
|
||||
_closeView() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* 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.journal-page-content"});
|
||||
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
|
||||
})
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async saveEditor(name, { preventRender=true, ...options }={}) {
|
||||
return super.saveEditor(name, { ...options, preventRender });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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 {Record<string, 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);
|
||||
}
|
||||
}
|
||||
1064
resources/app/client/apps/forms/journal-sheet.js
Normal file
1064
resources/app/client/apps/forms/journal-sheet.js
Normal file
File diff suppressed because it is too large
Load Diff
102
resources/app/client/apps/forms/macro-config.js
Normal file
102
resources/app/client/apps/forms/macro-config.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Should this Macro be created in a specific hotbar slot?
|
||||
* @internal
|
||||
*/
|
||||
_hotbarSlot;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options={}) {
|
||||
const data = super.getData();
|
||||
data.macroTypes = game.documentTypes.Macro.map(t => ({
|
||||
value: t,
|
||||
label: game.i18n.localize(CONFIG.Macro.typeLabels[t]),
|
||||
disabled: (t === "script") && !game.user.can("MACRO_SCRIPT")
|
||||
}));
|
||||
data.macroScopes = CONST.MACRO_SCOPES.map(s => ({value: s, label: s}));
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find("button.execute").click(this.#onExecute.bind(this));
|
||||
html.find('select[name="type"]').change(this.#updateCommandDisabled.bind(this));
|
||||
this.#updateCommandDisabled();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_disableFields(form) {
|
||||
super._disableFields(form);
|
||||
if ( this.object.canExecute ) form.querySelector("button.execute").disabled = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the disabled state of the command textarea.
|
||||
*/
|
||||
#updateCommandDisabled() {
|
||||
const type = this.element[0].querySelector('select[name="type"]').value;
|
||||
this.element[0].querySelector('textarea[name="command"]').disabled = (type === "script") && !game.user.can("MACRO_SCRIPT");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Save and execute the macro using the button on the configuration sheet
|
||||
* @param {MouseEvent} event The originating click event
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #onExecute(event) {
|
||||
event.preventDefault();
|
||||
await this._updateObject(event, this._getSubmitData()); // Submit pending changes
|
||||
this.object.execute(); // Execute the macro
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
const updateData = foundry.utils.expandObject(formData);
|
||||
try {
|
||||
if ( this.object.id ) {
|
||||
this.object.updateSource(updateData, { dryRun: true, fallback: false });
|
||||
return await super._updateObject(event, formData);
|
||||
} else {
|
||||
const macro = await Macro.implementation.create(new Macro.implementation(updateData));
|
||||
if ( !macro ) throw new Error("Failed to create Macro");
|
||||
this.object = macro;
|
||||
await game.user.assignHotbarMacro(macro, this._hotbarSlot);
|
||||
}
|
||||
} catch(err) {
|
||||
Hooks.onError("MacroConfig#_updateObject", err, { notify: "error" });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
resources/app/client/apps/forms/measure-template.js
Normal file
41
resources/app/client/apps/forms/measure-template.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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: this.document.parent.grid.units || game.i18n.localize("GridUnits"),
|
||||
userColor: game.user.color,
|
||||
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);
|
||||
}
|
||||
}
|
||||
139
resources/app/client/apps/forms/ownership.js
Normal file
139
resources/app/client/apps/forms/ownership.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* A generic application for configuring permissions for various Document types.
|
||||
*/
|
||||
class DocumentOwnershipConfig extends DocumentSheet {
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "permission",
|
||||
template: "templates/apps/ownership.html",
|
||||
width: 400
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Are Gamemaster users currently hidden?
|
||||
* @type {boolean}
|
||||
*/
|
||||
static #gmHidden = true;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get title() {
|
||||
return `${game.i18n.localize("OWNERSHIP.Title")}: ${this.document.name}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async 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,
|
||||
cssClass: user.isGM ? "gm" : "",
|
||||
icon: user.isGM ? "fa-solid fa-crown": "",
|
||||
tooltip: user.isGM ? game.i18n.localize("USER.RoleGamemaster") : ""
|
||||
};
|
||||
}).sort((a, b) => a.user.name.localeCompare(b.user.name, game.i18n.lang));
|
||||
|
||||
// Construct and return the data object
|
||||
return {
|
||||
currentDefault: ownership?.default ?? CONST.DOCUMENT_META_OWNERSHIP_LEVELS.DEFAULT,
|
||||
instructions: game.i18n.localize(isFolder ? "OWNERSHIP.HintFolder" : "OWNERSHIP.HintDocument"),
|
||||
defaultLevels,
|
||||
playerLevels,
|
||||
isFolder,
|
||||
showGM: !DocumentOwnershipConfig.#gmHidden,
|
||||
users
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
// Toggle GM user visibility
|
||||
const toggle = html[0].querySelector("input#show-gm-toggle");
|
||||
toggle.addEventListener("change", () => this.#toggleGamemasters());
|
||||
this.#toggleGamemasters(DocumentOwnershipConfig.#gmHidden);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @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});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle CSS classes which display or hide gamemaster users
|
||||
* @param {boolean} hidden Should gamemaster users be hidden?
|
||||
*/
|
||||
#toggleGamemasters(hidden) {
|
||||
hidden ??= !DocumentOwnershipConfig.#gmHidden;
|
||||
this.form.classList.toggle("no-gm", hidden);
|
||||
DocumentOwnershipConfig.#gmHidden = hidden;
|
||||
this.setPosition({height: "auto", width: this.options.width});
|
||||
}
|
||||
}
|
||||
85
resources/app/client/apps/forms/playlist-config.js
Normal file
85
resources/app/client/apps/forms/playlist-config.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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;
|
||||
}, {});
|
||||
data.channels = CONST.AUDIO_CHANNELS;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find("file-picker").on("change", this._onBulkImport.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Special actions to take when a bulk-import path is selected in the FilePicker.
|
||||
* @param {Event} event The <file-picker> change event
|
||||
*/
|
||||
async _onBulkImport(event) {
|
||||
|
||||
// Get audio files
|
||||
const fp = event.target;
|
||||
fp.picker.type = "audio";
|
||||
const contents = await fp.picker.browse(fp.value);
|
||||
fp.picker.type = "folder";
|
||||
if ( !contents?.files?.length ) return;
|
||||
|
||||
// Prepare PlaylistSound data
|
||||
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: foundry.audio.AudioHelper.getDefaultSoundName(src), path: src };
|
||||
arr.push(soundData);
|
||||
return arr;
|
||||
}, []);
|
||||
|
||||
// Create all PlaylistSound documents
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
resources/app/client/apps/forms/playlist-sound-config.js
Normal file
73
resources/app/client/apps/forms/playlist-sound-config.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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 = foundry.audio.AudioHelper.volumeToInput(this.document.volume);
|
||||
context.channels = CONST.AUDIO_CHANNELS;
|
||||
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 = foundry.audio.AudioHelper.getDefaultSoundName(field.value);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _updateObject(event, formData) {
|
||||
formData["volume"] = foundry.audio.AudioHelper.inputToVolume(formData["lvolume"]);
|
||||
if (this.object.id) return this.object.update(formData);
|
||||
return this.object.constructor.create(formData, {parent: this.object.parent});
|
||||
}
|
||||
}
|
||||
450
resources/app/client/apps/forms/roll-table-config.js
Normal file
450
resources/app/client/apps/forms/roll-table-config.js
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* 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 tbody"],
|
||||
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, {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,
|
||||
resultTypes: Object.entries(CONST.TABLE_RESULT_TYPES).reduce((obj, v) => {
|
||||
obj[v[1]] = game.i18n.localize(`TABLE.RESULT_TYPES.${v[0]}.label`);
|
||||
return obj;
|
||||
}, {}),
|
||||
documentTypes: CONST.COMPENDIUM_DOCUMENT_TYPES.map(d =>
|
||||
({value: d, label: game.i18n.localize(getDocumentClass(d).metadata.label)})),
|
||||
compendiumPacks: Array.from(game.packs.keys()).map(k => ({value: k, label: k}))
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
// We need to disable roll button if the document is not editable AND has no formula
|
||||
if ( !this.isEditable && !this.document.formula ) return;
|
||||
|
||||
// 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.COMPENDIUM_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);
|
||||
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 = CONST.TABLE_RESULT_TYPES.TEXT;
|
||||
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 <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);
|
||||
});
|
||||
}
|
||||
}
|
||||
526
resources/app/client/apps/forms/scene-config.js
Normal file
526
resources/app/client/apps/forms/scene-config.js
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* 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[data-group="main"]', contentSelector: "form", initial: "basic"},
|
||||
{navSelector: '.tabs[data-group="ambience"]', contentSelector: '.tab[data-tab="ambience"]', 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 && !["createScene", "updateScene"].includes(options.renderContext) ) return this;
|
||||
return super.render(force, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
const context = super.getData(options);
|
||||
context.data = this.document.toObject(); // Source data, not derived
|
||||
context.playlistSound = this.document.playlistSound?.id || "";
|
||||
context.foregroundElevation = this.document.foregroundElevation;
|
||||
|
||||
// Selectable types
|
||||
context.minGrid = CONST.GRID_MIN_SIZE;
|
||||
context.gridTypes = this.constructor._getGridTypes();
|
||||
context.gridStyles = CONFIG.Canvas.gridStyles;
|
||||
context.weatherTypes = this._getWeatherTypes();
|
||||
context.ownerships = [
|
||||
{value: 0, label: "SCENES.AccessibilityGM"},
|
||||
{value: 2, label: "SCENES.AccessibilityAll"}
|
||||
];
|
||||
|
||||
// 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) ?? [];
|
||||
context.isEnvironment = (this._tabs[0].active === "ambience") && (this._tabs[1].active === "environment");
|
||||
context.baseHueSliderDisabled = (this.document.environment.base.intensity === 0);
|
||||
context.darknessHueSliderDisabled = (this.document.environment.dark.intensity === 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;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _renderInner(...args) {
|
||||
await loadTemplates([
|
||||
"templates/scene/parts/scene-ambience.html"
|
||||
]);
|
||||
return super._renderInner(...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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, game.i18n.lang));
|
||||
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));
|
||||
html.find('button[type="reset"]').click(this._onResetForm.bind(this));
|
||||
html.find("hue-slider").change(this._onChangeRange.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("SCENES.CaptureInitialViewPosition", {localize: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events to open the grid configuration application
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
async _onGridConfig(event) {
|
||||
event.preventDefault();
|
||||
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) {
|
||||
if ( event.target.name === "width" || event.target.name === "height" ) this._onChangeDimensions(event);
|
||||
if ( event.target.name === "environment.darknessLock" ) await this.#onDarknessLockChange(event.target.checked);
|
||||
this._previewScene(event.target.name);
|
||||
return super._onChangeInput(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle darkness lock change and update immediately the database.
|
||||
* @param {boolean} darknessLock If the darkness lock is checked or not.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #onDarknessLockChange(darknessLock) {
|
||||
const darknessLevelForm = this.form["environment.darknessLevel"];
|
||||
darknessLevelForm.disabled = darknessLock;
|
||||
await this.document.update({
|
||||
environment: {
|
||||
darknessLock,
|
||||
darknessLevel: darknessLevelForm.valueAsNumber
|
||||
}}, {render: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onChangeColorPicker(event) {
|
||||
super._onChangeColorPicker(event);
|
||||
this._previewScene(event.target.dataset.edit);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onChangeRange(event) {
|
||||
super._onChangeRange(event);
|
||||
for ( const target of ["base", "dark"] ) {
|
||||
if ( event.target.name === `environment.${target}.intensity` ) {
|
||||
const intensity = this.form[`environment.${target}.intensity`].valueAsNumber;
|
||||
this.form[`environment.${target}.hue`].disabled = (intensity === 0);
|
||||
}
|
||||
}
|
||||
this._previewScene(event.target.name);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onChangeTab(event, tabs, active) {
|
||||
super._onChangeTab(event, tabs, active);
|
||||
const enabled = (this._tabs[0].active === "ambience") && (this._tabs[1].active === "environment");
|
||||
this.element.find('button[type="reset"]').toggleClass("hidden", !enabled);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reset the values of the environment attributes to their default state.
|
||||
* @param {PointerEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onResetForm(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Get base and dark ambience defaults and originals
|
||||
const def = Scene.cleanData().environment;
|
||||
const ori = this.document.toObject().environment;
|
||||
const defaults = {base: def.base, dark: def.dark};
|
||||
const original = {base: ori.base, dark: ori.dark};
|
||||
|
||||
// Reset the elements to the default values
|
||||
for ( const target of ["base", "dark"] ) {
|
||||
this.form[`environment.${target}.hue`].disabled = (defaults[target].intensity === 0);
|
||||
this.form[`environment.${target}.intensity`].value = defaults[target].intensity;
|
||||
this.form[`environment.${target}.luminosity`].value = defaults[target].luminosity;
|
||||
this.form[`environment.${target}.saturation`].value = defaults[target].saturation;
|
||||
this.form[`environment.${target}.shadows`].value = defaults[target].shadows;
|
||||
this.form[`environment.${target}.hue`].value = defaults[target].hue;
|
||||
}
|
||||
|
||||
// Update the document with the default environment values
|
||||
this.document.updateSource({environment: defaults});
|
||||
|
||||
// Preview the scene and re-render the config
|
||||
this._previewScene("forceEnvironmentPreview");
|
||||
this.render();
|
||||
|
||||
// Restore original environment values
|
||||
this.document.updateSource({environment: original});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Live update the scene as certain properties are changed.
|
||||
* @param {string} changed The changed property.
|
||||
* @internal
|
||||
*/
|
||||
_previewScene(changed) {
|
||||
if ( !this.object.isView || !canvas.ready || !changed ) return;
|
||||
const force = changed.includes("force");
|
||||
|
||||
// Preview triggered for the grid
|
||||
if ( ["grid.style", "grid.thickness", "grid.color", "grid.alpha"].includes(changed) || force ) {
|
||||
canvas.interface.grid.initializeMesh({
|
||||
style: this.form["grid.style"].value,
|
||||
thickness: Number(this.form["grid.thickness"].value),
|
||||
color: this.form["grid.color"].value,
|
||||
alpha: Number(this.form["grid.alpha"].value)
|
||||
});
|
||||
}
|
||||
|
||||
// To easily track all the environment changes
|
||||
const environmentChange = changed.includes("environment.") || changed.includes("forceEnvironmentPreview") || force;
|
||||
|
||||
// Preview triggered for the ambience manager
|
||||
if ( ["backgroundColor", "fog.colors.explored", "fog.colors.unexplored"].includes(changed)
|
||||
|| environmentChange ) {
|
||||
canvas.environment.initialize(this.#getAmbienceFormData());
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the ambience form data.
|
||||
* @returns {Object}
|
||||
*/
|
||||
#getAmbienceFormData() {
|
||||
const fd = new FormDataExtended(this.form);
|
||||
const formData = foundry.utils.expandObject(fd.object);
|
||||
return {
|
||||
backgroundColor: formData.backgroundColor,
|
||||
fogExploredColor: formData.fog.colors.explored,
|
||||
fogUnexploredColor: formData.fog.colors.unexplored,
|
||||
environment: formData.environment
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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;
|
||||
canvas.scene.reset();
|
||||
canvas.environment.initialize();
|
||||
canvas.interface.grid.initializeMesh(canvas.scene.grid);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// FIXME: Ideally, FormDataExtended would know to set these fields to null instead of keeping a blank string
|
||||
// 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["fog.overlay"] === "" ) formData["fog.overlay"] = null;
|
||||
|
||||
// The same for fog colors
|
||||
if ( formData["fog.colors.unexplored"] === "" ) formData["fog.colors.unexplored"] = null;
|
||||
if ( formData["fog.colors.explored"] === "" ) formData["fog.colors.explored"] = 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
|
||||
delete formData["environment.darknessLock"];
|
||||
return scene.update(formData, {autoReposition});
|
||||
}
|
||||
}
|
||||
364
resources/app/client/apps/forms/sheet-config.js
Normal file
364
resources/app/client/apps/forms/sheet-config.js
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 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: foundry.applications.sheets.UserConfig,
|
||||
// Embedded Documents
|
||||
ActiveEffect: ActiveEffectConfig,
|
||||
AmbientLight: foundry.applications.sheets.AmbientLightConfig,
|
||||
AmbientSound: foundry.applications.sheets.AmbientSoundConfig,
|
||||
Card: CardConfig,
|
||||
Combatant: CombatantConfig,
|
||||
Drawing: DrawingConfig,
|
||||
MeasuredTemplate: MeasuredTemplateConfig,
|
||||
Note: NoteConfig,
|
||||
PlaylistSound: PlaylistSoundConfig,
|
||||
Region: foundry.applications.sheets.RegionConfig,
|
||||
RegionBehavior: foundry.applications.sheets.RegionBehaviorConfig,
|
||||
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")
|
||||
});
|
||||
}
|
||||
}
|
||||
234
resources/app/client/apps/hud/chatbubble.js
Normal file
234
resources/app/client/apps/hud/chatbubble.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* @typedef {Object} 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, { rollData: actor?.getRollData() });
|
||||
let html = $(await this._renderHTML({token, message, cssClasses: cssClasses.join(" ")}));
|
||||
|
||||
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.clamp(1000, ms, 20000);
|
||||
}
|
||||
}
|
||||
73
resources/app/client/apps/hud/container.js
Normal file
73
resources/app/client/apps/hud/container.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The Heads-Up Display is a canvas-sized Application which renders HTML overtop of the game canvas.
|
||||
*/
|
||||
class HeadsUpDisplay extends Application {
|
||||
|
||||
/**
|
||||
* Token HUD
|
||||
* @type {TokenHUD}
|
||||
*/
|
||||
token = new CONFIG.Token.hudClass();
|
||||
|
||||
/**
|
||||
* Tile HUD
|
||||
* @type {TileHUD}
|
||||
*/
|
||||
tile = new CONFIG.Tile.hudClass();
|
||||
|
||||
/**
|
||||
* Drawing HUD
|
||||
* @type {DrawingHUD}
|
||||
*/
|
||||
drawing = new CONFIG.Drawing.hudClass();
|
||||
|
||||
/**
|
||||
* Chat Bubbles
|
||||
* @type {ChatBubbles}
|
||||
*/
|
||||
bubbles = new CONFIG.Canvas.chatBubblesClass();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @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})`;
|
||||
}
|
||||
}
|
||||
1051
resources/app/client/apps/hud/controls.js
vendored
Normal file
1051
resources/app/client/apps/hud/controls.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
423
resources/app/client/apps/hud/hotbar.js
Normal file
423
resources/app/client/apps/hud/hotbar.js
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* 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 foundry.utils.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.clamp(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 temporary Macro
|
||||
if ( li.classList.contains("inactive") ) {
|
||||
const cls = getDocumentClass("Macro");
|
||||
const macro = new cls({name: cls.defaultName({type: "chat"}), type: "chat", scope: "global"});
|
||||
macro.sheet._hotbarSlot = 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();`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
312
resources/app/client/apps/hud/hud.js
Normal file
312
resources/app/client/apps/hud/hud.js
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* An abstract base class for displaying a heads-up-display interface bound to a Placeable Object on the canvas
|
||||
* @interface
|
||||
* @template {PlaceableObject} ActiveHUDObject
|
||||
* @template {CanvasDocument} ActiveHUDDocument
|
||||
* @template {PlaceablesLayer} ActiveHUDLayer
|
||||
*/
|
||||
class BasePlaceableHUD extends Application {
|
||||
|
||||
/**
|
||||
* Reference a PlaceableObject this HUD is currently bound to.
|
||||
* @type {ActiveHUDObject}
|
||||
*/
|
||||
object;
|
||||
|
||||
/**
|
||||
* Track whether a control icon is hovered or not
|
||||
* @type {boolean}
|
||||
*/
|
||||
#hoverControlIcon = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["placeable-hud"],
|
||||
popOut: false
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convenience access to the Document which this HUD modifies.
|
||||
* @returns {ActiveHUDDocument}
|
||||
*/
|
||||
get document() {
|
||||
return this.object?.document;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convenience access for the canvas layer which this HUD modifies
|
||||
* @type {ActiveHUDLayer}
|
||||
*/
|
||||
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,
|
||||
isGamePaused: game.paused,
|
||||
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) {
|
||||
// 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)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle initial click to focus an attribute update field
|
||||
* @param {MouseEvent} event The mouse click event
|
||||
* @protected
|
||||
*/
|
||||
_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
|
||||
* @protected
|
||||
*/
|
||||
_onAttributeKeydown(event) {
|
||||
if ( (event.code === "Enter") || (event.code === "NumpadEnter") ) event.currentTarget.blur();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle attribute updates
|
||||
* @param {FocusEvent} event The originating focusout event
|
||||
*/
|
||||
_onAttributeUpdate(event) {
|
||||
event.preventDefault();
|
||||
if ( !this.object ) return;
|
||||
const input = event.currentTarget;
|
||||
this._updateAttribute(input.name, event.currentTarget.value.trim());
|
||||
if ( !this.#hoverControlIcon ) this.clear();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle attribute bar update
|
||||
* @param {string} name The name of the attribute
|
||||
* @param {string} input The raw string input value for the update
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _updateAttribute(name, input) {
|
||||
const current = foundry.utils.getProperty(this.object.document, name);
|
||||
const {value} = this._parseAttributeInput(name, current, input);
|
||||
await this.object.document.update({[name]: value});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Parse an attribute bar input string into a new value for the attribute field.
|
||||
* @param {string} name The name of the attribute
|
||||
* @param {object|number} attr The current value of the attribute
|
||||
* @param {string} input The raw string input value
|
||||
* @returns {{value: number, [delta]: number, isDelta: boolean, isBar: boolean}} The parsed input value
|
||||
* @protected
|
||||
*/
|
||||
_parseAttributeInput(name, attr, input) {
|
||||
const isBar = (typeof attr === "object") && ("max" in attr);
|
||||
const isEqual = input.startsWith("=");
|
||||
const isDelta = input.startsWith("+") || input.startsWith("-");
|
||||
const current = isBar ? attr.value : attr;
|
||||
let v;
|
||||
|
||||
// Explicit equality
|
||||
if ( isEqual ) input = input.slice(1);
|
||||
|
||||
// Percentage change
|
||||
if ( input.endsWith("%") ) {
|
||||
const p = Number(input.slice(0, -1)) / 100;
|
||||
if ( isBar ) v = attr.max * p;
|
||||
else v = Math.abs(current) * p;
|
||||
}
|
||||
|
||||
// Additive delta
|
||||
else v = Number(input);
|
||||
|
||||
// Return parsed input
|
||||
const value = isDelta ? current + v : v;
|
||||
const delta = isDelta ? v : undefined;
|
||||
return {value, delta, isDelta, isBar};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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 {PointerEvent} event The originating mouse click event
|
||||
* @param {boolean} up Move the object upwards in the vertical stack?
|
||||
* If false, the object is moved downwards.
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _onSort(event, up) {
|
||||
event.preventDefault();
|
||||
this.layer._sendToBackOrBringToFront(up);
|
||||
}
|
||||
}
|
||||
82
resources/app/client/apps/hud/menu.js
Normal file
82
resources/app/client/apps/hud/menu.js
Normal 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 {Record<string, {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);
|
||||
}
|
||||
}
|
||||
310
resources/app/client/apps/hud/navigation.js
Normal file
310
resources/app/client/apps/hud/navigation.js
Normal 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", "active", "navigation", "navName", "navOrder"];
|
||||
if ( (renderContext === "updateScene") && !renderData.some(d => updateKeys.some(k => k in d)) ) 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.css});
|
||||
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.clamp(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);
|
||||
}
|
||||
}
|
||||
18
resources/app/client/apps/hud/pause.js
Normal file
18
resources/app/client/apps/hud/pause.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
289
resources/app/client/apps/hud/players.js
Normal file
289
resources/app/client/apps/hud/players.js
Normal file
@@ -0,0 +1,289 @@
|
||||
|
||||
/**
|
||||
* 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;
|
||||
if ( renderContext === "updateUser" ) {
|
||||
const updateKeys = ["name", "pronouns", "ownership", "ownership.default", "active", "navigation"];
|
||||
if ( !renderData.some(d => updateKeys.some(k => k in d)) ) 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.css : "#333333";
|
||||
u.border = u.active ? user.border.css : "#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, game.i18n.lang);
|
||||
});
|
||||
|
||||
// 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.`);
|
||||
}
|
||||
}
|
||||
479
resources/app/client/apps/i18n.js
Normal file
479
resources/app/client/apps/i18n.js
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* 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 {Record<string, 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 ( const t of types ) {
|
||||
if ( config.typeLabels[t] ) continue;
|
||||
const key = t === CONST.BASE_DOCUMENT_TYPE ? "TYPES.Base" :`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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-localize data models
|
||||
Localization.#localizeDataModels();
|
||||
Hooks.callAll("i18nInit");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Data Model Localization */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform one-time localization of the fields in a DataModel schema, translating their label and hint properties.
|
||||
* @param {typeof DataModel} model The DataModel class to localize
|
||||
* @param {object} options Options which configure how localization is performed
|
||||
* @param {string[]} [options.prefixes] An array of localization key prefixes to use. If not specified, prefixes
|
||||
* are learned from the DataModel.LOCALIZATION_PREFIXES static property.
|
||||
* @param {string} [options.prefixPath] A localization path prefix used to prefix all field names within this
|
||||
* model. This is generally not required.
|
||||
*
|
||||
* @example
|
||||
* JavaScript class definition and localization call.
|
||||
* ```js
|
||||
* class MyDataModel extends foundry.abstract.DataModel {
|
||||
* static defineSchema() {
|
||||
* return {
|
||||
* foo: new foundry.data.fields.StringField(),
|
||||
* bar: new foundry.data.fields.NumberField()
|
||||
* };
|
||||
* }
|
||||
* static LOCALIZATION_PREFIXES = ["MYMODULE.MYDATAMODEL"];
|
||||
* }
|
||||
*
|
||||
* Hooks.on("i18nInit", () => {
|
||||
* Localization.localizeDataModel(MyDataModel);
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* JSON localization file
|
||||
* ```json
|
||||
* {
|
||||
* "MYMODULE": {
|
||||
* "MYDATAMODEL": {
|
||||
* "FIELDS" : {
|
||||
* "foo": {
|
||||
* "label": "Foo",
|
||||
* "hint": "Instructions for foo"
|
||||
* },
|
||||
* "bar": {
|
||||
* "label": "Bar",
|
||||
* "hint": "Instructions for bar"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static localizeDataModel(model, {prefixes, prefixPath}={}) {
|
||||
prefixes ||= model.LOCALIZATION_PREFIXES;
|
||||
Localization.#localizeSchema(model.schema, prefixes, {prefixPath});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform one-time localization of data model definitions which localizes their label and hint properties.
|
||||
*/
|
||||
static #localizeDataModels() {
|
||||
for ( const document of Object.values(foundry.documents) ) {
|
||||
const cls = document.implementation;
|
||||
Localization.localizeDataModel(cls);
|
||||
for ( const model of Object.values(CONFIG[cls.documentName].dataModels ?? {}) ) {
|
||||
Localization.localizeDataModel(model, {prefixPath: "system."});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Localize the "label" and "hint" properties for all fields in a data schema.
|
||||
* @param {SchemaField} schema
|
||||
* @param {string[]} prefixes
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.prefixPath]
|
||||
*/
|
||||
static #localizeSchema(schema, prefixes=[], {prefixPath=""}={}) {
|
||||
const getRules = prefixes => {
|
||||
const rules = {};
|
||||
for ( const prefix of prefixes ) {
|
||||
if ( game.i18n.lang !== "en" ) {
|
||||
const fallback = foundry.utils.getProperty(game.i18n._fallback, `${prefix}.FIELDS`);
|
||||
Object.assign(rules, fallback);
|
||||
}
|
||||
Object.assign(rules, foundry.utils.getProperty(game.i18n.translations, `${prefix}.FIELDS`));
|
||||
}
|
||||
return rules;
|
||||
};
|
||||
const rules = getRules(prefixes);
|
||||
|
||||
// Apply localization to fields of the model
|
||||
schema.apply(function() {
|
||||
|
||||
// Inner models may have prefixes which take precedence
|
||||
if ( this instanceof foundry.data.fields.EmbeddedDataField ) {
|
||||
if ( this.model.LOCALIZATION_PREFIXES.length ) {
|
||||
foundry.utils.setProperty(rules, this.fieldPath, getRules(this.model.LOCALIZATION_PREFIXES));
|
||||
}
|
||||
}
|
||||
|
||||
// Localize model fields
|
||||
let k = this.fieldPath;
|
||||
if ( prefixPath ) k = k.replace(prefixPath, "");
|
||||
const field = foundry.utils.getProperty(rules, k);
|
||||
if ( field?.label ) this.label = game.i18n.localize(field.label);
|
||||
if ( field?.hint ) this.hint = game.i18n.localize(field.hint);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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 || ((p.type === "module") && !p.active) ) 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;
|
||||
}
|
||||
}
|
||||
141
resources/app/client/apps/placeables/drawing-config.js
Normal file
141
resources/app/client/apps/placeables/drawing-config.js
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @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(),
|
||||
drawingRoles: {
|
||||
object: "DRAWING.Object",
|
||||
information: "DRAWING.Information"
|
||||
},
|
||||
currentRole: this.document.interface ? "information" : "object",
|
||||
object: this.document.toObject(),
|
||||
options: this.options,
|
||||
gridUnits: this.document.parent?.grid.units || canvas.scene.grid.units || game.i18n.localize("GridUnits"),
|
||||
userColor: game.user.color,
|
||||
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);
|
||||
}
|
||||
|
||||
// Assign location
|
||||
formData.interface = (formData.drawingRole === "information");
|
||||
delete formData.drawingRole;
|
||||
|
||||
// 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);
|
||||
formData.shape.width = shape.width;
|
||||
formData.shape.height = shape.height;
|
||||
foundry.utils.mergeObject(formData, Drawing.rescaleDimensions(formData, 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();
|
||||
}
|
||||
}
|
||||
42
resources/app/client/apps/placeables/drawing-hud.js
Normal file
42
resources/app/client/apps/placeables/drawing-hud.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Drawing objects.
|
||||
* The DrawingHUD implementation can be configured and replaced via {@link CONFIG.Drawing.hudClass}.
|
||||
* @extends {BasePlaceableHUD<Drawing, DrawingDocument, DrawingsLayer>}
|
||||
*/
|
||||
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 {locked, hidden} = this.object.document;
|
||||
return foundry.utils.mergeObject(super.getData(options), {
|
||||
lockedClass: locked ? "active" : "",
|
||||
visibilityClass: hidden ? "active" : ""
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
setPosition(options) {
|
||||
let {x, y, width, height} = this.object.frame.bounds;
|
||||
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);
|
||||
}
|
||||
}
|
||||
120
resources/app/client/apps/placeables/note-config.js
Normal file
120
resources/app/client/apps/placeables/note-config.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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, game.i18n.lang));
|
||||
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, game.i18n.lang)),
|
||||
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;
|
||||
}, {}),
|
||||
gridUnits: this.document.parent.grid.units || game.i18n.localize("GridUnits"),
|
||||
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["icon.selected"];
|
||||
this.form["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);
|
||||
}
|
||||
}
|
||||
91
resources/app/client/apps/placeables/tile-config.js
Normal file
91
resources/app/client/apps/placeables/tile-config.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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;
|
||||
}, {});
|
||||
data.gridUnits = this.document.parent.grid.units || game.i18n.localize("GridUnits");
|
||||
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"] = "#ffffff";
|
||||
fdo["texture.tint"] = Color.from(fdo["texture.tint"]);
|
||||
|
||||
// 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
|
||||
});
|
||||
}
|
||||
}
|
||||
93
resources/app/client/apps/placeables/tile-hud.js
Normal file
93
resources/app/client/apps/placeables/tile-hud.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Tile objects.
|
||||
* The TileHUD implementation can be configured and replaced via {@link CONFIG.Tile.hudClass}.
|
||||
* @extends {BasePlaceableHUD<Tile, TileDocument, TilesLayer>}
|
||||
*/
|
||||
class TileHUD extends BasePlaceableHUD {
|
||||
|
||||
/** @inheritDoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "tile-hud",
|
||||
template: "templates/hud/tile-hud.html"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
getData(options={}) {
|
||||
const {locked, hidden} = this.document;
|
||||
const {isVideo, sourceElement} = this.object;
|
||||
const isPlaying = isVideo && !sourceElement.paused && !sourceElement.ended;
|
||||
return foundry.utils.mergeObject(super.getData(options), {
|
||||
isVideo: isVideo,
|
||||
lockedClass: locked ? "active" : "",
|
||||
visibilityClass: hidden ? "active" : "",
|
||||
videoIcon: isPlaying ? "fas fa-pause" : "fas fa-play",
|
||||
videoTitle: game.i18n.localize(isPlaying ? "HUD.TilePause" : "HUD.TilePlay")
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
setPosition(options) {
|
||||
let {x, y, width, height} = this.object.frame.bounds;
|
||||
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);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onClickControl(event) {
|
||||
super._onClickControl(event);
|
||||
if ( event.defaultPrevented ) return;
|
||||
const button = event.currentTarget;
|
||||
switch ( button.dataset.action ) {
|
||||
case "video":
|
||||
return this.#onControlVideo(event);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Control video playback by toggling play or paused state for a video Tile.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
#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;
|
||||
};
|
||||
}
|
||||
|
||||
// Update the video playing state
|
||||
return this.object.document.update({"video.autoplay": false}, {
|
||||
diff: false,
|
||||
playVideo: !isPlaying,
|
||||
offset: src.ended ? 0 : null
|
||||
});
|
||||
}
|
||||
}
|
||||
660
resources/app/client/apps/placeables/token-config.js
Normal file
660
resources/app/client/apps/placeables/token-config.js
Normal file
@@ -0,0 +1,660 @@
|
||||
/**
|
||||
* 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({}, {keepId: true});
|
||||
this.preview = clone.document;
|
||||
clone.control({releaseOthers: true});
|
||||
}
|
||||
await this.preview.object.draw();
|
||||
this.document.object.renderable = false;
|
||||
this.document.object.initializeSources({deleted: true});
|
||||
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 usesTrackableAttributes = !foundry.utils.isEmpty(CONFIG.Actor.trackableAttributes);
|
||||
const attributeSource = (this.actor?.system instanceof foundry.abstract.DataModel) && usesTrackableAttributes
|
||||
? this.actor?.type
|
||||
: this.actor?.system;
|
||||
const attributes = TokenDocument.implementation.getTrackedAttributes(attributeSource);
|
||||
const canBrowseFiles = game.user.hasPermission("FILES_BROWSE");
|
||||
|
||||
// Prepare Token data
|
||||
const doc = this.preview ?? this.document;
|
||||
const source = doc.toObject();
|
||||
const sourceDetectionModes = new Set(source.detectionModes.map(m => m.id));
|
||||
const preparedDetectionModes = doc.detectionModes.filter(m => !sourceDetectionModes.has(m.id));
|
||||
|
||||
// Return rendering context
|
||||
return {
|
||||
fields: this.document.schema.fields, // Important to use the true document schema,
|
||||
lightFields: this.document.schema.fields.light.fields,
|
||||
cssClasses: [this.isPrototype ? "prototype" : null].filter(c => !!c).join(" "),
|
||||
isPrototype: this.isPrototype,
|
||||
hasAlternates: !foundry.utils.isEmpty(alternateImages),
|
||||
alternateImages: alternateImages,
|
||||
object: source,
|
||||
options: this.options,
|
||||
gridUnits: (this.isPrototype ? "" : this.document.parent?.grid.units) || 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),
|
||||
preparedDetectionModes,
|
||||
displayModes: Object.entries(CONST.TOKEN_DISPLAY_MODES).reduce((obj, e) => {
|
||||
obj[e[1]] = game.i18n.localize(`TOKEN.DISPLAY_${e[0]}`);
|
||||
return obj;
|
||||
}, {}),
|
||||
hexagonalShapes: Object.entries(CONST.TOKEN_HEXAGONAL_SHAPES).reduce((obj, [k, v]) => {
|
||||
obj[v] = game.i18n.localize(`TOKEN.HEXAGONAL_SHAPE_${k}`);
|
||||
return obj;
|
||||
}, {}),
|
||||
showHexagonalShapes: this.isPrototype || !doc.parent || doc.parent.grid.isHexagonal,
|
||||
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, game.i18n.lang)),
|
||||
dispositions: Object.entries(CONST.TOKEN_DISPOSITIONS).reduce((obj, e) => {
|
||||
obj[e[1]] = game.i18n.localize(`TOKEN.DISPOSITION.${e[0]}`);
|
||||
return obj;
|
||||
}, {}),
|
||||
lightAnimations: CONFIG.Canvas.lightAnimations,
|
||||
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,
|
||||
textureFitModes: CONST.TEXTURE_DATA_FIT_MODES.reduce((obj, fit) => {
|
||||
obj[fit] = game.i18n.localize(`TEXTURE_DATA.FIT.${fit}`);
|
||||
return obj;
|
||||
}, {}),
|
||||
ringEffectsInput: this.#ringEffectsInput.bind(this)
|
||||
};
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _renderInner(...args) {
|
||||
await loadTemplates([
|
||||
"templates/scene/parts/token-lighting.hbs",
|
||||
"templates/scene/parts/token-vision.html",
|
||||
"templates/scene/parts/token-resources.html"
|
||||
]);
|
||||
return super._renderInner(...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render the Token ring effects input using a multi-checkbox element.
|
||||
* @param {NumberField} field The ring effects field
|
||||
* @param {FormInputConfig} inputConfig Form input configuration
|
||||
* @returns {HTMLMultiCheckboxElement}
|
||||
*/
|
||||
#ringEffectsInput(field, inputConfig) {
|
||||
const options = [];
|
||||
const value = [];
|
||||
for ( const [effectName, effectValue] of Object.entries(CONFIG.Token.ring.ringClass.effects) ) {
|
||||
const localization = CONFIG.Token.ring.effects[effectName];
|
||||
if ( (effectName === "DISABLED") || (effectName === "ENABLED") || !localization ) continue;
|
||||
options.push({value: effectName, label: game.i18n.localize(localization)});
|
||||
if ( (inputConfig.value & effectValue) !== 0 ) value.push(effectName);
|
||||
}
|
||||
Object.assign(inputConfig, {name: field.fieldPath, options, value, type: "checkboxes"});
|
||||
return foundry.applications.fields.createMultiSelectInput(inputConfig);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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 = foundry.utils.expandObject(super._getSubmitData(updateData));
|
||||
|
||||
// Prototype Token unpacking
|
||||
if ( this.document instanceof foundry.data.PrototypeToken ) {
|
||||
Object.assign(formData, formData.prototypeToken);
|
||||
delete formData.prototypeToken;
|
||||
}
|
||||
|
||||
// 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]);
|
||||
|
||||
// Token Ring Effects
|
||||
if ( Array.isArray(formData.ring?.effects) ) {
|
||||
const TRE = CONFIG.Token.ring.ringClass.effects;
|
||||
let effects = formData.ring.enabled ? TRE.ENABLED : TRE.DISABLED;
|
||||
for ( const effectName of formData.ring.effects ) {
|
||||
const v = TRE[effectName] ?? 0;
|
||||
effects |= v;
|
||||
}
|
||||
formData.ring.effects = effects;
|
||||
}
|
||||
|
||||
// Clear detection modes array
|
||||
formData.detectionModes ??= [];
|
||||
|
||||
// Treat "None" as null for bar attributes
|
||||
formData.bar1.attribute ||= null;
|
||||
formData.bar2.attribute ||= null;
|
||||
return foundry.utils.flattenObject(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 ) {
|
||||
const value = visionDefaults[fieldName];
|
||||
if ( value === undefined ) return;
|
||||
if ( field.type === "checkbox" ) {
|
||||
field.checked = value;
|
||||
} else if ( field.type === "range" ) {
|
||||
field.value = value;
|
||||
const rangeValue = field.parentNode.querySelector(".range-value");
|
||||
if ( rangeValue ) rangeValue.innerText = value;
|
||||
} else if ( field.classList.contains("color") ) {
|
||||
field.value = value;
|
||||
const colorInput = field.parentNode.querySelector('input[type="color"]');
|
||||
if ( colorInput ) colorInput.value = value;
|
||||
} else {
|
||||
field.value = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
for ( const fieldName of ["color", "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.initializeSources();
|
||||
this.preview.object.renderFlags.set({refresh: 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.initializeSources();
|
||||
this.document.object.control();
|
||||
this.document.object.renderFlags.set({refresh: 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}, {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);
|
||||
await game.settings.set("core", DefaultTokenConfig.SETTING, delta);
|
||||
return SettingsConfig.reloadConfirm({ world: true });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @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({}, {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 });
|
||||
}
|
||||
}
|
||||
258
resources/app/client/apps/placeables/token-hud.js
Normal file
258
resources/app/client/apps/placeables/token-hud.js
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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.
|
||||
* The TokenHUD implementation can be configured and replaced via {@link CONFIG.Token.hudClass}.
|
||||
* @extends {BasePlaceableHUD<Token, TokenDocument, TokenLayer>}
|
||||
*/
|
||||
class TokenHUD extends BasePlaceableHUD {
|
||||
|
||||
/** @inheritDoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "token-hud",
|
||||
template: "templates/hud/token-hud.html"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Track whether the status effects control palette is currently expanded or hidden
|
||||
* @type {boolean}
|
||||
*/
|
||||
#statusTrayActive = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convenience reference to the Actor modified by this TokenHUD.
|
||||
* @type {Actor}
|
||||
*/
|
||||
get actor() {
|
||||
return this.document?.actor;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
bind(object) {
|
||||
this.#statusTrayActive = false;
|
||||
return super.bind(object);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
setPosition(_position) {
|
||||
const b = this.object.bounds;
|
||||
const {width, height} = this.document;
|
||||
const ratio = canvas.dimensions.size / 100;
|
||||
const position = {width: width * 100, height: height * 100, left: b.left, top: b.top};
|
||||
if ( ratio !== 1 ) position.transform = `scale(${ratio})`;
|
||||
this.element.css(position);
|
||||
this.element[0].classList.toggle("large", height >= 2);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
getData(options={}) {
|
||||
let data = super.getData(options);
|
||||
const bar1 = this.document.getBarAttribute("bar1");
|
||||
const bar2 = this.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.#statusTrayActive ? "active" : "",
|
||||
combatClass: this.object.inCombat ? "active" : "",
|
||||
targetClass: this.object.targeted.has(game.user) ? "active" : ""
|
||||
});
|
||||
data.statusEffects = this._getStatusEffectChoices();
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an array of icon paths which represent valid status effect choices.
|
||||
* @protected
|
||||
*/
|
||||
_getStatusEffectChoices() {
|
||||
|
||||
// Include all HUD-enabled status effects
|
||||
const choices = {};
|
||||
for ( const status of CONFIG.statusEffects ) {
|
||||
if ( (status.hud === false) || ((foundry.utils.getType(status.hud) === "Object")
|
||||
&& (status.hud.actorTypes?.includes(this.document.actor.type) === false)) ) {
|
||||
continue;
|
||||
}
|
||||
choices[status.id] = {
|
||||
_id: status._id,
|
||||
id: status.id,
|
||||
title: game.i18n.localize(status.name ?? /** @deprecated since v12 */ status.label),
|
||||
src: status.img ?? /** @deprecated since v12 */ status.icon,
|
||||
isActive: false,
|
||||
isOverlay: false
|
||||
};
|
||||
}
|
||||
|
||||
// Update the status of effects which are active for the token actor
|
||||
const activeEffects = this.actor?.effects || [];
|
||||
for ( const effect of activeEffects ) {
|
||||
for ( const statusId of effect.statuses ) {
|
||||
const status = choices[statusId];
|
||||
if ( !status ) continue;
|
||||
if ( status._id ) {
|
||||
if ( status._id !== effect.id ) continue;
|
||||
} else {
|
||||
if ( effect.statuses.size !== 1 ) continue;
|
||||
}
|
||||
status.isActive = true;
|
||||
if ( effect.getFlag("core", "overlay") ) status.isOverlay = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Flag status CSS class
|
||||
for ( const status of Object.values(choices) ) {
|
||||
status.cssClass = [
|
||||
status.isActive ? "active" : null,
|
||||
status.isOverlay ? "overlay" : null
|
||||
].filterJoin(" ");
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle the expanded state of the status effects selection tray.
|
||||
* @param {boolean} [active] Force the status tray to be active or inactive
|
||||
*/
|
||||
toggleStatusTray(active) {
|
||||
active ??= !this.#statusTrayActive;
|
||||
this.#statusTrayActive = active;
|
||||
const button = this.element.find('.control-icon[data-action="effects"]')[0];
|
||||
button.classList.toggle("active", active);
|
||||
const palette = this.element[0].querySelector(".status-effects");
|
||||
palette.classList.toggle("active", active);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
this.toggleStatusTray(this.#statusTrayActive);
|
||||
const effectsTray = html.find(".status-effects");
|
||||
effectsTray.on("click", ".effect-control", this.#onToggleEffect.bind(this));
|
||||
effectsTray.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);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _updateAttribute(name, input) {
|
||||
const attr = this.document.getBarAttribute(name);
|
||||
if ( !attr ) return super._updateAttribute(name, input);
|
||||
const {value, delta, isDelta, isBar} = this._parseAttributeInput(name, attr, input);
|
||||
await this.actor?.modifyTokenAttribute(attr.attribute, isDelta ? delta : value, isDelta, isBar);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle the combat state of all controlled Tokens.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
async #onToggleCombat(event) {
|
||||
event.preventDefault();
|
||||
const tokens = canvas.tokens.controlled.map(t => t.document);
|
||||
if ( !this.object.controlled ) tokens.push(this.document);
|
||||
try {
|
||||
if ( this.document.inCombat ) await TokenDocument.implementation.deleteCombatants(tokens);
|
||||
else await TokenDocument.implementation.createCombatants(tokens);
|
||||
} catch(err) {
|
||||
ui.notifications.warn(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle Token configuration button click.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
#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 {PointerEvent} event
|
||||
*/
|
||||
#onToggleStatusEffects(event) {
|
||||
event.preventDefault();
|
||||
this.toggleStatusTray(!this.#statusTrayActive);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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?
|
||||
*/
|
||||
#onToggleEffect(event, {overlay=false}={}) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if ( !this.actor ) return ui.notifications.warn("HUD.WarningEffectNoActor", {localize: true});
|
||||
const statusId = event.currentTarget.dataset.statusId;
|
||||
this.actor.toggleStatusEffect(statusId, {overlay});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling the target state for this Token
|
||||
* @param {PointerEvent} event The click event to toggle the target
|
||||
*/
|
||||
#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);
|
||||
}
|
||||
}
|
||||
189
resources/app/client/apps/placeables/wall-config.js
Normal file
189
resources/app/client/apps/placeables/wall-config.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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 = this.document.parent.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)];
|
||||
game.audio.play(src, {context: game.audio.interface});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @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});
|
||||
}
|
||||
}
|
||||
55
resources/app/client/apps/sidebar/apps/chat-popout.js
Normal file
55
resources/app/client/apps/sidebar/apps/chat-popout.js
Normal 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"]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get id() {
|
||||
return `chat-popout-${this.message.id}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get title() {
|
||||
let title = this.message.flavor ?? this.message.speaker.alias;
|
||||
return TextEditor.previewHTML(title, 32);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _renderInner(_data) {
|
||||
const html = await this.message.getHTML();
|
||||
html.find(".message-delete").remove();
|
||||
return html;
|
||||
}
|
||||
}
|
||||
189
resources/app/client/apps/sidebar/apps/client-settings.js
Normal file
189
resources/app/client/apps/sidebar/apps/client-settings.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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;
|
||||
if ( (menu.key === "core.permissions") && !game.user.hasRole("GAMEMASTER") ) 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;
|
||||
s.dataField = setting.type instanceof foundry.data.fields.DataField ? setting.type : null;
|
||||
s.input = setting.input;
|
||||
|
||||
// Categorize setting
|
||||
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 foundry.applications.api.DialogV2.confirm({
|
||||
id: "reload-world-confirm",
|
||||
modal: true,
|
||||
rejectClose: false,
|
||||
window: { title: "SETTINGS.ReloadPromptTitle" },
|
||||
position: { width: 400 },
|
||||
content: `<p>${game.i18n.localize("SETTINGS.ReloadPromptBody")}</p>`
|
||||
});
|
||||
if ( !reload ) return;
|
||||
if ( world && game.user.can("SETTINGS_MODIFY") ) game.socket.emit("reload");
|
||||
foundry.utils.debouncedReload();
|
||||
}
|
||||
}
|
||||
236
resources/app/client/apps/sidebar/apps/compendium.js
Normal file
236
resources/app/client/apps/sidebar/apps/compendium.js
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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() {
|
||||
const title = game.i18n.localize(this.collection.title);
|
||||
return this.collection.locked ? `${title} [${game.i18n.localize("PACKAGE.Locked")}]` : title;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @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 CONFIG.Adventure.exporterClass(adventure).render(true);
|
||||
}
|
||||
return super._onCreateEntry(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @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 CONFIG.Adventure.exporterClass(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 document.deleteDialog();
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
414
resources/app/client/apps/sidebar/apps/dependency-resolution.js
Normal file
414
resources/app/client/apps/sidebar/apps/dependency-resolution.js
Normal file
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* A class responsible for prompting the user about dependency resolution for their modules.
|
||||
*/
|
||||
class DependencyResolution extends FormApplication {
|
||||
/**
|
||||
* @typedef {object} DependencyResolutionInfo
|
||||
* @property {Module} module The module.
|
||||
* @property {boolean} checked Has the user toggled the checked state of this dependency in this application.
|
||||
* @property {string} [reason] Some reason associated with the dependency.
|
||||
* @property {boolean} [required] Whether this module is a hard requirement and cannot be unchecked.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {FormApplicationOptions} DependencyResolutionAppOptions
|
||||
* @property {boolean} enabling Whether the root dependency is being enabled or disabled.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {ModuleManagement} manager The module management application.
|
||||
* @param {Module} root The module that is the root of the dependency resolution.
|
||||
* @param {DependencyResolutionAppOptions} [options] Additional options that configure resolution behavior.
|
||||
*/
|
||||
constructor(manager, root, options={}) {
|
||||
super(root, options);
|
||||
this.#manager = manager;
|
||||
|
||||
// Always include the root module.
|
||||
this.#modules.set(root.id, root);
|
||||
|
||||
// Determine initial state.
|
||||
if ( options.enabling ) this.#initializeEnabling();
|
||||
else this.#initializeDisabling();
|
||||
}
|
||||
|
||||
/**
|
||||
* The full set of modules considered for dependency resolution stemming from the root module.
|
||||
* @type {Set<Module>}
|
||||
*/
|
||||
#candidates = new Set();
|
||||
|
||||
/**
|
||||
* The set of all modules dependent on a given module.
|
||||
* @type {Map<Module, Set<Module>>}
|
||||
*/
|
||||
#dependents = new Map();
|
||||
|
||||
/**
|
||||
* The module management application.
|
||||
* @type {ModuleManagement}
|
||||
*/
|
||||
#manager;
|
||||
|
||||
/**
|
||||
* A subset of the games modules that are currently active in the module manager.
|
||||
* @type {Map<string, Module>}
|
||||
*/
|
||||
#modules = new Map();
|
||||
|
||||
/**
|
||||
* Track the changes being made by the user as part of dependency resolution.
|
||||
* @type {Map<Module, DependencyResolutionInfo>}
|
||||
*/
|
||||
#resolution = new Map();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether there are additional dependencies that need resolving by the user.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get needsResolving() {
|
||||
if ( this.options.enabling ) return this.#candidates.size > 0;
|
||||
return (this.#candidates.size > 1) || !!this.#getUnavailableSubtypes();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {DependencyResolutionAppOptions}
|
||||
*/
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
enabling: true,
|
||||
template: "templates/setup/impacted-dependencies.html"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options={}) {
|
||||
const required = [];
|
||||
const optional = [];
|
||||
let subtypes;
|
||||
|
||||
if ( this.options.enabling ) {
|
||||
const context = this.#getDependencyContext();
|
||||
required.push(...context.required);
|
||||
optional.push(...context.optional);
|
||||
} else {
|
||||
optional.push(...this.#getUnusedContext());
|
||||
subtypes = this.#getUnavailableSubtypes();
|
||||
}
|
||||
|
||||
return {
|
||||
required, optional, subtypes,
|
||||
enabling: this.options.enabling
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find('input[type="checkbox"]').on("change", this._onChangeCheckbox.bind(this));
|
||||
html.find("[data-action]").on("click", this._onAction.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, options) {
|
||||
await super._render(force, options);
|
||||
this.setPosition({ height: "auto" });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the user toggling a dependency.
|
||||
* @param {Event} event The checkbox change event.
|
||||
* @protected
|
||||
*/
|
||||
_onChangeCheckbox(event) {
|
||||
const target = event.currentTarget;
|
||||
const module = this.#modules.get(target.name);
|
||||
const checked = target.checked;
|
||||
const resolution = this.#resolution.get(module);
|
||||
resolution.checked = checked;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button presses.
|
||||
* @param {PointerEvent} event The triggering event.
|
||||
* @protected
|
||||
*/
|
||||
_onAction(event) {
|
||||
const action = event.currentTarget.dataset.action;
|
||||
switch ( action ) {
|
||||
case "cancel":
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getSubmitData(updateData={}) {
|
||||
const fd = new FormDataExtended(this.form, { disabled: true });
|
||||
return foundry.utils.mergeObject(fd.object, updateData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
formData[this.object.id] = true;
|
||||
this.#manager._onSelectDependencies(formData, this.options.enabling);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return any modules that the root module is required by.
|
||||
* @returns {Set<Module>}
|
||||
* @internal
|
||||
*/
|
||||
_getRootRequiredBy() {
|
||||
const requiredBy = new Set();
|
||||
if ( this.options.enabling ) return requiredBy;
|
||||
const dependents = this.#dependents.get(this.object);
|
||||
for ( const dependent of (dependents ?? []) ) {
|
||||
if ( dependent.relationships.requires.find(({ id }) => id === this.object.id) ) {
|
||||
requiredBy.add(dependent);
|
||||
}
|
||||
}
|
||||
return requiredBy;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build the structure of modules that are dependent on other modules.
|
||||
*/
|
||||
#buildDependents() {
|
||||
const addDependent = (module, dep) => {
|
||||
dep = this.#modules.get(dep.id);
|
||||
if ( !dep ) return;
|
||||
if ( !this.#dependents.has(dep) ) this.#dependents.set(dep, new Set());
|
||||
const dependents = this.#dependents.get(dep);
|
||||
dependents.add(module);
|
||||
};
|
||||
|
||||
for ( const module of this.#modules.values() ) {
|
||||
for ( const dep of module.relationships.requires ) addDependent(module, dep);
|
||||
for ( const dep of module.relationships.recommends ) addDependent(module, dep);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Recurse down the dependency tree and gather modules that are required or optional.
|
||||
* @param {Set<Module>} [skip] If any of these modules are encountered in the graph, skip them.
|
||||
* @returns {Map<Module, DependencyResolutionInfo>}
|
||||
*/
|
||||
#getDependencies(skip=new Set()) {
|
||||
const resolution = new Map();
|
||||
|
||||
const addDependency = (module, { required=false, reason, dependent }={}) => {
|
||||
if ( !resolution.has(module) ) resolution.set(module, { module, checked: true });
|
||||
const info = resolution.get(module);
|
||||
if ( !info.required ) info.required = required;
|
||||
if ( reason ) {
|
||||
if ( info.reason ) info.reason += "<br>";
|
||||
info.reason += `${dependent.title}: ${reason}`;
|
||||
}
|
||||
};
|
||||
|
||||
const addDependencies = (module, deps, required=false) => {
|
||||
for ( const { id, reason } of deps ) {
|
||||
const dep = this.#modules.get(id);
|
||||
if ( !dep ) continue;
|
||||
const info = resolution.get(dep);
|
||||
|
||||
// Avoid cycles in the dependency graph.
|
||||
if ( info && (info.required === true || info.required === required) ) continue;
|
||||
|
||||
// Add every dependency we see so tha user can toggle them on and off, but do not traverse the graph any further
|
||||
// if we have indicated this dependency should be skipped.
|
||||
addDependency(dep, { reason, required, dependent: module });
|
||||
if ( skip.has(dep) ) continue;
|
||||
|
||||
addDependencies(dep, dep.relationships.requires, true);
|
||||
addDependencies(dep, dep.relationships.recommends);
|
||||
}
|
||||
};
|
||||
|
||||
addDependencies(this.object, this.object.relationships.requires, true);
|
||||
addDependencies(this.object, this.object.relationships.recommends);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the set of all modules that would be unused (i.e. have no dependents) if the given set of modules were
|
||||
* disabled.
|
||||
* @param {Set<Module>} disabling The set of modules that are candidates for disablement.
|
||||
* @returns {Set<Module>}
|
||||
*/
|
||||
#getUnused(disabling) {
|
||||
const unused = new Set();
|
||||
for ( const module of this.#modules.values() ) {
|
||||
const dependents = this.#dependents.get(module);
|
||||
if ( !dependents ) continue;
|
||||
|
||||
// What dependents are left if we remove the set of to-be-disabled modules?
|
||||
const remaining = dependents.difference(disabling);
|
||||
if ( !remaining.size ) unused.add(module);
|
||||
}
|
||||
return unused;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Find the maximum dependents that can be pruned if the root module is disabled.
|
||||
* Starting at the root module, add all modules that would become unused to the set of modules to disable. For each
|
||||
* module added in this way, check again for new modules that would become unused. Repeat until there are no more
|
||||
* unused modules.
|
||||
*/
|
||||
#initializeDisabling() {
|
||||
const disabling = new Set([this.object]);
|
||||
|
||||
// Initialize modules.
|
||||
for ( const module of game.modules ) {
|
||||
if ( this.#manager._isModuleChecked(module.id) ) this.#modules.set(module.id, module);
|
||||
}
|
||||
|
||||
// Initialize dependents.
|
||||
this.#buildDependents();
|
||||
|
||||
// Set a maximum iteration limit of 100 to prevent accidental infinite recursion.
|
||||
for ( let i = 0; i < 100; i++ ) {
|
||||
const unused = this.#getUnused(disabling);
|
||||
if ( !unused.size ) break;
|
||||
unused.forEach(disabling.add, disabling);
|
||||
}
|
||||
|
||||
this.#candidates = disabling;
|
||||
|
||||
// Initialize resolution state.
|
||||
for ( const module of disabling ) {
|
||||
this.#resolution.set(module, { module, checked: true, required: false });
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Find the full list of recursive dependencies for the root module.
|
||||
*/
|
||||
#initializeEnabling() {
|
||||
// Initialize modules.
|
||||
for ( const module of game.modules ) {
|
||||
if ( !this.#manager._isModuleChecked(module.id) ) this.#modules.set(module.id, module);
|
||||
}
|
||||
|
||||
// Traverse the dependency graph and locate dependencies that need activation.
|
||||
this.#resolution = this.#getDependencies();
|
||||
for ( const module of this.#resolution.keys() ) this.#candidates.add(module);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The list of modules that the user currently has selected, including the root module.
|
||||
* @returns {Set<Module>}
|
||||
*/
|
||||
#getSelectedModules() {
|
||||
const selected = new Set([this.object]);
|
||||
for ( const module of this.#candidates ) {
|
||||
const { checked } = this.#resolution.get(module);
|
||||
if ( checked ) selected.add(module);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* After the user has adjusted their choices, re-calculate the dependency graph.
|
||||
* Display all modules which are still in the set of reachable dependencies, preserving their checked states. If a
|
||||
* module is no longer reachable in the dependency graph (because there are no more checked modules that list it as
|
||||
* a dependency), do not display it to the user.
|
||||
* @returns {{required: DependencyResolutionInfo[], optional: DependencyResolutionInfo[]}}
|
||||
*/
|
||||
#getDependencyContext() {
|
||||
const skip = Array.from(this.#resolution.values()).reduce((acc, info) => {
|
||||
if ( info.checked === false ) acc.add(info.module);
|
||||
return acc;
|
||||
}, new Set());
|
||||
|
||||
const dependencies = this.#getDependencies(skip);
|
||||
const required = [];
|
||||
const optional = [];
|
||||
|
||||
for ( const module of this.#candidates ) {
|
||||
if ( !dependencies.has(module) ) continue;
|
||||
const info = this.#resolution.get(module);
|
||||
if ( info.required ) required.push(info);
|
||||
else optional.push(info);
|
||||
}
|
||||
|
||||
return { required, optional };
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* After the user has adjusted their choices, re-calculate which modules are still unused.
|
||||
* Display all modules which are still unused, preserving their checked states. If a module is no longer unused
|
||||
* (because a module that uses it was recently unchecked), do not display it to the user.
|
||||
* @returns {DependencyResolutionInfo[]}
|
||||
*/
|
||||
#getUnusedContext() {
|
||||
// Re-calculate unused modules after we remove those the user unchecked.
|
||||
const unused = this.#getUnused(this.#getSelectedModules());
|
||||
const context = [];
|
||||
for ( const module of this.#candidates ) {
|
||||
if ( unused.has(module) ) context.push(this.#resolution.get(module));
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a formatted string of the Documents that would be rendered unavailable if the currently-selected modules were
|
||||
* to be disabled.
|
||||
* @returns {string}
|
||||
*/
|
||||
#getUnavailableSubtypes() {
|
||||
const allCounts = {};
|
||||
for ( const module of this.#getSelectedModules() ) {
|
||||
const counts = game.issues.getSubTypeCountsFor(module);
|
||||
if ( !counts ) continue;
|
||||
Object.entries(counts).forEach(([documentName, subtypes]) => {
|
||||
const documentCounts = allCounts[documentName] ??= {};
|
||||
Object.entries(subtypes).forEach(([subtype, count]) => {
|
||||
documentCounts[subtype] = (documentCounts[subtype] ?? 0) + count;
|
||||
});
|
||||
});
|
||||
}
|
||||
return this.#manager._formatDocumentSummary(allCounts, true);
|
||||
}
|
||||
}
|
||||
79
resources/app/client/apps/sidebar/apps/invitation-links.js
Normal file
79
resources/app/client/apps/sidebar/apps/invitation-links.js
Normal 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";
|
||||
});
|
||||
}
|
||||
}
|
||||
543
resources/app/client/apps/sidebar/apps/keybindings-config.js
Normal file
543
resources/app/client/apps/sidebar/apps/keybindings-config.js
Normal 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);
|
||||
}
|
||||
}
|
||||
411
resources/app/client/apps/sidebar/apps/module-management.js
Normal file
411
resources/app/client/apps/sidebar/apps/module-management.js
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* 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, game.i18n.lang));
|
||||
|
||||
// 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);
|
||||
const listFormatter = game.i18n.getListFormatter();
|
||||
|
||||
// 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?.size ) 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: listFormatter.format(missing)});
|
||||
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);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the checked state of modules based on user dependency resolution.
|
||||
* @param {Record<string, boolean>} formData The dependency resolution result.
|
||||
* @param {boolean} enabling Whether the user was performing an enabling or disabling workflow.
|
||||
* @internal
|
||||
*/
|
||||
_onSelectDependencies(formData, enabling) {
|
||||
for ( const [id, checked] of Object.entries(formData) ) {
|
||||
this.form.elements[id].checked = enabling ? checked : !checked;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const resolver = new DependencyResolution(this, module, { enabling });
|
||||
const requiredBy = resolver._getRootRequiredBy();
|
||||
|
||||
if ( requiredBy.size || resolver.needsResolving ) {
|
||||
this.form.elements[input.name].checked = !enabling;
|
||||
if ( requiredBy.size ) {
|
||||
// TODO: Rather than throwing an error, we should prompt the user to disable all dependent modules, as well as
|
||||
// all their dependents, recursively, and all unused modules that would result from those disablings.
|
||||
const listFormatter = game.i18n.getListFormatter();
|
||||
const dependents = listFormatter.format(Array.from(requiredBy).map(m => m.title));
|
||||
ui.notifications.error(game.i18n.format("MODMANAGE.RequiredDepError", { dependents }), { console: false });
|
||||
}
|
||||
else resolver.render(true);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @internal
|
||||
*/
|
||||
_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(" • ");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check if a module is enabled currently in the application.
|
||||
* @param {string} id The module ID.
|
||||
* @returns {boolean}
|
||||
* @internal
|
||||
*/
|
||||
_isModuleChecked(id) {
|
||||
return !!this.form.elements[id]?.checked;
|
||||
}
|
||||
}
|
||||
365
resources/app/client/apps/sidebar/apps/support-details.js
Normal file
365
resources/app/client/apps/sidebar/apps/support-details.js
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 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 */
|
||||
async getData(options={}) {
|
||||
const context = super.getData(options);
|
||||
|
||||
// Build report data
|
||||
context.report = await 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;
|
||||
|
||||
case "fullReport":
|
||||
this.#generateFullReport();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generate a more detailed support report and append it to the basic report.
|
||||
*/
|
||||
async #generateFullReport() {
|
||||
let fullReport = "";
|
||||
const report = document.getElementById("support-report");
|
||||
const [button] = this.element.find('[data-action="fullReport"]');
|
||||
const icon = button.querySelector("i");
|
||||
button.disabled = true;
|
||||
icon.className = "fas fa-spinner fa-spin-pulse";
|
||||
|
||||
const sizeInfo = await this.#getWorldSizeInfo();
|
||||
const { worldSizes, packSizes } = Object.entries(sizeInfo).reduce((obj, entry) => {
|
||||
const [collectionName] = entry;
|
||||
if ( collectionName.includes(".") ) obj.packSizes.push(entry);
|
||||
else obj.worldSizes.push(entry);
|
||||
return obj;
|
||||
}, { worldSizes: [], packSizes: [] });
|
||||
|
||||
fullReport += `\n${this.#drawBox(game.i18n.localize("SUPPORT.WorldData"))}\n\n`;
|
||||
fullReport += worldSizes.map(([collectionName, size]) => {
|
||||
let collection = game[collectionName];
|
||||
if ( collectionName === "fog" ) collection = game.collections.get("FogExploration");
|
||||
else if ( collectionName === "settings" ) collection = game.collections.get("Setting");
|
||||
return `${collection.name}: ${collection.size} | ${foundry.utils.formatFileSize(size, { decimalPlaces: 0 })}`;
|
||||
}).join("\n");
|
||||
|
||||
if ( packSizes.length ) {
|
||||
fullReport += `\n\n${this.#drawBox(game.i18n.localize("SUPPORT.CompendiumData"))}\n\n`;
|
||||
fullReport += packSizes.map(([collectionName, size]) => {
|
||||
const pack = game.packs.get(collectionName);
|
||||
const type = game.i18n.localize(pack.documentClass.metadata.labelPlural);
|
||||
size = foundry.utils.formatFileSize(size, { decimalPlaces: 0 });
|
||||
return `"${collectionName}": ${pack.index.size} ${type} | ${size}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
const activeModules = game.modules.filter(m => m.active);
|
||||
if ( activeModules.length ) {
|
||||
fullReport += `\n\n${this.#drawBox(game.i18n.localize("SUPPORT.ActiveModules"))}\n\n`;
|
||||
fullReport += activeModules.map(m => `${m.id} | ${m.version} | "${m.title}" | "${m.manifest}"`).join("\n");
|
||||
}
|
||||
|
||||
icon.className = "fas fa-check";
|
||||
report.innerText += fullReport;
|
||||
this.setPosition({ height: "auto" });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve information about the size of the World and any active compendiums.
|
||||
* @returns {Promise<Record<string, number>>}
|
||||
*/
|
||||
async #getWorldSizeInfo() {
|
||||
return new Promise(resolve => game.socket.emit("sizeInfo", resolve));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw an ASCII box around the given string for legibility in the full report.
|
||||
* @param {string} text The text.
|
||||
* @returns {string}
|
||||
*/
|
||||
#drawBox(text) {
|
||||
const border = `/* ${"-".repeat(44)} */`;
|
||||
return `${border}\n/* ${text}${" ".repeat(border.length - text.length - 6)}*/\n${border}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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 {string} 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 {number} 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
|
||||
* @property {number} performanceMode
|
||||
* @property {boolean} hasViewedScene
|
||||
* @property {string[]} worldScripts
|
||||
* @property {{width: number, height: number, [src]: string}} largestTexture
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collects a number of metrics that is useful for Support
|
||||
* @returns {Promise<SupportReportData>}
|
||||
*/
|
||||
static async 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";
|
||||
|
||||
let os = navigator.oscpu ?? "Unknown";
|
||||
let client = navigator.userAgent;
|
||||
|
||||
// Attempt to retrieve high-entropy Sec-CH headers.
|
||||
if ( navigator.userAgentData ) {
|
||||
const secCH = await navigator.userAgentData.getHighEntropyValues([
|
||||
"architecture", "model", "bitness", "platformVersion", "fullVersionList"
|
||||
]);
|
||||
|
||||
const { architecture, bitness, brands, platform, platformVersion, fullVersionList } = secCH;
|
||||
os = [platform, platformVersion, architecture, bitness ? `(${bitness}-bit)` : null].filterJoin(" ");
|
||||
const { brand, version } = fullVersionList?.[0] ?? brands?.[0] ?? {};
|
||||
client = `${brand}/${version}`;
|
||||
}
|
||||
|
||||
// Build report data
|
||||
const viewedScene = game.scenes.get(game.user.viewedScene);
|
||||
/** @type {Partial<SupportReportData>} **/
|
||||
const report = {
|
||||
os, client,
|
||||
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"),
|
||||
gpu: rendererInfo,
|
||||
maxTextureSize: gl && gl.getParameter ? gl.getParameter(gl.MAX_TEXTURE_SIZE) : "Could not detect",
|
||||
hasViewedScene: !!viewedScene,
|
||||
packs: game.packs.size,
|
||||
worldScripts: Array.from(game.world.esmodules).concat(...game.world.scripts).map(s => `"${s}"`).join(", ")
|
||||
};
|
||||
|
||||
// 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;
|
||||
report.largestTexture = SupportDetails.#getLargestTexture();
|
||||
}
|
||||
|
||||
// Clean up temporary canvas
|
||||
if ( tempCanvas ) tempCanvas.remove();
|
||||
return report;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Find the largest texture in the scene.
|
||||
* @returns {{width: number, height: number, [src]: string}}
|
||||
*/
|
||||
static #getLargestTexture() {
|
||||
let largestTexture = { width: 0, height: 0 };
|
||||
|
||||
/**
|
||||
* Find any textures in the given DisplayObject or its children.
|
||||
* @param {DisplayObject} obj The object.
|
||||
*/
|
||||
function findTextures(obj) {
|
||||
if ( (obj instanceof PIXI.Sprite) || (obj instanceof SpriteMesh) || (obj instanceof PrimarySpriteMesh) ) {
|
||||
const texture = obj.texture?.baseTexture ?? {};
|
||||
const { width, height, resource } = texture;
|
||||
if ( Math.max(width, height) > Math.max(largestTexture.width, largestTexture.height) ) {
|
||||
largestTexture = { width, height, src: resource?.src };
|
||||
}
|
||||
}
|
||||
(obj?.children ?? []).forEach(findTextures);
|
||||
}
|
||||
|
||||
findTextures(canvas.stage);
|
||||
return largestTexture;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
133
resources/app/client/apps/sidebar/apps/tours-management.js
Normal file
133
resources/app/client/apps/sidebar/apps/tours-management.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
180
resources/app/client/apps/sidebar/apps/world-config.js
Normal file
180
resources/app/client/apps/sidebar/apps/world-config.js
Normal 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, game.i18n.lang));
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
881
resources/app/client/apps/sidebar/directory-tab-mixin.js
Normal file
881
resources/app/client/apps/sidebar/directory-tab-mixin.js
Normal file
@@ -0,0 +1,881 @@
|
||||
/**
|
||||
* @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 ) {
|
||||
const typeLabel = game.i18n.localize(getDocumentClass(this.collection.documentName).metadata.label);
|
||||
ui.notifications.warn(game.i18n.format("FOLDER.InvalidDocumentType", {type: typeLabel}));
|
||||
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
|
||||
* @returns {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 {DirectoryApplication} application The Application instance that the context menu is constructed in
|
||||
* @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 && (this.entryType !== "Compendium"),
|
||||
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, addSource: true});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
560
resources/app/client/apps/sidebar/document-directory.js
Normal file
560
resources/app/client/apps/sidebar/document-directory.js
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* @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 {renderContext, renderData} = context;
|
||||
if ( (renderContext === `update${this.entryType}`) && !renderData?.some(d => {
|
||||
return this.options.renderUpdateKeys.some(k => foundry.utils.hasProperty(d, k));
|
||||
}) ) 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;
|
||||
}
|
||||
});
|
||||
171
resources/app/client/apps/sidebar/package-configuration.js
Normal file
171
resources/app/client/apps/sidebar/package-configuration.js
Normal 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, game.i18n.lang);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @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) {}
|
||||
}
|
||||
178
resources/app/client/apps/sidebar/sidebar-tab.js
Normal file
178
resources/app/client/apps/sidebar/sidebar-tab.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Retain options from the main tab
|
||||
const options = {...this.options, popOut: true};
|
||||
delete options.id;
|
||||
delete options.classes;
|
||||
|
||||
// Create a popout application
|
||||
const pop = new this.constructor(options);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
272
resources/app/client/apps/sidebar/sidebar.js
Normal file
272
resources/app/client/apps/sidebar/sidebar.js
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Render the Sidebar container, and after rendering insert Sidebar tabs.
|
||||
*/
|
||||
class Sidebar extends Application {
|
||||
|
||||
/**
|
||||
* Singleton application instances for each sidebar tab
|
||||
* @type {Record<string, 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 {Record<string, 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];
|
||||
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();
|
||||
}
|
||||
}
|
||||
94
resources/app/client/apps/sidebar/tabs/actors-directory.js
Normal file
94
resources/app/client/apps/sidebar/tabs/actors-directory.js
Normal 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);
|
||||
}
|
||||
}
|
||||
21
resources/app/client/apps/sidebar/tabs/cards-directory.js
Normal file
21
resources/app/client/apps/sidebar/tabs/cards-directory.js
Normal 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;
|
||||
}
|
||||
}
|
||||
962
resources/app/client/apps/sidebar/tabs/chat-log.js
Normal file
962
resources/app/client/apps/sidebar/tabs/chat-log.js
Normal file
@@ -0,0 +1,962 @@
|
||||
/**
|
||||
* @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;
|
||||
|
||||
/**
|
||||
* A semaphore to queue rendering of Chat Messages.
|
||||
* @type {Semaphore}
|
||||
*/
|
||||
#renderingQueue = new foundry.utils.Semaphore(1);
|
||||
|
||||
/**
|
||||
* Currently rendering the next batch?
|
||||
* @type {boolean}
|
||||
*/
|
||||
#renderingBatch = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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: Object.entries(CONFIG.Dice.rollModes).map(([k, v]) => ({
|
||||
group: "CHAT.RollDefault",
|
||||
value: k,
|
||||
label: v
|
||||
})),
|
||||
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) {
|
||||
if ( this.#renderingBatch ) return;
|
||||
this.#renderingBatch = true;
|
||||
return this.#renderingQueue.add(async () => {
|
||||
const messages = this.collection.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;
|
||||
this.#renderingBatch = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* 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}={}) {
|
||||
return this.#renderingQueue.add(async () => {
|
||||
|
||||
// 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 ) game.audio.play(message.sound, {context: game.audio.interface});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return this.#renderingQueue.add(async () => {
|
||||
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.author._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");
|
||||
if (stamp) 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
|
||||
* @param {object} [options] Additional options
|
||||
* @param {ChatSpeakerData} [options.speaker] The speaker data
|
||||
* @returns {Promise<Object|void>} The prepared chat data object, or void if we were executing a macro instead
|
||||
*/
|
||||
async processMessage(message, {speaker}={}) {
|
||||
message = message.trim();
|
||||
if ( !message ) return;
|
||||
const cls = ChatMessage.implementation;
|
||||
|
||||
// Set up basic chat data
|
||||
const chatData = {
|
||||
user: game.user.id,
|
||||
speaker: 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 = [];
|
||||
const rollMode = command === "roll" ? game.settings.get("core", "rollMode") : command;
|
||||
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({allowInteractive: rollMode !== CONST.DICE_ROLL_MODES.BLIND});
|
||||
rolls.push(roll);
|
||||
}
|
||||
chatData.rolls = rolls;
|
||||
chatData.sound = CONFIG.sounds.dice;
|
||||
chatData.content = rolls.reduce((t, r) => t + r.total, 0);
|
||||
createOptions.rollMode = rollMode;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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.whisper);
|
||||
group.delete(game.user.id);
|
||||
group.add(w.author.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.style = CONST.CHAT_MESSAGE_STYLES.IC;
|
||||
createOptions.chatBubble = true;
|
||||
} else if ( command === "emote" ) {
|
||||
chatData.style = CONST.CHAT_MESSAGE_STYLES.EMOTE;
|
||||
chatData.content = `${chatData.speaker.alias} ${chatData.content}`;
|
||||
createOptions.chatBubble = true;
|
||||
}
|
||||
else {
|
||||
chatData.style = CONST.CHAT_MESSAGE_STYLES.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 = {};
|
||||
let k = undefined;
|
||||
for ( const p of params ) {
|
||||
const kv = p.split("=");
|
||||
if ( kv.length === 2 ) {
|
||||
k = kv[0];
|
||||
scope[k] = kv[1];
|
||||
expandName = false;
|
||||
}
|
||||
else if ( expandName ) macroName += ` ${p}`; // Macro names may contain spaces
|
||||
else if ( k ) scope[k] += ` ${p}`; // Expand prior argument value
|
||||
}
|
||||
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.clamp(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;
|
||||
event.stopPropagation();
|
||||
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) || Number.isNaN(pct);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
535
resources/app/client/apps/sidebar/tabs/combat-tracker.js
Normal file
535
resources/app/client/apps/sidebar/tabs/combat-tracker.js
Normal file
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* 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();
|
||||
for ( const effect of (combatant.actor?.temporaryEffects || []) ) {
|
||||
if ( effect.statuses.has(CONFIG.specialStatusEffects.DEFEATED) ) turn.defeated = true;
|
||||
else if ( effect.img ) turn.effects.add(effect.img);
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
// Confirm user permission to advance
|
||||
const isPlayerTurn = combat.combatant?.players?.includes(game.user);
|
||||
const canControl = combat.turn && combat.turn.between(1, combat.turns.length - 2)
|
||||
? combat.canUserModify(game.user, "update", {turn: 0})
|
||||
: combat.canUserModify(game.user, "update", {round: 0});
|
||||
|
||||
// Merge update data for rendering
|
||||
return foundry.utils.mergeObject(context, {
|
||||
round: combat.round,
|
||||
turn: combat.turn,
|
||||
turns: turns,
|
||||
control: isPlayerTurn && canControl
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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");
|
||||
await cls.create({scene: scene?.id, active: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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);
|
||||
try {
|
||||
const fn = combat[ctrl.dataset.control];
|
||||
if ( fn ) await fn.bind(combat)();
|
||||
} finally {
|
||||
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);
|
||||
|
||||
case "panToCombatant":
|
||||
return this._onPanToCombatant(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 defeatedId = CONFIG.specialStatusEffects.DEFEATED;
|
||||
await combatant.actor?.toggleStatusEffect(defeatedId, {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.WarnNonVisibleToken"));
|
||||
await canvas.ping(combatant.token.object.center);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle panning to a combatant Token
|
||||
* @param {Combatant} combatant The combatant data
|
||||
* @returns {Promise}
|
||||
* @protected
|
||||
*/
|
||||
async _onPanToCombatant(combatant) {
|
||||
if ( !canvas.ready || (combatant.sceneId !== canvas.scene.id) ) return;
|
||||
if ( !combatant.token.object.visible ) return ui.notifications.warn(game.i18n.localize("COMBAT.WarnNonVisibleToken"));
|
||||
const {x, y} = combatant.token.object.center;
|
||||
await canvas.animatePan({x, y, scale: Math.max(canvas.stage.scale.x, 0.5)});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
432
resources/app/client/apps/sidebar/tabs/compendium-directory.js
Normal file
432
resources/app/client/apps/sidebar/tabs/compendium-directory.js
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* 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;
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
39
resources/app/client/apps/sidebar/tabs/items-directory.js
Normal file
39
resources/app/client/apps/sidebar/tabs/items-directory.js
Normal 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);
|
||||
}
|
||||
}
|
||||
30
resources/app/client/apps/sidebar/tabs/journal-directory.js
Normal file
30
resources/app/client/apps/sidebar/tabs/journal-directory.js
Normal 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();
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
19
resources/app/client/apps/sidebar/tabs/macros-directory.js
Normal file
19
resources/app/client/apps/sidebar/tabs/macros-directory.js
Normal 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";
|
||||
}
|
||||
770
resources/app/client/apps/sidebar/tabs/playlists-directory.js
Normal file
770
resources/app/client/apps/sidebar/tabs/playlists-directory.js
Normal file
@@ -0,0 +1,770 @@
|
||||
/**
|
||||
* 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: foundry.audio.AudioHelper.volumeToInput(game.settings.get("core", "globalPlaylistVolume")),
|
||||
playlistTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalPlaylistVolume")),
|
||||
ambientModifier: foundry.audio.AudioHelper.volumeToInput(game.settings.get("core", "globalAmbientVolume")),
|
||||
ambientTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalAmbientVolume")),
|
||||
interfaceModifier: foundry.audio.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.VOLUME.TOOLTIP", { volume: Math.round(foundry.audio.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) {
|
||||
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 = (playlist.isOwner && !p.disabled) ? "" : "disabled";
|
||||
p.isOwner = playlist.isOwner;
|
||||
|
||||
// Playlist sounds
|
||||
const sounds = [];
|
||||
for ( const soundId of playlist.playbackOrder ) {
|
||||
const sound = playlist.sounds.get(soundId);
|
||||
if ( !(sound.isOwner || sound.playing) ) continue;
|
||||
|
||||
// All sounds
|
||||
const s = sound.toObject(false);
|
||||
s.playlistId = playlist.id;
|
||||
s.css = s.playing ? "playing" : "";
|
||||
s.controlCSS = sound.isOwner ? "" : "disabled";
|
||||
s.playIcon = this._getPlayIcon(sound);
|
||||
s.playTitle = s.pausedTime ? "PLAYLIST.SoundResume" : "PLAYLIST.SoundPlay";
|
||||
s.isOwner = sound.isOwner;
|
||||
|
||||
// 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 = foundry.audio.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]: "fas fa-ban",
|
||||
[CONST.PLAYLIST_MODES.SEQUENTIAL]: "far fa-arrow-alt-circle-right",
|
||||
[CONST.PLAYLIST_MODES.SHUFFLE]: "fas fa-random",
|
||||
[CONST.PLAYLIST_MODES.SIMULTANEOUS]: "fas fa-compress-arrows-alt"
|
||||
}[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));
|
||||
|
||||
// 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 = foundry.audio.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 = foundry.audio.AudioHelper.inputToVolume(slider.value);
|
||||
if ( volume === playlistSound.volume ) return;
|
||||
|
||||
// Immediately apply a local adjustment
|
||||
playlistSound.updateSource({volume});
|
||||
playlistSound.sound?.fade(playlistSound.volume, {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");
|
||||
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 {PlaylistDirectory} application The Application instance that the context menu is constructed in
|
||||
* @param {ContextMenuEntry[]} entryOptions The context menu entries
|
||||
*/
|
||||
ContextMenu.create(this, html, ".playlist .sound", this._getSoundContextOptions(), {hookName: "SoundContext"});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getEntryContextOptions() {
|
||||
const options = super._getEntryContextOptions();
|
||||
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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
122
resources/app/client/apps/sidebar/tabs/scenes-directory.js
Normal file
122
resources/app/client/apps/sidebar/tabs/scenes-directory.js
Normal 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;
|
||||
}
|
||||
}
|
||||
185
resources/app/client/apps/sidebar/tabs/settings.js
Normal file
185
resources/app/client/apps/sidebar/tabs/settings.js
Normal 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.WORLD_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);
|
||||
}
|
||||
}
|
||||
566
resources/app/client/apps/templates.js
Normal file
566
resources/app/client/apps/templates.js
Normal file
@@ -0,0 +1,566 @@
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* HTML Template Loading */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a template from the server by fetch request and caching the retrieved result
|
||||
* @param {string} path The web-accessible HTML template URL
|
||||
* @param {string} [id] An ID to register the partial with.
|
||||
* @returns {Promise<Function>} A Promise which resolves to the compiled Handlebars template
|
||||
*/
|
||||
async function getTemplate(path, id) {
|
||||
if ( path in Handlebars.partials ) return Handlebars.partials[path];
|
||||
const htmlString = await new Promise((resolve, reject) => {
|
||||
game.socket.emit("template", path, resp => {
|
||||
if ( resp.error ) return reject(new Error(resp.error));
|
||||
return resolve(resp.html);
|
||||
});
|
||||
});
|
||||
const compiled = Handlebars.compile(htmlString);
|
||||
Handlebars.registerPartial(id ?? path, compiled);
|
||||
console.log(`Foundry VTT | Retrieved and compiled template ${path}`);
|
||||
return compiled;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Load and cache a set of templates by providing an Array of paths
|
||||
* @param {string[]|Record<string, 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));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Construct an editor element for rich text editing with TinyMCE or ProseMirror.
|
||||
* @param {string} content The content to display and edit.
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.target] The named target data element
|
||||
* @param {boolean} [options.button] Include a button used to activate the editor later?
|
||||
* @param {string} [options.class] A specific CSS class to add to the editor container
|
||||
* @param {boolean} [options.editable=true] Is the text editor area currently editable?
|
||||
* @param {string} [options.engine=tinymce] The editor engine to use, see {@link TextEditor.create}.
|
||||
* @param {boolean} [options.collaborate=false] Whether to turn on collaborative editing features for ProseMirror.
|
||||
* @returns {Handlebars.SafeString}
|
||||
*
|
||||
* @example
|
||||
* ```hbs
|
||||
* {{editor world.description target="description" button=false engine="prosemirror" collaborate=false}}
|
||||
* ```
|
||||
*/
|
||||
static editor(content, options) {
|
||||
const { target, editable=true, button, engine="tinymce", collaborate=false, class: cssClass } = options.hash;
|
||||
const config = {name: target, value: content, button, collaborate, editable, engine};
|
||||
const element = foundry.applications.fields.createEditorInput(config);
|
||||
if ( cssClass ) element.querySelector(".editor-content").classList.add(cssClass);
|
||||
return new Handlebars.SafeString(element.outerHTML);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A ternary expression that allows inserting A or B depending on the value of C.
|
||||
* @param {boolean} criteria The test criteria
|
||||
* @param {string} ifTrue The string to output if true
|
||||
* @param {string} ifFalse The string to output if false
|
||||
* @returns {string} The ternary result
|
||||
*
|
||||
* @example Ternary if-then template usage
|
||||
* ```hbs
|
||||
* {{ifThen true "It is true" "It is false"}}
|
||||
* ```
|
||||
*/
|
||||
static ifThen(criteria, ifTrue, ifFalse) {
|
||||
return criteria ? ifTrue : ifFalse;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Translate a provided string key by using the loaded dictionary of localization strings.
|
||||
* @returns {string}
|
||||
*
|
||||
* @example Translate a provided localization string, optionally including formatting parameters
|
||||
* ```hbs
|
||||
* <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|string} value A numeric value to format
|
||||
* @param {object} options Additional options which customize the resulting format
|
||||
* @param {number} [options.decimals=0] The number of decimal places to include in the resulting string
|
||||
* @param {boolean} [options.sign=false] Whether to include an explicit "+" sign for positive numbers *
|
||||
* @returns {Handlebars.SafeString} The formatted string to be included in a template
|
||||
*
|
||||
* @example
|
||||
* ```hbs
|
||||
* {{formatNumber 5.5}} <!-- 5.5 -->
|
||||
* {{formatNumber 5.5 decimals=2}} <!-- 5.50 -->
|
||||
* {{formatNumber 5.5 decimals=2 sign=true}} <!-- +5.50 -->
|
||||
* {{formatNumber null decimals=2 sign=false}} <!-- NaN -->
|
||||
* {{formatNumber undefined decimals=0 sign=true}} <!-- NaN -->
|
||||
* ```
|
||||
*/
|
||||
static numberFormat(value, options) {
|
||||
const originalValue = value;
|
||||
const dec = options.hash.decimals ?? 0;
|
||||
const sign = options.hash.sign || false;
|
||||
if ( (typeof value === "string") || (value == null) ) value = parseFloat(value);
|
||||
if ( Number.isNaN(value) ) {
|
||||
console.warn("An invalid value was passed to numberFormat:", {
|
||||
originalValue,
|
||||
valueType: typeof originalValue,
|
||||
options
|
||||
});
|
||||
}
|
||||
let strVal = sign && (value >= 0) ? `+${value.toFixed(dec)}` : value.toFixed(dec);
|
||||
return new Handlebars.SafeString(strVal);
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render a form input field of type number with value appropriately rounded to step size.
|
||||
* @param {number} value
|
||||
* @param {FormInputConfig<number> & NumberInputConfig} options
|
||||
* @returns {Handlebars.SafeString}
|
||||
*
|
||||
* @example
|
||||
* ```hbs
|
||||
* {{numberInput value name="numberField" step=1 min=0 max=10}}
|
||||
* ```
|
||||
*/
|
||||
static numberInput(value, options) {
|
||||
const {class: cssClass, ...config} = options.hash;
|
||||
config.value = value;
|
||||
const element = foundry.applications.fields.createNumberInput(config);
|
||||
if ( cssClass ) element.className = cssClass;
|
||||
return new Handlebars.SafeString(element.outerHTML);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A helper to create a set of radio checkbox input elements in a named set.
|
||||
* The provided keys are the possible radio values while the provided values are human readable labels.
|
||||
*
|
||||
* @param {string} name The radio checkbox field name
|
||||
* @param {object} choices A mapping of radio checkbox values to human readable labels
|
||||
* @param {object} options Options which customize the radio boxes creation
|
||||
* @param {string} options.checked Which key is currently checked?
|
||||
* @param {boolean} options.localize Pass each label through string localization?
|
||||
* @returns {Handlebars.SafeString}
|
||||
*
|
||||
* @example The provided input data
|
||||
* ```js
|
||||
* let groupName = "importantChoice";
|
||||
* let choices = {a: "Choice A", b: "Choice B"};
|
||||
* let chosen = "a";
|
||||
* ```
|
||||
*
|
||||
* @example The template HTML structure
|
||||
* ```hbs
|
||||
* <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);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} SelectOptionsHelperOptions
|
||||
* @property {boolean} invert Invert the key/value order of a provided choices object
|
||||
* @property {string|string[]|Set<string>} selected The currently selected value or values
|
||||
*/
|
||||
|
||||
/**
|
||||
* A helper to create a set of <option> elements in a <select> block based on a provided dictionary.
|
||||
* The provided keys are the option values while the provided values are human-readable labels.
|
||||
* This helper supports both single-select and multi-select input fields.
|
||||
*
|
||||
* @param {object|Array<object>} choices A mapping of radio checkbox values to human-readable labels
|
||||
* @param {SelectInputConfig & SelectOptionsHelperOptions} options Options which configure how select options are
|
||||
* generated by the helper
|
||||
* @returns {Handlebars.SafeString} Generated HTML safe for rendering into a Handlebars template
|
||||
*
|
||||
* @example The provided input data
|
||||
* ```js
|
||||
* let choices = {a: "Choice A", b: "Choice B"};
|
||||
* let value = "a";
|
||||
* ```
|
||||
* The template HTML structure
|
||||
* ```hbs
|
||||
* <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, blank, sort, nameAttr, valueAttr, labelAttr, inverted, groups} = options.hash;
|
||||
if ( (selected === undefined) || (selected === null) ) selected = [];
|
||||
else if ( !(selected instanceof Array) ) selected = [selected];
|
||||
|
||||
if ( nameAttr && !valueAttr ) {
|
||||
foundry.utils.logCompatibilityWarning(`The "nameAttr" property of the {{selectOptions}} handlebars helper is
|
||||
renamed to "valueAttr" for consistency with other methods.`, {since: 12, until: 14});
|
||||
valueAttr = nameAttr;
|
||||
}
|
||||
|
||||
// Prepare the choices as an array of objects
|
||||
const selectOptions = [];
|
||||
if ( choices instanceof Array ) {
|
||||
for ( const [i, choice] of choices.entries() ) {
|
||||
if ( typeof choice === "object" ) selectOptions.push(choice);
|
||||
else selectOptions.push({value: i, label: choice});
|
||||
}
|
||||
}
|
||||
|
||||
// Object of keys and values
|
||||
else {
|
||||
for ( const choice of Object.entries(choices) ) {
|
||||
const [k, v] = inverted ? choice.reverse() : choice;
|
||||
const value = valueAttr ? v[valueAttr] : k;
|
||||
if ( typeof v === "object" ) selectOptions.push({value, ...v});
|
||||
else selectOptions.push({value, label: v});
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate to new fields helper
|
||||
const select = foundry.applications.fields.createSelectInput({
|
||||
options: selectOptions,
|
||||
value: selected,
|
||||
blank,
|
||||
groups,
|
||||
labelAttr,
|
||||
localize,
|
||||
sort,
|
||||
valueAttr
|
||||
});
|
||||
return new Handlebars.SafeString(select.innerHTML);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert a DataField instance into an HTML input fragment.
|
||||
* @param {DataField} field The DataField instance to convert to an input
|
||||
* @param {object} options Helper options
|
||||
* @returns {Handlebars.SafeString}
|
||||
*/
|
||||
static formInput(field, options) {
|
||||
const input = field.toInput(options.hash);
|
||||
return new Handlebars.SafeString(input.outerHTML);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert a DataField instance into an HTML input fragment.
|
||||
* @param {DataField} field The DataField instance to convert to an input
|
||||
* @param {object} options Helper options
|
||||
* @returns {Handlebars.SafeString}
|
||||
*/
|
||||
static formGroup(field, options) {
|
||||
const {classes, label, hint, rootId, stacked, units, widget, ...inputConfig} = options.hash;
|
||||
const groupConfig = {label, hint, rootId, stacked, widget, localize: inputConfig.localize, units,
|
||||
classes: typeof classes === "string" ? classes.split(" ") : []};
|
||||
const group = field.toFormGroup(groupConfig, inputConfig);
|
||||
return new Handlebars.SafeString(group.outerHTML);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
static filePicker(options) {
|
||||
foundry.utils.logCompatibilityWarning("The {{filePicker}} Handlebars helper is deprecated and replaced by"
|
||||
+ " use of the <file-picker> custom HTML element", {since: 12, until: 14, once: true});
|
||||
const type = options.hash.type;
|
||||
const target = options.hash.target;
|
||||
if ( !target ) throw new Error("You must define the name of the target field.");
|
||||
if ( game.world && !game.user.can("FILES_BROWSE" ) ) return "";
|
||||
const tooltip = game.i18n.localize("FILES.BrowseTooltip");
|
||||
return new Handlebars.SafeString(`
|
||||
<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>`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
static colorPicker(options) {
|
||||
foundry.utils.logCompatibilityWarning("The {{colorPicker}} Handlebars helper is deprecated and replaced by"
|
||||
+ " use of the <color-picker> custom HTML element", {since: 12, until: 14, once: true});
|
||||
let {name, default: defaultColor, value} = options.hash;
|
||||
name = name || "color";
|
||||
value = value || defaultColor || "";
|
||||
const htmlString = `<color-picker name="${name}" value="${value}"></color-picker>`;
|
||||
return new Handlebars.SafeString(htmlString);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
static select(selected, options) {
|
||||
foundry.utils.logCompatibilityWarning("The {{select}} handlebars helper is deprecated in favor of using the "
|
||||
+ "{{selectOptions}} helper or the foundry.applications.fields.createSelectInput, "
|
||||
+ "foundry.applications.fields.createMultiSelectElement, or "
|
||||
+ "foundry.applications.fields.prepareSelectOptionGroups methods.", {since: 12, until: 14});
|
||||
const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected));
|
||||
const rgx = new RegExp(` value=[\"']${escapedValue}[\"\']`);
|
||||
const html = options.fn(this);
|
||||
return html.replace(rgx, "$& selected");
|
||||
}
|
||||
}
|
||||
|
||||
// Register all handlebars helpers
|
||||
Handlebars.registerHelper({
|
||||
checked: HandlebarsHelpers.checked,
|
||||
disabled: HandlebarsHelpers.disabled,
|
||||
colorPicker: HandlebarsHelpers.colorPicker,
|
||||
concat: HandlebarsHelpers.concat,
|
||||
editor: HandlebarsHelpers.editor,
|
||||
formInput: HandlebarsHelpers.formInput,
|
||||
formGroup: HandlebarsHelpers.formGroup,
|
||||
formField: HandlebarsHelpers.formGroup, // Alias
|
||||
filePicker: HandlebarsHelpers.filePicker,
|
||||
ifThen: HandlebarsHelpers.ifThen,
|
||||
numberFormat: HandlebarsHelpers.numberFormat,
|
||||
numberInput: HandlebarsHelpers.numberInput,
|
||||
localize: HandlebarsHelpers.localize,
|
||||
radioBoxes: HandlebarsHelpers.radioBoxes,
|
||||
rangePicker: HandlebarsHelpers.rangePicker,
|
||||
select: HandlebarsHelpers.select,
|
||||
selectOptions: HandlebarsHelpers.selectOptions,
|
||||
timeSince: foundry.utils.timeSince,
|
||||
eq: (v1, v2) => v1 === v2,
|
||||
ne: (v1, v2) => v1 !== v2,
|
||||
lt: (v1, v2) => v1 < v2,
|
||||
gt: (v1, v2) => v1 > v2,
|
||||
lte: (v1, v2) => v1 <= v2,
|
||||
gte: (v1, v2) => v1 >= v2,
|
||||
not: pred => !pred,
|
||||
and() {return Array.prototype.every.call(arguments, Boolean);},
|
||||
or() {return Array.prototype.slice.call(arguments, 0, -1).some(Boolean);}
|
||||
});
|
||||
264
resources/app/client/av/client.js
Normal file
264
resources/app/client/av/client.js
Normal 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.");
|
||||
}
|
||||
}
|
||||
499
resources/app/client/av/clients/simplepeer.js
Normal file
499
resources/app/client/av/clients/simplepeer.js
Normal 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);
|
||||
}
|
||||
}
|
||||
467
resources/app/client/av/master.js
Normal file
467
resources/app/client/av/master.js
Normal 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(foundry.utils.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 = foundry.utils.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);
|
||||
}
|
||||
}
|
||||
254
resources/app/client/av/settings.js
Normal file
254
resources/app/client/av/settings.js
Normal 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 = foundry.utils.debounce((key, value) => game.settings.set("core", key, value), 100);
|
||||
this._change = foundry.utils.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 {Record<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 foundry.utils.getProperty(this[scope], setting);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
getUser(userId) {
|
||||
const user = game.users.get(userId);
|
||||
if ( !user ) return null;
|
||||
return this._getUserSettings(user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
set(scope, setting, value) {
|
||||
foundry.utils.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);
|
||||
}
|
||||
}
|
||||
2172
resources/app/client/config.js
Normal file
2172
resources/app/client/config.js
Normal file
File diff suppressed because it is too large
Load Diff
35
resources/app/client/core/clipboard.js
Normal file
35
resources/app/client/core/clipboard.js
Normal 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");
|
||||
}
|
||||
}
|
||||
222
resources/app/client/core/document-index.js
Normal file
222
resources/app/client/core/document-index.js
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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 {Record<string, WordTree>}
|
||||
*/
|
||||
Object.defineProperty(this, "trees", {value: {}});
|
||||
|
||||
/**
|
||||
* A reverse-lookup of a document's UUID to its parent node in the word tree.
|
||||
* @type {Record<string, StringTreeNode>}
|
||||
*/
|
||||
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.WORLD_DOCUMENT_TYPES.filter(c => {
|
||||
const documentClass = getDocumentClass(c);
|
||||
return documentClass.metadata.indexed && documentClass.schema.has("name");
|
||||
});
|
||||
// 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.
|
||||
* @param {StringTreeEntryFilter} [options.filterEntries] A filter function to apply to each candidate entry.
|
||||
* @param {DOCUMENT_OWNERSHIP_LEVELS|string} [options.ownership] Only return entries that the user meets this
|
||||
* ownership level for.
|
||||
* @returns {Record<string, WordTreeEntry[]>} A number of entries that have the given prefix, grouped by document
|
||||
* type.
|
||||
*/
|
||||
lookup(prefix, {limit=10, documentTypes=[], ownership, filterEntries}={}) {
|
||||
const types = documentTypes.length ? documentTypes : Object.keys(this.trees);
|
||||
if ( ownership !== undefined ) {
|
||||
const originalFilterEntries = filterEntries ?? (() => true);
|
||||
filterEntries = entry => {
|
||||
return originalFilterEntries(entry) && DocumentIndex.#filterEntryForOwnership(entry, ownership);
|
||||
}
|
||||
}
|
||||
const results = {};
|
||||
for ( const type of types ) {
|
||||
results[type] = [];
|
||||
const tree = this.trees[type];
|
||||
if ( !tree ) continue;
|
||||
results[type].push(...tree.lookup(prefix, { limit, filterEntries }));
|
||||
}
|
||||
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[foundry.utils.StringTree.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 foundry.utils.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);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check if the given entry meets the given ownership requirements.
|
||||
* @param {WordTreeEntry} entry The candidate entry.
|
||||
* @param {DOCUMENT_OWNERSHIP_LEVELS|string} ownership The ownership.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static #filterEntryForOwnership({ uuid, pack }, ownership) {
|
||||
if ( pack ) return game.packs.get(pack)?.testUserPermission(game.user, ownership);
|
||||
return fromUuidSync(uuid)?.testUserPermission(game.user, ownership);
|
||||
}
|
||||
}
|
||||
147
resources/app/client/core/gamepad.js
Normal file
147
resources/app/client/core/gamepad.js
Normal 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);
|
||||
}
|
||||
}
|
||||
195
resources/app/client/core/hooks.js
Normal file
195
resources/app/client/core/hooks.js
Normal 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 {Record<string, HookedFunction[]>}
|
||||
*/
|
||||
static get events() {
|
||||
return this.#events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {Record<string, 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 = new Error(`${msg}. ${error.message}`, { cause: error });
|
||||
if ( log ) console[log]?.(error);
|
||||
if ( notify ) ui.notifications[notify]?.(msg || error.message);
|
||||
Hooks.callAll("error", location, error, data);
|
||||
}
|
||||
}
|
||||
200
resources/app/client/core/image.js
Normal file
200
resources/app/client/core/image.js
Normal 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;
|
||||
}
|
||||
}
|
||||
290
resources/app/client/core/issues.js
Normal file
290
resources/app/client/core/issues.js
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* An object structure of document types at the top level, with a count of different sub-types for that document type.
|
||||
* @typedef {Record<string, Record<string, 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 {Record<string, 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 {Record<string, BrowserTest>}
|
||||
*/
|
||||
static #BROWSER_TESTS = {
|
||||
Electron: {
|
||||
minimum: 29,
|
||||
match: /Electron\/(\d+)\./,
|
||||
message: "ERROR.ElectronVersion"
|
||||
},
|
||||
Chromium: {
|
||||
minimum: 105,
|
||||
match: /Chrom(?:e|ium)\/(\d+)\./,
|
||||
message: "ERROR.BrowserVersion"
|
||||
},
|
||||
Firefox: {
|
||||
minimum: 121,
|
||||
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 {typeof Document} cls The Document class.
|
||||
* @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(cls, source, options={}) {
|
||||
if ( cls.hasTypeData ) this.#countDocumentSubType(cls.documentName, source.type, options);
|
||||
for ( const [embeddedName, field] of Object.entries(cls.hierarchy) ) {
|
||||
if ( !(field instanceof foundry.data.fields.EmbeddedCollectionField) ) continue;
|
||||
for ( const embedded of source[embeddedName] ) {
|
||||
this._countDocumentSubType(field.model, embedded, 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 {Record<string, UsabilityIssue>}
|
||||
*/
|
||||
get usabilityIssues() {
|
||||
return this.#usabilityIssues;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {object} PackageCompatibilityIssue
|
||||
* @property {string[]} error Error messages.
|
||||
* @property {string[]} warning Warning messages.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieve package compatibility issues.
|
||||
* @returns {Record<string, PackageCompatibilityIssue>}
|
||||
*/
|
||||
get packageCompatibilityIssues() {
|
||||
return game.data.packageWarnings;
|
||||
}
|
||||
}
|
||||
1005
resources/app/client/core/keybindings.js
Normal file
1005
resources/app/client/core/keybindings.js
Normal file
File diff suppressed because it is too large
Load Diff
430
resources/app/client/core/keyboard.js
Normal file
430
resources/app/client/core/keyboard.js
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* A set of helpers and management functions for dealing with user input from keyboard events.
|
||||
* {@link https://keycode.info/}
|
||||
*/
|
||||
class KeyboardManager {
|
||||
constructor() {
|
||||
this._reset();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Begin listening to keyboard events.
|
||||
* @internal
|
||||
*/
|
||||
_activateListeners() {
|
||||
window.addEventListener("keydown", event => this._handleKeyboardEvent(event, false));
|
||||
window.addEventListener("keyup", event => this._handleKeyboardEvent(event, true));
|
||||
window.addEventListener("visibilitychange", this._reset.bind(this));
|
||||
window.addEventListener("compositionend", this._onCompositionEnd.bind(this));
|
||||
window.addEventListener("focusin", this._onFocusIn.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The set of key codes which are currently depressed (down)
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
downKeys = new Set();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The set of movement keys which were recently pressed
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
moveKeys = new Set();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Allowed modifier keys
|
||||
* @enum {string}
|
||||
*/
|
||||
static MODIFIER_KEYS = {
|
||||
CONTROL: "Control",
|
||||
SHIFT: "Shift",
|
||||
ALT: "Alt"
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Track which KeyboardEvent#code presses associate with each modifier
|
||||
* @enum {string[]}
|
||||
*/
|
||||
static MODIFIER_CODES = {
|
||||
[this.MODIFIER_KEYS.ALT]: ["AltLeft", "AltRight"],
|
||||
[this.MODIFIER_KEYS.CONTROL]: ["ControlLeft", "ControlRight", "MetaLeft", "MetaRight", "Meta", "OsLeft", "OsRight"],
|
||||
[this.MODIFIER_KEYS.SHIFT]: ["ShiftLeft", "ShiftRight"]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Key codes which are "protected" and should not be used because they are reserved for browser-level actions.
|
||||
* @type {string[]}
|
||||
*/
|
||||
static PROTECTED_KEYS = ["F5", "F11", "F12", "PrintScreen", "ScrollLock", "NumLock", "CapsLock"];
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The OS-specific string display for what their Command key is
|
||||
* @type {string}
|
||||
*/
|
||||
static CONTROL_KEY_STRING = navigator.appVersion.includes("Mac") ? "⌘" : "Control";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A special mapping of how special KeyboardEvent#code values should map to displayed strings or symbols.
|
||||
* Values in this configuration object override any other display formatting rules which may be applied.
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
static KEYCODE_DISPLAY_MAPPING = (() => {
|
||||
const isMac = navigator.appVersion.includes("Mac");
|
||||
return {
|
||||
ArrowLeft: isMac ? "←" : "🡸",
|
||||
ArrowRight: isMac ? "→" : "🡺",
|
||||
ArrowUp: isMac ? "↑" : "🡹",
|
||||
ArrowDown: isMac ? "↓" : "🡻",
|
||||
Backquote: "`",
|
||||
Backslash: "\\",
|
||||
BracketLeft: "[",
|
||||
BracketRight: "]",
|
||||
Comma: ",",
|
||||
Control: this.CONTROL_KEY_STRING,
|
||||
Equal: "=",
|
||||
Meta: isMac ? "⌘" : "⊞",
|
||||
MetaLeft: isMac ? "⌘" : "⊞",
|
||||
MetaRight: isMac ? "⌘" : "⊞",
|
||||
OsLeft: isMac ? "⌘" : "⊞",
|
||||
OsRight: isMac ? "⌘" : "⊞",
|
||||
Minus: "-",
|
||||
NumpadAdd: "Numpad+",
|
||||
NumpadSubtract: "Numpad-",
|
||||
Period: ".",
|
||||
Quote: "'",
|
||||
Semicolon: ";",
|
||||
Slash: "/"
|
||||
};
|
||||
})();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether an HTMLElement currently has focus.
|
||||
* If so we normally don't want to process keybinding actions.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get hasFocus() {
|
||||
return document.querySelector(":focus") instanceof HTMLElement;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Emulates a key being pressed, triggering the Keyboard event workflow.
|
||||
* @param {boolean} up If True, emulates the `keyup` Event. Else, the `keydown` event
|
||||
* @param {string} code The KeyboardEvent#code which is being pressed
|
||||
* @param {object} [options] Additional options to configure behavior.
|
||||
* @param {boolean} [options.altKey=false] Emulate the ALT modifier as pressed
|
||||
* @param {boolean} [options.ctrlKey=false] Emulate the CONTROL modifier as pressed
|
||||
* @param {boolean} [options.shiftKey=false] Emulate the SHIFT modifier as pressed
|
||||
* @param {boolean} [options.repeat=false] Emulate this as a repeat event
|
||||
* @param {boolean} [options.force=false] Force the event to be handled.
|
||||
* @returns {KeyboardEventContext}
|
||||
*/
|
||||
static emulateKeypress(up, code, {altKey=false, ctrlKey=false, shiftKey=false, repeat=false, force=false}={}) {
|
||||
const event = new KeyboardEvent(`key${up ? "up" : "down"}`, {code, altKey, ctrlKey, shiftKey, repeat});
|
||||
const context = this.getKeyboardEventContext(event, up);
|
||||
game.keyboard._processKeyboardContext(context, {force});
|
||||
game.keyboard.downKeys.delete(context.key);
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Format a KeyboardEvent#code into a displayed string.
|
||||
* @param {string} code The input code
|
||||
* @returns {string} The displayed string for this code
|
||||
*/
|
||||
static getKeycodeDisplayString(code) {
|
||||
if ( code in this.KEYCODE_DISPLAY_MAPPING ) return this.KEYCODE_DISPLAY_MAPPING[code];
|
||||
if ( code.startsWith("Digit") ) return code.replace("Digit", "");
|
||||
if ( code.startsWith("Key") ) return code.replace("Key", "");
|
||||
return code;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a standardized keyboard context for a given event.
|
||||
* Every individual keypress is uniquely identified using the KeyboardEvent#code property.
|
||||
* A list of possible key codes is documented here: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values
|
||||
*
|
||||
* @param {KeyboardEvent} event The originating keypress event
|
||||
* @param {boolean} up A flag for whether the key is down or up
|
||||
* @return {KeyboardEventContext} The standardized context of the event
|
||||
*/
|
||||
static getKeyboardEventContext(event, up=false) {
|
||||
let context = {
|
||||
event: event,
|
||||
key: event.code,
|
||||
isShift: event.shiftKey,
|
||||
isControl: event.ctrlKey || event.metaKey,
|
||||
isAlt: event.altKey,
|
||||
hasModifier: event.shiftKey || event.ctrlKey || event.metaKey || event.altKey,
|
||||
modifiers: [],
|
||||
up: up,
|
||||
repeat: event.repeat
|
||||
};
|
||||
if ( context.isShift ) context.modifiers.push(this.MODIFIER_KEYS.SHIFT);
|
||||
if ( context.isControl ) context.modifiers.push(this.MODIFIER_KEYS.CONTROL);
|
||||
if ( context.isAlt ) context.modifiers.push(this.MODIFIER_KEYS.ALT);
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Report whether a modifier in KeyboardManager.MODIFIER_KEYS is currently actively depressed.
|
||||
* @param {string} modifier A modifier in MODIFIER_KEYS
|
||||
* @returns {boolean} Is this modifier key currently down (active)?
|
||||
*/
|
||||
isModifierActive(modifier) {
|
||||
return this.constructor.MODIFIER_CODES[modifier].some(k => this.downKeys.has(k));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Report whether a core action key is currently actively depressed.
|
||||
* @param {string} action The core action to verify (ex: "target")
|
||||
* @returns {boolean} Is this core action key currently down (active)?
|
||||
*/
|
||||
isCoreActionKeyActive(action) {
|
||||
const binds = game.keybindings.get("core", action);
|
||||
return !!binds?.some(k => this.downKeys.has(k.key));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Converts a Keyboard Context event into a string representation, such as "C" or "Control+C"
|
||||
* @param {KeyboardEventContext} context The standardized context of the event
|
||||
* @param {boolean} includeModifiers If True, includes modifiers in the string representation
|
||||
* @return {string}
|
||||
* @private
|
||||
*/
|
||||
static _getContextDisplayString(context, includeModifiers = true) {
|
||||
const parts = [this.getKeycodeDisplayString(context.key)];
|
||||
if ( includeModifiers && context.hasModifier ) {
|
||||
if ( context.isShift && context.event.key !== "Shift" ) parts.unshift(this.MODIFIER_KEYS.SHIFT);
|
||||
if ( context.isControl && context.event.key !== "Control" ) parts.unshift(this.MODIFIER_KEYS.CONTROL);
|
||||
if ( context.isAlt && context.event.key !== "Alt" ) parts.unshift(this.MODIFIER_KEYS.ALT);
|
||||
}
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a standardized pressed key, find all matching registered Keybind Actions.
|
||||
* @param {KeyboardEventContext} context A standardized keyboard event context
|
||||
* @return {KeybindingAction[]} The matched Keybind Actions. May be empty.
|
||||
* @internal
|
||||
*/
|
||||
static _getMatchingActions(context) {
|
||||
let possibleMatches = game.keybindings.activeKeys.get(context.key) ?? [];
|
||||
if ( CONFIG.debug.keybindings ) console.dir(possibleMatches);
|
||||
return possibleMatches.filter(action => KeyboardManager._testContext(action, context));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether a keypress context matches the registration for a keybinding action
|
||||
* @param {KeybindingAction} action The keybinding action
|
||||
* @param {KeyboardEventContext} context The keyboard event context
|
||||
* @returns {boolean} Does the context match the action requirements?
|
||||
* @private
|
||||
*/
|
||||
static _testContext(action, context) {
|
||||
if ( context.repeat && !action.repeat ) return false;
|
||||
if ( action.restricted && !game.user.isGM ) return false;
|
||||
|
||||
// If the context includes no modifiers, we match if the binding has none
|
||||
if ( !context.hasModifier ) return action.requiredModifiers.length === 0;
|
||||
|
||||
// Test that modifiers match expectation
|
||||
const modifiers = this.MODIFIER_KEYS;
|
||||
const activeModifiers = {
|
||||
[modifiers.CONTROL]: context.isControl,
|
||||
[modifiers.SHIFT]: context.isShift,
|
||||
[modifiers.ALT]: context.isAlt
|
||||
};
|
||||
for (let [k, v] of Object.entries(activeModifiers)) {
|
||||
|
||||
// Ignore exact matches to a modifier key
|
||||
if ( this.MODIFIER_CODES[k].includes(context.key) ) continue;
|
||||
|
||||
// Verify that required modifiers are present
|
||||
if ( action.requiredModifiers.includes(k) ) {
|
||||
if ( !v ) return false;
|
||||
}
|
||||
|
||||
// No unsupported modifiers can be present for a "down" event
|
||||
else if ( !context.up && !action.optionalModifiers.includes(k) && v ) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a registered Keybinding Action, executes the action with a given event and context
|
||||
*
|
||||
* @param {KeybindingAction} keybind The registered Keybinding action to execute
|
||||
* @param {KeyboardEventContext} context The gathered context of the event
|
||||
* @return {boolean} Returns true if the keybind was consumed
|
||||
* @private
|
||||
*/
|
||||
static _executeKeybind(keybind, context) {
|
||||
if ( CONFIG.debug.keybindings ) console.log("Executing " + game.i18n.localize(keybind.name));
|
||||
context.action = keybind.action;
|
||||
let consumed = false;
|
||||
if ( context.up && keybind.onUp ) consumed = keybind.onUp(context);
|
||||
else if ( !context.up && keybind.onDown ) consumed = keybind.onDown(context);
|
||||
return consumed;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Processes a keyboard event context, checking it against registered keybinding actions
|
||||
* @param {KeyboardEventContext} context The keyboard event context
|
||||
* @param {object} [options] Additional options to configure behavior.
|
||||
* @param {boolean} [options.force=false] Force the event to be handled.
|
||||
* @protected
|
||||
*/
|
||||
_processKeyboardContext(context, {force=false}={}) {
|
||||
|
||||
// Track the current set of pressed keys
|
||||
if ( context.up ) this.downKeys.delete(context.key);
|
||||
else this.downKeys.add(context.key);
|
||||
|
||||
// If an input field has focus, don't process Keybinding Actions
|
||||
if ( this.hasFocus && !force ) return;
|
||||
|
||||
// Open debugging group
|
||||
if ( CONFIG.debug.keybindings ) {
|
||||
console.group(`[${context.up ? 'UP' : 'DOWN'}] Checking for keybinds that respond to ${context.modifiers}+${context.key}`);
|
||||
console.dir(context);
|
||||
}
|
||||
|
||||
// Check against registered Keybindings
|
||||
const actions = KeyboardManager._getMatchingActions(context);
|
||||
if (actions.length === 0) {
|
||||
if ( CONFIG.debug.keybindings ) {
|
||||
console.log("No matching keybinds");
|
||||
console.groupEnd();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute matching Keybinding Actions to see if any consume the event
|
||||
let handled;
|
||||
for ( const action of actions ) {
|
||||
handled = KeyboardManager._executeKeybind(action, context);
|
||||
if ( handled ) break;
|
||||
}
|
||||
|
||||
// Cancel event since we handled it
|
||||
if ( handled && context.event ) {
|
||||
if ( CONFIG.debug.keybindings ) console.log("Event was consumed");
|
||||
context.event?.preventDefault();
|
||||
context.event?.stopPropagation();
|
||||
}
|
||||
if ( CONFIG.debug.keybindings ) console.groupEnd();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reset tracking for which keys are in the down and released states
|
||||
* @private
|
||||
*/
|
||||
_reset() {
|
||||
this.downKeys = new Set();
|
||||
this.moveKeys = new Set();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Emulate a key-up event for any currently down keys. When emulating, we go backwards such that combinations such as
|
||||
* "CONTROL + S" emulate the "S" first in order to capture modifiers.
|
||||
* @param {object} [options] Options to configure behavior.
|
||||
* @param {boolean} [options.force=true] Force the keyup events to be handled.
|
||||
*/
|
||||
releaseKeys({force=true}={}) {
|
||||
const reverseKeys = Array.from(this.downKeys).reverse();
|
||||
for ( const key of reverseKeys ) {
|
||||
this.constructor.emulateKeypress(true, key, {
|
||||
force,
|
||||
ctrlKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.CONTROL),
|
||||
shiftKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.SHIFT),
|
||||
altKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.ALT)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a key press into the down position
|
||||
* @param {KeyboardEvent} event The originating keyboard event
|
||||
* @param {boolean} up A flag for whether the key is down or up
|
||||
* @private
|
||||
*/
|
||||
_handleKeyboardEvent(event, up) {
|
||||
if ( event.isComposing ) return; // Ignore IME composition
|
||||
if ( !event.key && !event.code ) return; // Some browsers fire keyup and keydown events when autocompleting values.
|
||||
let context = KeyboardManager.getKeyboardEventContext(event, up);
|
||||
this._processKeyboardContext(context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Input events do not fire with isComposing = false at the end of a composition event in Chrome
|
||||
* See: https://github.com/w3c/uievents/issues/202
|
||||
* @param {CompositionEvent} event
|
||||
*/
|
||||
_onCompositionEnd(event) {
|
||||
return this._handleKeyboardEvent(event, false);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Release any down keys when focusing a form element.
|
||||
* @param {FocusEvent} event The focus event.
|
||||
* @protected
|
||||
*/
|
||||
_onFocusIn(event) {
|
||||
const formElements = [
|
||||
HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLOptionElement, HTMLButtonElement
|
||||
];
|
||||
if ( event.target.isContentEditable || formElements.some(cls => event.target instanceof cls) ) this.releaseKeys();
|
||||
}
|
||||
}
|
||||
71
resources/app/client/core/mouse.js
Normal file
71
resources/app/client/core/mouse.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Management class for Mouse events
|
||||
*/
|
||||
class MouseManager {
|
||||
constructor() {
|
||||
this._wheelTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify a rate limit for mouse wheel to gate repeated scrolling.
|
||||
* This is especially important for continuous scrolling mice which emit hundreds of events per second.
|
||||
* This designates a minimum number of milliseconds which must pass before another wheel event is handled
|
||||
* @type {number}
|
||||
*/
|
||||
static MOUSE_WHEEL_RATE_LIMIT = 50;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Begin listening to mouse events.
|
||||
* @internal
|
||||
*/
|
||||
_activateListeners() {
|
||||
window.addEventListener("wheel", this._onWheel.bind(this), {passive: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Master mouse-wheel event handler
|
||||
* @param {WheelEvent} event The mouse wheel event
|
||||
* @private
|
||||
*/
|
||||
_onWheel(event) {
|
||||
|
||||
// Prevent zooming the entire browser window
|
||||
if ( event.ctrlKey ) event.preventDefault();
|
||||
|
||||
// Interpret shift+scroll as vertical scroll
|
||||
let dy = event.delta = event.deltaY;
|
||||
if ( event.shiftKey && (dy === 0) ) {
|
||||
dy = event.delta = event.deltaX;
|
||||
}
|
||||
if ( dy === 0 ) return;
|
||||
|
||||
// Take no actions if the canvas is not hovered
|
||||
if ( !canvas.ready ) return;
|
||||
const hover = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if ( !hover || (hover.id !== "board") ) return;
|
||||
event.preventDefault();
|
||||
|
||||
// Identify scroll modifiers
|
||||
const isCtrl = event.ctrlKey || event.metaKey;
|
||||
const isShift = event.shiftKey;
|
||||
const layer = canvas.activeLayer;
|
||||
|
||||
// Case 1 - rotate placeable objects
|
||||
if ( layer?.options?.rotatableObjects && (isCtrl || isShift) ) {
|
||||
const hasTarget = layer.options?.controllableObjects ? layer.controlled.length : !!layer.hover;
|
||||
if ( hasTarget ) {
|
||||
const t = Date.now();
|
||||
if ( (t - this._wheelTime) < this.constructor.MOUSE_WHEEL_RATE_LIMIT ) return;
|
||||
this._wheelTime = t;
|
||||
return layer._onMouseWheel(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2 - zoom the canvas
|
||||
canvas._onMouseWheel(event);
|
||||
}
|
||||
}
|
||||
133
resources/app/client/core/nue.js
Normal file
133
resources/app/client/core/nue.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Responsible for managing the New User Experience workflows.
|
||||
*/
|
||||
class NewUserExperience {
|
||||
constructor() {
|
||||
Hooks.on("renderChatMessage", this._activateListeners.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the new user experience.
|
||||
* Currently, this generates some chat messages with hints for getting started if we detect this is a new world.
|
||||
*/
|
||||
initialize() {
|
||||
// If there are no documents, we can reasonably assume this is a new World.
|
||||
const isNewWorld = !(game.actors.size + game.scenes.size + game.items.size + game.journal.size);
|
||||
if ( !isNewWorld ) return;
|
||||
this._createInitialChatMessages();
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this._showNewWorldTour();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Show chat tips for first launch.
|
||||
* @private
|
||||
*/
|
||||
_createInitialChatMessages() {
|
||||
if ( game.settings.get("core", "nue.shownTips") ) return;
|
||||
|
||||
// Get GM's
|
||||
const gms = ChatMessage.getWhisperRecipients("GM");
|
||||
|
||||
// Build Chat Messages
|
||||
const content = [`
|
||||
<h3 class="nue">${game.i18n.localize("NUE.FirstLaunchHeader")}</h3>
|
||||
<p class="nue">${game.i18n.localize("NUE.FirstLaunchBody")}</p>
|
||||
<p class="nue">${game.i18n.localize("NUE.FirstLaunchKB")}</p>
|
||||
<footer class="nue">${game.i18n.localize("NUE.FirstLaunchHint")}</footer>
|
||||
`, `
|
||||
<h3 class="nue">${game.i18n.localize("NUE.FirstLaunchInvite")}</h3>
|
||||
<p class="nue">${game.i18n.localize("NUE.FirstLaunchInviteBody")}</p>
|
||||
<p class="nue">${game.i18n.localize("NUE.FirstLaunchTroubleshooting")}</p>
|
||||
<footer class="nue">${game.i18n.localize("NUE.FirstLaunchHint")}</footer>
|
||||
`];
|
||||
const chatData = content.map(c => {
|
||||
return {
|
||||
whisper: gms,
|
||||
speaker: {alias: game.i18n.localize("Foundry Virtual Tabletop")},
|
||||
flags: {core: {nue: true, canPopout: true}},
|
||||
content: c
|
||||
};
|
||||
});
|
||||
ChatMessage.implementation.createDocuments(chatData);
|
||||
|
||||
// Store flag indicating this was shown
|
||||
game.settings.set("core", "nue.shownTips", true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a default scene for the new world.
|
||||
* @private
|
||||
*/
|
||||
async _createDefaultScene() {
|
||||
if ( !game.user.isGM ) return;
|
||||
const filePath = foundry.utils.getRoute("/nue/defaultscene/scene.json");
|
||||
const response = await foundry.utils.fetchWithTimeout(filePath, {method: "GET"});
|
||||
const json = await response.json();
|
||||
const scene = await Scene.create(json);
|
||||
await scene.activate();
|
||||
canvas.animatePan({scale: 0.7, duration: 100});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Automatically show uncompleted Tours related to new worlds.
|
||||
* @private
|
||||
*/
|
||||
async _showNewWorldTour() {
|
||||
const tour = game.tours.get("core.welcome");
|
||||
if ( tour?.status === Tour.STATUS.UNSTARTED ) {
|
||||
await this._createDefaultScene();
|
||||
tour.start();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add event listeners to the chat card links.
|
||||
* @param {ChatMessage} msg The ChatMessage being rendered.
|
||||
* @param {jQuery} html The HTML content of the message.
|
||||
* @private
|
||||
*/
|
||||
_activateListeners(msg, html) {
|
||||
if ( !msg.getFlag("core", "nue") ) return;
|
||||
html.find(".nue-tab").click(this._onTabLink.bind(this));
|
||||
html.find(".nue-action").click(this._onActionLink.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform some special action triggered by clicking on a link in a NUE chat card.
|
||||
* @param {TriggeredEvent} event The click event.
|
||||
* @private
|
||||
*/
|
||||
_onActionLink(event) {
|
||||
event.preventDefault();
|
||||
const action = event.currentTarget.dataset.action;
|
||||
switch ( action ) {
|
||||
case "invite": return new InvitationLinks().render(true);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Switch to the appropriate tab when a user clicks on a link in the chat message.
|
||||
* @param {TriggeredEvent} event The click event.
|
||||
* @private
|
||||
*/
|
||||
_onTabLink(event) {
|
||||
event.preventDefault();
|
||||
const tab = event.currentTarget.dataset.tab;
|
||||
ui.sidebar.activateTab(tab);
|
||||
}
|
||||
}
|
||||
365
resources/app/client/core/packages.js
Normal file
365
resources/app/client/core/packages.js
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* @typedef {Object} PackageCompatibilityBadge
|
||||
* @property {string} type A type in "safe", "unsafe", "warning", "neutral" applied as a CSS class
|
||||
* @property {string} tooltip A tooltip string displayed when hovering over the badge
|
||||
* @property {string} [label] An optional text label displayed in the badge
|
||||
* @property {string} [icon] An optional icon displayed in the badge
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* A client-side mixin used for all Package types.
|
||||
* @param {typeof BasePackage} BasePackage The parent BasePackage class being mixed
|
||||
* @returns {typeof ClientPackage} A BasePackage subclass mixed with ClientPackage features
|
||||
* @category - Mixins
|
||||
*/
|
||||
function ClientPackageMixin(BasePackage) {
|
||||
class ClientPackage extends BasePackage {
|
||||
|
||||
/**
|
||||
* Is this package marked as a favorite?
|
||||
* This boolean is currently only populated as true in the /setup view of the software.
|
||||
* @type {boolean}
|
||||
*/
|
||||
favorite = false;
|
||||
|
||||
/**
|
||||
* Associate package availability with certain badge for client-side display.
|
||||
* @returns {PackageCompatibilityBadge|null}
|
||||
*/
|
||||
getVersionBadge() {
|
||||
return this.constructor.getVersionBadge(this.availability, this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine a version badge for the provided compatibility data.
|
||||
* @param {number} availability The availability level.
|
||||
* @param {Partial<PackageManifestData>} data The compatibility data.
|
||||
* @param {object} [options]
|
||||
* @param {Collection<string, Module>} [options.modules] A specific collection of modules to test availability
|
||||
* against. Tests against the currently installed modules by
|
||||
* default.
|
||||
* @param {Collection<string, System>} [options.systems] A specific collection of systems to test availability
|
||||
* against. Tests against the currently installed systems by
|
||||
* default.
|
||||
* @returns {PackageCompatibilityBadge|null}
|
||||
*/
|
||||
static getVersionBadge(availability, data, { modules, systems }={}) {
|
||||
modules ??= game.modules;
|
||||
systems ??= game.systems;
|
||||
const codes = CONST.PACKAGE_AVAILABILITY_CODES;
|
||||
const { compatibility, version, relationships } = data;
|
||||
switch ( availability ) {
|
||||
|
||||
// Unsafe
|
||||
case codes.UNKNOWN:
|
||||
case codes.REQUIRES_CORE_DOWNGRADE:
|
||||
case codes.REQUIRES_CORE_UPGRADE_STABLE:
|
||||
case codes.REQUIRES_CORE_UPGRADE_UNSTABLE:
|
||||
const labels = {
|
||||
[codes.UNKNOWN]: "SETUP.CompatibilityUnknown",
|
||||
[codes.REQUIRES_CORE_DOWNGRADE]: "SETUP.RequireCoreDowngrade",
|
||||
[codes.REQUIRES_CORE_UPGRADE_STABLE]: "SETUP.RequireCoreUpgrade",
|
||||
[codes.REQUIRES_CORE_UPGRADE_UNSTABLE]: "SETUP.RequireCoreUnstable"
|
||||
};
|
||||
return {
|
||||
type: "error",
|
||||
tooltip: game.i18n.localize(labels[availability]),
|
||||
label: version,
|
||||
icon: "fa fa-file-slash"
|
||||
};
|
||||
|
||||
case codes.MISSING_SYSTEM:
|
||||
return {
|
||||
type: "error",
|
||||
tooltip: game.i18n.format("SETUP.RequireDep", { dependencies: data.system }),
|
||||
label: version,
|
||||
icon: "fa fa-file-slash"
|
||||
};
|
||||
|
||||
case codes.MISSING_DEPENDENCY:
|
||||
case codes.REQUIRES_DEPENDENCY_UPDATE:
|
||||
return {
|
||||
type: "error",
|
||||
label: version,
|
||||
icon: "fa fa-file-slash",
|
||||
tooltip: this._formatBadDependenciesTooltip(availability, data, relationships.requires, {
|
||||
modules, systems
|
||||
})
|
||||
};
|
||||
|
||||
// Warning
|
||||
case codes.UNVERIFIED_GENERATION:
|
||||
return {
|
||||
type: "warning",
|
||||
tooltip: game.i18n.format("SETUP.CompatibilityRiskWithVersion", { version: compatibility.verified }),
|
||||
label: version,
|
||||
icon: "fas fa-exclamation-triangle"
|
||||
};
|
||||
|
||||
case codes.UNVERIFIED_SYSTEM:
|
||||
return {
|
||||
type: "warning",
|
||||
label: version,
|
||||
icon: "fas fa-exclamation-triangle",
|
||||
tooltip: this._formatIncompatibleSystemsTooltip(data, relationships.systems, { systems })
|
||||
};
|
||||
|
||||
// Neutral
|
||||
case codes.UNVERIFIED_BUILD:
|
||||
return {
|
||||
type: "neutral",
|
||||
tooltip: game.i18n.format("SETUP.CompatibilityRiskWithVersion", { version: compatibility.verified }),
|
||||
label: version,
|
||||
icon: "fas fa-code-branch"
|
||||
};
|
||||
|
||||
// Safe
|
||||
case codes.VERIFIED:
|
||||
return {
|
||||
type: "success",
|
||||
tooltip: game.i18n.localize("SETUP.Verified"),
|
||||
label: version,
|
||||
icon: "fas fa-code-branch"
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* List missing dependencies and format them for display.
|
||||
* @param {number} availability The availability value.
|
||||
* @param {Partial<PackageManifestData>} data The compatibility data.
|
||||
* @param {Iterable<RelatedPackage>} deps The dependencies to format.
|
||||
* @param {object} [options]
|
||||
* @param {Collection<string, Module>} [options.modules] A specific collection of modules to test availability
|
||||
* against. Tests against the currently installed modules by
|
||||
* default.
|
||||
* @param {Collection<string, System>} [options.systems] A specific collection of systems to test availability
|
||||
* against. Tests against the currently installed systems by
|
||||
* default.
|
||||
* @returns {string}
|
||||
* @protected
|
||||
*/
|
||||
static _formatBadDependenciesTooltip(availability, data, deps, { modules, systems }={}) {
|
||||
modules ??= game.modules;
|
||||
systems ??= game.systems;
|
||||
const codes = CONST.PACKAGE_AVAILABILITY_CODES;
|
||||
const checked = new Set();
|
||||
const bad = [];
|
||||
for ( const dep of deps ) {
|
||||
if ( (dep.type !== "module") || checked.has(dep.id) ) continue;
|
||||
if ( !modules.has(dep.id) ) bad.push(dep.id);
|
||||
else if ( availability === codes.REQUIRES_DEPENDENCY_UPDATE ) {
|
||||
const module = modules.get(dep.id);
|
||||
if ( module.availability !== codes.VERIFIED ) bad.push(dep.id);
|
||||
}
|
||||
checked.add(dep.id);
|
||||
}
|
||||
const label = availability === codes.MISSING_DEPENDENCY ? "SETUP.RequireDep" : "SETUP.IncompatibleDep";
|
||||
const formatter = game.i18n.getListFormatter({ style: "short", type: "unit" });
|
||||
return game.i18n.format(label, { dependencies: formatter.format(bad) });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* List any installed systems that are incompatible with this module's systems relationship, and format them for
|
||||
* display.
|
||||
* @param {Partial<PackageManifestData>} data The compatibility data.
|
||||
* @param {Iterable<RelatedPackage>} relationships The system relationships.
|
||||
* @param {object} [options]
|
||||
* @param {Collection<string, System>} [options.systems] A specific collection of systems to test against. Tests
|
||||
* against the currently installed systems by default.
|
||||
* @returns {string}
|
||||
* @protected
|
||||
*/
|
||||
static _formatIncompatibleSystemsTooltip(data, relationships, { systems }={}) {
|
||||
systems ??= game.systems;
|
||||
const incompatible = [];
|
||||
for ( const { id, compatibility } of relationships ) {
|
||||
const system = systems.get(id);
|
||||
if ( !system ) continue;
|
||||
if ( !this.testDependencyCompatibility(compatibility, system) || system.unavailable ) incompatible.push(id);
|
||||
}
|
||||
const label = incompatible.length ? "SETUP.IncompatibleSystems" : "SETUP.NoSupportedSystem";
|
||||
const formatter = game.i18n.getListFormatter({ style: "short", type: "unit" });
|
||||
return game.i18n.format(label, { systems: formatter.format(incompatible) });
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* When a package has been installed, add it to the local game data.
|
||||
*/
|
||||
install() {
|
||||
const collection = this.constructor.collection;
|
||||
game.data[collection].push(this.toObject());
|
||||
game[collection].set(this.id, this);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* When a package has been uninstalled, remove it from the local game data.
|
||||
*/
|
||||
uninstall() {
|
||||
this.constructor.uninstall(this.id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a package from the local game data when it has been uninstalled.
|
||||
* @param {string} id The package ID.
|
||||
*/
|
||||
static uninstall(id) {
|
||||
game.data[this.collection].findSplice(p => p.id === id);
|
||||
game[this.collection].delete(id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the latest Package manifest from a provided remote location.
|
||||
* @param {string} manifest A remote manifest URL to load
|
||||
* @param {object} options Additional options which affect package construction
|
||||
* @param {boolean} [options.strict=true] Whether to construct the remote package strictly
|
||||
* @returns {Promise<ClientPackage|null>} A Promise which resolves to a constructed ServerPackage instance
|
||||
* @throws An error if the retrieved manifest data is invalid
|
||||
*/
|
||||
static async fromRemoteManifest(manifest, {strict=false}={}) {
|
||||
try {
|
||||
const data = await Setup.post({action: "getPackageFromRemoteManifest", type: this.type, manifest});
|
||||
return new this(data, {installed: false, strict: strict});
|
||||
}
|
||||
catch(e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ClientPackage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @extends foundry.packages.BaseModule
|
||||
* @mixes ClientPackageMixin
|
||||
* @category - Packages
|
||||
*/
|
||||
class Module extends ClientPackageMixin(foundry.packages.BaseModule) {
|
||||
constructor(data, options = {}) {
|
||||
const {active} = data;
|
||||
super(data, options);
|
||||
|
||||
/**
|
||||
* Is this package currently active?
|
||||
* @type {boolean}
|
||||
*/
|
||||
Object.defineProperty(this, "active", {value: active, writable: false});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @extends foundry.packages.BaseSystem
|
||||
* @mixes ClientPackageMixin
|
||||
* @category - Packages
|
||||
*/
|
||||
class System extends ClientPackageMixin(foundry.packages.BaseSystem) {
|
||||
constructor(data, options={}) {
|
||||
options.strictDataCleaning = data.strictDataCleaning;
|
||||
super(data, options);
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
_configure(options) {
|
||||
super._configure(options);
|
||||
this.strictDataCleaning = !!options.strictDataCleaning;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get template() {
|
||||
foundry.utils.logCompatibilityWarning("System#template is deprecated in favor of System#documentTypes",
|
||||
{since: 12, until: 14});
|
||||
return game.model;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @extends foundry.packages.BaseWorld
|
||||
* @mixes ClientPackageMixin
|
||||
* @category - Packages
|
||||
*/
|
||||
class World extends ClientPackageMixin(foundry.packages.BaseWorld) {
|
||||
|
||||
/** @inheritDoc */
|
||||
static getVersionBadge(availability, data, { modules, systems }={}) {
|
||||
modules ??= game.modules;
|
||||
systems ??= game.systems;
|
||||
const badge = super.getVersionBadge(availability, data, { modules, systems });
|
||||
if ( !badge ) return badge;
|
||||
const codes = CONST.PACKAGE_AVAILABILITY_CODES;
|
||||
if ( availability === codes.VERIFIED ) {
|
||||
const system = systems.get(data.system);
|
||||
if ( system.availability !== codes.VERIFIED ) badge.type = "neutral";
|
||||
}
|
||||
if ( !data.manifest ) badge.label = "";
|
||||
return badge;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide data for a system badge displayed for the world which reflects the system ID and its availability
|
||||
* @param {System} [system] A specific system to use, otherwise use the installed system.
|
||||
* @returns {PackageCompatibilityBadge|null}
|
||||
*/
|
||||
getSystemBadge(system) {
|
||||
system ??= game.systems.get(this.system);
|
||||
if ( !system ) return {
|
||||
type: "error",
|
||||
tooltip: game.i18n.format("SETUP.RequireSystem", { system: this.system }),
|
||||
label: this.system,
|
||||
icon: "fa fa-file-slash"
|
||||
};
|
||||
const badge = system.getVersionBadge();
|
||||
if ( badge.type === "safe" ) {
|
||||
badge.type = "neutral";
|
||||
badge.icon = null;
|
||||
}
|
||||
badge.tooltip = `<p>${system.title}</p><p>${badge.tooltip}</p>`;
|
||||
badge.label = system.id;
|
||||
return badge;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static _formatBadDependenciesTooltip(availability, data, deps) {
|
||||
const system = game.systems.get(data.system);
|
||||
if ( system ) deps ??= [...data.relationships.requires.values(), ...system.relationships.requires.values()];
|
||||
return super._formatBadDependenciesTooltip(availability, data, deps);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A mapping of allowed package types and the classes which implement them.
|
||||
* @type {{world: World, system: System, module: Module}}
|
||||
*/
|
||||
const PACKAGE_TYPES = {
|
||||
world: World,
|
||||
system: System,
|
||||
module: Module
|
||||
};
|
||||
285
resources/app/client/core/settings.js
Normal file
285
resources/app/client/core/settings.js
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* A class responsible for managing defined game settings or settings menus.
|
||||
* Each setting is a string key/value pair belonging to a certain namespace and a certain store scope.
|
||||
*
|
||||
* When Foundry Virtual Tabletop is initialized, a singleton instance of this class is constructed within the global
|
||||
* Game object as game.settings.
|
||||
*
|
||||
* @see {@link Game#settings}
|
||||
* @see {@link Settings}
|
||||
* @see {@link SettingsConfig}
|
||||
*/
|
||||
class ClientSettings {
|
||||
constructor(worldSettings) {
|
||||
|
||||
/**
|
||||
* A object of registered game settings for this scope
|
||||
* @type {Map<string, SettingsConfig>}
|
||||
*/
|
||||
this.settings = new Map();
|
||||
|
||||
/**
|
||||
* Registered settings menus which trigger secondary applications
|
||||
* @type {Map}
|
||||
*/
|
||||
this.menus = new Map();
|
||||
|
||||
/**
|
||||
* The storage interfaces used for persisting settings
|
||||
* Each storage interface shares the same API as window.localStorage
|
||||
*/
|
||||
this.storage = new Map([
|
||||
["client", window.localStorage],
|
||||
["world", new WorldSettings(worldSettings)]
|
||||
]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a singleton instance of the Game Settings Configuration app
|
||||
* @returns {SettingsConfig}
|
||||
*/
|
||||
get sheet() {
|
||||
if ( !this._sheet ) this._sheet = new SettingsConfig();
|
||||
return this._sheet;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register a new game setting under this setting scope
|
||||
*
|
||||
* @param {string} namespace The namespace under which the setting is registered
|
||||
* @param {string} key The key name for the setting under the namespace
|
||||
* @param {SettingConfig} data Configuration for setting data
|
||||
*
|
||||
* @example Register a client setting
|
||||
* ```js
|
||||
* game.settings.register("myModule", "myClientSetting", {
|
||||
* name: "Register a Module Setting with Choices",
|
||||
* hint: "A description of the registered setting and its behavior.",
|
||||
* scope: "client", // This specifies a client-stored setting
|
||||
* config: true, // This specifies that the setting appears in the configuration view
|
||||
* requiresReload: true // This will prompt the user to reload the application for the setting to take effect.
|
||||
* type: String,
|
||||
* choices: { // If choices are defined, the resulting setting will be a select menu
|
||||
* "a": "Option A",
|
||||
* "b": "Option B"
|
||||
* },
|
||||
* default: "a", // The default value for the setting
|
||||
* onChange: value => { // A callback function which triggers when the setting is changed
|
||||
* console.log(value)
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example Register a world setting
|
||||
* ```js
|
||||
* game.settings.register("myModule", "myWorldSetting", {
|
||||
* name: "Register a Module Setting with a Range slider",
|
||||
* hint: "A description of the registered setting and its behavior.",
|
||||
* scope: "world", // This specifies a world-level setting
|
||||
* config: true, // This specifies that the setting appears in the configuration view
|
||||
* requiresReload: true // This will prompt the GM to have all clients reload the application for the setting to
|
||||
* // take effect.
|
||||
* type: new foundry.fields.NumberField({nullable: false, min: 0, max: 100, step: 10}),
|
||||
* default: 50, // The default value for the setting
|
||||
* onChange: value => { // A callback function which triggers when the setting is changed
|
||||
* console.log(value)
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
register(namespace, key, data) {
|
||||
if ( !namespace || !key ) throw new Error("You must specify both namespace and key portions of the setting");
|
||||
data.key = key;
|
||||
data.namespace = namespace;
|
||||
data.scope = ["client", "world"].includes(data.scope) ? data.scope : "client";
|
||||
key = `${namespace}.${key}`;
|
||||
|
||||
// Validate type
|
||||
if ( data.type ) {
|
||||
const allowedTypes = [foundry.data.fields.DataField, foundry.abstract.DataModel, Function];
|
||||
if ( !allowedTypes.some(t => data.type instanceof t) ) {
|
||||
throw new Error(`Setting ${key} type must be a DataField, DataModel, or callable function`);
|
||||
}
|
||||
|
||||
// Sync some setting data with the DataField
|
||||
if ( data.type instanceof foundry.data.fields.DataField ) {
|
||||
data.default ??= data.type.initial;
|
||||
data.type.name = key;
|
||||
data.type.label ??= data.label;
|
||||
data.type.hint ??= data.hint;
|
||||
}
|
||||
}
|
||||
|
||||
// Setting values may not be undefined, only null, so the default should also adhere to this behavior
|
||||
data.default ??= null;
|
||||
|
||||
// Store the setting configuration
|
||||
this.settings.set(key, data);
|
||||
|
||||
// Reinitialize to cast the value of the Setting into its defined type
|
||||
if ( data.scope === "world" ) this.storage.get("world").getSetting(key)?.reset();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register a new sub-settings menu
|
||||
*
|
||||
* @param {string} namespace The namespace under which the menu is registered
|
||||
* @param {string} key The key name for the setting under the namespace
|
||||
* @param {SettingSubmenuConfig} data Configuration for setting data
|
||||
*
|
||||
* @example Define a settings submenu which handles advanced configuration needs
|
||||
* ```js
|
||||
* game.settings.registerMenu("myModule", "mySettingsMenu", {
|
||||
* name: "My Settings Submenu",
|
||||
* label: "Settings Menu Label", // The text label used in the button
|
||||
* hint: "A description of what will occur in the submenu dialog.",
|
||||
* icon: "fas fa-bars", // A Font Awesome icon used in the submenu button
|
||||
* type: MySubmenuApplicationClass, // A FormApplication subclass which should be created
|
||||
* restricted: true // Restrict this submenu to gamemaster only?
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
registerMenu(namespace, key, data) {
|
||||
if ( !namespace || !key ) throw new Error("You must specify both namespace and key portions of the menu");
|
||||
data.key = `${namespace}.${key}`;
|
||||
data.namespace = namespace;
|
||||
if ( !((data.type?.prototype instanceof FormApplication)
|
||||
|| (data.type?.prototype instanceof foundry.applications.api.ApplicationV2) )) {
|
||||
throw new Error("You must provide a menu type that is a FormApplication or ApplicationV2 instance or subclass");
|
||||
}
|
||||
this.menus.set(data.key, data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the value of a game setting for a certain namespace and setting key
|
||||
*
|
||||
* @param {string} namespace The namespace under which the setting is registered
|
||||
* @param {string} key The setting key to retrieve
|
||||
*
|
||||
* @example Retrieve the current setting value
|
||||
* ```js
|
||||
* game.settings.get("myModule", "myClientSetting");
|
||||
* ```
|
||||
*/
|
||||
get(namespace, key) {
|
||||
key = this.#assertKey(namespace, key);
|
||||
const config = this.settings.get(key);
|
||||
const storage = this.storage.get(config.scope);
|
||||
|
||||
// Get the Setting instance
|
||||
let setting;
|
||||
switch ( config.scope ) {
|
||||
case "client":
|
||||
setting = new Setting({key, value: storage.getItem(key) ?? config.default});
|
||||
break;
|
||||
case "world":
|
||||
setting = storage.getSetting(key);
|
||||
if ( !setting ) setting = new Setting({key, value: config.default});
|
||||
break;
|
||||
}
|
||||
return setting.value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set the value of a game setting for a certain namespace and setting key
|
||||
*
|
||||
* @param {string} namespace The namespace under which the setting is registered
|
||||
* @param {string} key The setting key to retrieve
|
||||
* @param {*} value The data to assign to the setting key
|
||||
* @param {object} [options] Additional options passed to the server when updating world-scope settings
|
||||
* @returns {*} The assigned setting value
|
||||
*
|
||||
* @example Update the current value of a setting
|
||||
* ```js
|
||||
* game.settings.set("myModule", "myClientSetting", "b");
|
||||
* ```
|
||||
*/
|
||||
async set(namespace, key, value, options={}) {
|
||||
key = this.#assertKey(namespace, key);
|
||||
const setting = this.settings.get(key);
|
||||
if ( value === undefined ) value = setting.default;
|
||||
|
||||
// Assign using DataField
|
||||
if ( setting.type instanceof foundry.data.fields.DataField ) {
|
||||
const err = setting.type.validate(value, {fallback: false});
|
||||
if ( err instanceof foundry.data.validation.DataModelValidationFailure ) throw err.asError();
|
||||
}
|
||||
|
||||
// Assign using DataModel
|
||||
if ( foundry.utils.isSubclass(setting.type, foundry.abstract.DataModel) ) {
|
||||
value = setting.type.fromSource(value, {strict: true});
|
||||
}
|
||||
|
||||
// Save the setting change
|
||||
if ( setting.scope === "world" ) await this.#setWorld(key, value, options);
|
||||
else this.#setClient(key, value, setting.onChange);
|
||||
return value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assert that the namespace and setting name were provided and form a valid key.
|
||||
* @param {string} namespace The setting namespace
|
||||
* @param {string} settingName The setting name
|
||||
* @returns {string} The combined setting key
|
||||
*/
|
||||
#assertKey(namespace, settingName) {
|
||||
const key = `${namespace}.${settingName}`;
|
||||
if ( !namespace || !settingName ) throw new Error("You must specify both namespace and key portions of the"
|
||||
+ `setting, you provided "${key}"`);
|
||||
if ( !this.settings.has(key) ) throw new Error(`"${key}" is not a registered game setting`);
|
||||
return key;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create or update a Setting document in the World database.
|
||||
* @param {string} key The setting key
|
||||
* @param {*} value The desired setting value
|
||||
* @param {object} [options] Additional options which are passed to the document creation or update workflows
|
||||
* @returns {Promise<Setting>} The created or updated Setting document
|
||||
*/
|
||||
async #setWorld(key, value, options) {
|
||||
if ( !game.ready ) throw new Error("You may not set a World-level Setting before the Game is ready.");
|
||||
const current = this.storage.get("world").getSetting(key);
|
||||
const json = JSON.stringify(value);
|
||||
if ( current ) return current.update({value: json}, options);
|
||||
else return Setting.create({key, value: json}, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create or update a Setting document in the browser client storage.
|
||||
* @param {string} key The setting key
|
||||
* @param {*} value The desired setting value
|
||||
* @param {Function} onChange A registered setting onChange callback
|
||||
* @returns {Setting} A Setting document which represents the created setting
|
||||
*/
|
||||
#setClient(key, value, onChange) {
|
||||
const storage = this.storage.get("client");
|
||||
const json = JSON.stringify(value);
|
||||
let setting;
|
||||
if ( key in storage ) {
|
||||
setting = new Setting({key, value: storage.getItem(key)});
|
||||
const diff = setting.updateSource({value: json});
|
||||
if ( foundry.utils.isEmpty(diff) ) return setting;
|
||||
}
|
||||
else setting = new Setting({key, value: json});
|
||||
storage.setItem(key, json);
|
||||
if ( onChange instanceof Function ) onChange(value);
|
||||
return setting;
|
||||
}
|
||||
}
|
||||
35
resources/app/client/core/socket.js
Normal file
35
resources/app/client/core/socket.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* A standardized way socket messages are dispatched and their responses are handled
|
||||
*/
|
||||
class SocketInterface {
|
||||
/**
|
||||
* Send a socket request to all other clients and handle their responses.
|
||||
* @param {string} eventName The socket event name being handled
|
||||
* @param {DocumentSocketRequest|object} request Request data provided to the Socket event
|
||||
* @returns {Promise<SocketResponse>} A Promise which resolves to the SocketResponse
|
||||
*/
|
||||
static dispatch(eventName, request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
game.socket.emit(eventName, request, response => {
|
||||
if ( response.error ) {
|
||||
const err = SocketInterface.#handleError(response.error);
|
||||
reject(err);
|
||||
}
|
||||
else resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle an error returned from the database, displaying it on screen and in the console
|
||||
* @param {Error} err The provided Error message
|
||||
*/
|
||||
static #handleError(err) {
|
||||
let error = err instanceof Error ? err : new Error(err.message);
|
||||
if ( err.stack ) error.stack = err.stack;
|
||||
if ( ui.notifications ) ui.notifications.error(error.message);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
114
resources/app/client/core/sorting.js
Normal file
114
resources/app/client/core/sorting.js
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* A collection of functions related to sorting objects within a parent container.
|
||||
*/
|
||||
class SortingHelpers {
|
||||
|
||||
/**
|
||||
* Given a source object to sort, a target to sort relative to, and an Array of siblings in the container:
|
||||
* Determine the updated sort keys for the source object, or all siblings if a reindex is required.
|
||||
* Return an Array of updates to perform, it is up to the caller to dispatch these updates.
|
||||
* Each update is structured as:
|
||||
* {
|
||||
* target: object,
|
||||
* update: {sortKey: sortValue}
|
||||
* }
|
||||
*
|
||||
* @param {object} source The source object being sorted
|
||||
* @param {object} [options] Options which modify the sort behavior
|
||||
* @param {object|null} [options.target] The target object relative which to sort
|
||||
* @param {object[]} [options.siblings] The Array of siblings which the source should be sorted within
|
||||
* @param {string} [options.sortKey=sort] The property name within the source object which defines the sort key
|
||||
* @param {boolean} [options.sortBefore] Explicitly sort before (true) or sort after( false).
|
||||
* If undefined the sort order will be automatically determined.
|
||||
* @returns {object[]} An Array of updates for the caller of the helper function to perform
|
||||
*/
|
||||
static performIntegerSort(source, {target=null, siblings=[], sortKey="sort", sortBefore}={}) {
|
||||
|
||||
// Automatically determine the sorting direction
|
||||
if ( sortBefore === undefined ) {
|
||||
sortBefore = (source[sortKey] || 0) > (target?.[sortKey] || 0);
|
||||
}
|
||||
|
||||
// Ensure the siblings are sorted
|
||||
siblings = Array.from(siblings);
|
||||
siblings.sort((a, b) => a[sortKey] - b[sortKey]);
|
||||
|
||||
// Determine the index target for the sort
|
||||
let defaultIdx = sortBefore ? siblings.length : 0;
|
||||
let idx = target ? siblings.findIndex(sib => sib === target) : defaultIdx;
|
||||
|
||||
// Determine the indices to sort between
|
||||
let min, max;
|
||||
if ( sortBefore ) [min, max] = this._sortBefore(siblings, idx, sortKey);
|
||||
else [min, max] = this._sortAfter(siblings, idx, sortKey);
|
||||
|
||||
// Easiest case - no siblings
|
||||
if ( siblings.length === 0 ) {
|
||||
return [{
|
||||
target: source,
|
||||
update: {[sortKey]: CONST.SORT_INTEGER_DENSITY}
|
||||
}];
|
||||
}
|
||||
|
||||
// No minimum - sort to beginning
|
||||
else if ( Number.isFinite(max) && (min === null) ) {
|
||||
return [{
|
||||
target: source,
|
||||
update: {[sortKey]: max - CONST.SORT_INTEGER_DENSITY}
|
||||
}];
|
||||
}
|
||||
|
||||
// No maximum - sort to end
|
||||
else if ( Number.isFinite(min) && (max === null) ) {
|
||||
return [{
|
||||
target: source,
|
||||
update: {[sortKey]: min + CONST.SORT_INTEGER_DENSITY}
|
||||
}];
|
||||
}
|
||||
|
||||
// Sort between two
|
||||
else if ( Number.isFinite(min) && Number.isFinite(max) && (Math.abs(max - min) > 1) ) {
|
||||
return [{
|
||||
target: source,
|
||||
update: {[sortKey]: Math.round(0.5 * (min + max))}
|
||||
}];
|
||||
}
|
||||
|
||||
// Reindex all siblings
|
||||
else {
|
||||
siblings.splice(idx + (sortBefore ? 0 : 1), 0, source);
|
||||
return siblings.map((sib, i) => {
|
||||
return {
|
||||
target: sib,
|
||||
update: {[sortKey]: (i+1) * CONST.SORT_INTEGER_DENSITY}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given an ordered Array of siblings and a target position, return the [min,max] indices to sort before the target
|
||||
* @private
|
||||
*/
|
||||
static _sortBefore(siblings, idx, sortKey) {
|
||||
let max = siblings[idx] ? siblings[idx][sortKey] : null;
|
||||
let min = siblings[idx-1] ? siblings[idx-1][sortKey] : null;
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given an ordered Array of siblings and a target position, return the [min,max] indices to sort after the target
|
||||
* @private
|
||||
*/
|
||||
static _sortAfter(siblings, idx, sortKey) {
|
||||
let min = siblings[idx] ? siblings[idx][sortKey] : null;
|
||||
let max = siblings[idx+1] ? siblings[idx+1][sortKey] : null;
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
}
|
||||
120
resources/app/client/core/time.js
Normal file
120
resources/app/client/core/time.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* A singleton class {@link game#time} which keeps the official Server and World time stamps.
|
||||
* Uses a basic implementation of https://www.geeksforgeeks.org/cristians-algorithm/ for synchronization.
|
||||
*/
|
||||
class GameTime {
|
||||
constructor(socket) {
|
||||
|
||||
/**
|
||||
* The most recently synchronized timestamps retrieved from the server.
|
||||
* @type {{clientTime: number, serverTime: number, worldTime: number}}
|
||||
*/
|
||||
this._time = {};
|
||||
|
||||
/**
|
||||
* The average one-way latency across the most recent 5 trips
|
||||
* @type {number}
|
||||
*/
|
||||
this._dt = 0;
|
||||
|
||||
/**
|
||||
* The most recent five synchronization durations
|
||||
* @type {number[]}
|
||||
*/
|
||||
this._dts = [];
|
||||
|
||||
// Perform an initial sync
|
||||
if ( socket ) this.sync(socket);
|
||||
}
|
||||
|
||||
/**
|
||||
* The amount of time to delay before re-syncing the official server time.
|
||||
* @type {number}
|
||||
*/
|
||||
static SYNC_INTERVAL_MS = 1000 * 60 * 5;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The current server time based on the last synchronization point and the approximated one-way latency.
|
||||
* @type {number}
|
||||
*/
|
||||
get serverTime() {
|
||||
const t1 = Date.now();
|
||||
const dt = t1 - this._time.clientTime;
|
||||
if ( dt > GameTime.SYNC_INTERVAL_MS ) this.sync();
|
||||
return this._time.serverTime + dt;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The current World time based on the last recorded value of the core.time setting
|
||||
* @type {number}
|
||||
*/
|
||||
get worldTime() {
|
||||
return this._time.worldTime;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Advance the game time by a certain number of seconds
|
||||
* @param {number} seconds The number of seconds to advance (or rewind if negative) by
|
||||
* @param {object} [options] Additional options passed to game.settings.set
|
||||
* @returns {Promise<number>} The new game time
|
||||
*/
|
||||
async advance(seconds, options) {
|
||||
return game.settings.set("core", "time", this.worldTime + seconds, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Synchronize the local client game time with the official time kept by the server
|
||||
* @param {Socket} socket The connected server Socket instance
|
||||
* @returns {Promise<GameTime>}
|
||||
*/
|
||||
async sync(socket) {
|
||||
socket = socket ?? game.socket;
|
||||
|
||||
// Get the official time from the server
|
||||
const t0 = Date.now();
|
||||
const time = await new Promise(resolve => socket.emit("time", resolve));
|
||||
const t1 = Date.now();
|
||||
|
||||
// Adjust for trip duration
|
||||
if ( this._dts.length >= 5 ) this._dts.unshift();
|
||||
this._dts.push(t1 - t0);
|
||||
|
||||
// Re-compute the average one-way duration
|
||||
this._dt = Math.round(this._dts.reduce((total, t) => total + t, 0) / (this._dts.length * 2));
|
||||
|
||||
// Adjust the server time and return the adjusted time
|
||||
time.clientTime = t1 - this._dt;
|
||||
this._time = time;
|
||||
console.log(`${vtt} | Synchronized official game time in ${this._dt}ms`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers and Callbacks */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle follow-up actions when the official World time is changed
|
||||
* @param {number} worldTime The new canonical World time.
|
||||
* @param {object} options Options passed from the requesting client where the change was made
|
||||
* @param {string} userId The ID of the User who advanced the time
|
||||
*/
|
||||
onUpdateWorldTime(worldTime, options, userId) {
|
||||
const dt = worldTime - this._time.worldTime;
|
||||
this._time.worldTime = worldTime;
|
||||
Hooks.callAll("updateWorldTime", worldTime, dt, options, userId);
|
||||
if ( CONFIG.debug.time ) console.log(`The world time advanced by ${dt} seconds, and is now ${worldTime}.`);
|
||||
}
|
||||
}
|
||||
497
resources/app/client/core/tooltip.js
Normal file
497
resources/app/client/core/tooltip.js
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* A singleton Tooltip Manager class responsible for rendering and positioning a dynamic tooltip element which is
|
||||
* accessible as `game.tooltip`.
|
||||
*
|
||||
* @see {@link Game.tooltip}
|
||||
*
|
||||
* @example API Usage
|
||||
* ```js
|
||||
* game.tooltip.activate(htmlElement, {text: "Some tooltip text", direction: "UP"});
|
||||
* game.tooltip.deactivate();
|
||||
* ```
|
||||
*
|
||||
* @example HTML Usage
|
||||
* ```html
|
||||
* <span data-tooltip="Some Tooltip" data-tooltip-direction="LEFT">I have a tooltip</span>
|
||||
* <ol data-tooltip-direction="RIGHT">
|
||||
* <li data-tooltip="The First One">One</li>
|
||||
* <li data-tooltip="The Second One">Two</li>
|
||||
* <li data-tooltip="The Third One">Three</li>
|
||||
* </ol>
|
||||
* ```
|
||||
*/
|
||||
class TooltipManager {
|
||||
|
||||
/**
|
||||
* A cached reference to the global tooltip element
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
tooltip = document.getElementById("tooltip");
|
||||
|
||||
/**
|
||||
* A reference to the HTML element which is currently tool-tipped, if any.
|
||||
* @type {HTMLElement|null}
|
||||
*/
|
||||
element = null;
|
||||
|
||||
/**
|
||||
* An amount of margin which is used to offset tooltips from their anchored element.
|
||||
* @type {number}
|
||||
*/
|
||||
static TOOLTIP_MARGIN_PX = 5;
|
||||
|
||||
/**
|
||||
* The number of milliseconds delay which activates a tooltip on a "long hover".
|
||||
* @type {number}
|
||||
*/
|
||||
static TOOLTIP_ACTIVATION_MS = 500;
|
||||
|
||||
/**
|
||||
* The directions in which a tooltip can extend, relative to its tool-tipped element.
|
||||
* @enum {string}
|
||||
*/
|
||||
static TOOLTIP_DIRECTIONS = {
|
||||
UP: "UP",
|
||||
DOWN: "DOWN",
|
||||
LEFT: "LEFT",
|
||||
RIGHT: "RIGHT",
|
||||
CENTER: "CENTER"
|
||||
};
|
||||
|
||||
/**
|
||||
* The number of pixels buffer around a locked tooltip zone before they should be dismissed.
|
||||
* @type {number}
|
||||
*/
|
||||
static LOCKED_TOOLTIP_BUFFER_PX = 50;
|
||||
|
||||
/**
|
||||
* Is the tooltip currently active?
|
||||
* @type {boolean}
|
||||
*/
|
||||
#active = false;
|
||||
|
||||
/**
|
||||
* A reference to a window timeout function when an element is activated.
|
||||
*/
|
||||
#activationTimeout;
|
||||
|
||||
/**
|
||||
* A reference to a window timeout function when an element is deactivated.
|
||||
*/
|
||||
#deactivationTimeout;
|
||||
|
||||
/**
|
||||
* An element which is pending tooltip activation if hover is sustained
|
||||
* @type {HTMLElement|null}
|
||||
*/
|
||||
#pending;
|
||||
|
||||
/**
|
||||
* Maintain state about active locked tooltips in order to perform appropriate automatic dismissal.
|
||||
* @type {{elements: Set<HTMLElement>, boundingBox: Rectangle}}
|
||||
*/
|
||||
#locked = {
|
||||
elements: new Set(),
|
||||
boundingBox: {}
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate interactivity by listening for hover events on HTML elements which have a data-tooltip defined.
|
||||
*/
|
||||
activateEventListeners() {
|
||||
document.body.addEventListener("pointerenter", this.#onActivate.bind(this), true);
|
||||
document.body.addEventListener("pointerleave", this.#onDeactivate.bind(this), true);
|
||||
document.body.addEventListener("pointerup", this._onLockTooltip.bind(this), true);
|
||||
document.body.addEventListener("pointermove", this.#testLockedTooltipProximity.bind(this), {
|
||||
capture: true,
|
||||
passive: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle hover events which activate a tooltipped element.
|
||||
* @param {PointerEvent} event The initiating pointerenter event
|
||||
*/
|
||||
#onActivate(event) {
|
||||
if ( Tour.tourInProgress ) return; // Don't activate tooltips during a tour
|
||||
const element = event.target;
|
||||
if ( element.closest(".editor-content.ProseMirror") ) return; // Don't activate tooltips inside text editors.
|
||||
if ( !element.dataset.tooltip ) {
|
||||
// Check if the element has moved out from underneath the cursor and pointerenter has fired on a non-child of the
|
||||
// tooltipped element.
|
||||
if ( this.#active && !this.element.contains(element) ) this.#startDeactivation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't activate tooltips if the element contains an active context menu or is in a matching link tooltip
|
||||
if ( element.matches("#context-menu") || element.querySelector("#context-menu") ) return;
|
||||
|
||||
// If the tooltip is currently active, we can move it to a new element immediately
|
||||
if ( this.#active ) {
|
||||
this.activate(element);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing deactivation workflow
|
||||
this.#clearDeactivation();
|
||||
|
||||
// Delay activation to determine user intent
|
||||
this.#pending = element;
|
||||
this.#activationTimeout = window.setTimeout(() => {
|
||||
this.#activationTimeout = null;
|
||||
if ( this.#pending ) this.activate(this.#pending);
|
||||
}, this.constructor.TOOLTIP_ACTIVATION_MS);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle hover events which deactivate a tooltipped element.
|
||||
* @param {PointerEvent} event The initiating pointerleave event
|
||||
*/
|
||||
#onDeactivate(event) {
|
||||
if ( event.target !== (this.element ?? this.#pending) ) return;
|
||||
const parent = event.target.parentElement.closest("[data-tooltip]");
|
||||
if ( parent ) this.activate(parent);
|
||||
else this.#startDeactivation();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Start the deactivation process.
|
||||
*/
|
||||
#startDeactivation() {
|
||||
if ( this.#deactivationTimeout ) return;
|
||||
|
||||
// Clear any existing activation workflow
|
||||
this.clearPending();
|
||||
|
||||
// Delay deactivation to confirm whether some new element is now pending
|
||||
this.#deactivationTimeout = window.setTimeout(() => {
|
||||
this.#deactivationTimeout = null;
|
||||
if ( !this.#pending ) this.deactivate();
|
||||
}, this.constructor.TOOLTIP_ACTIVATION_MS);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clear any existing deactivation workflow.
|
||||
*/
|
||||
#clearDeactivation() {
|
||||
window.clearTimeout(this.#deactivationTimeout);
|
||||
this.#deactivationTimeout = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate the tooltip for a hovered HTML element which defines a tooltip localization key.
|
||||
* @param {HTMLElement} element The HTML element being hovered.
|
||||
* @param {object} [options={}] Additional options which can override tooltip behavior.
|
||||
* @param {string} [options.text] Explicit tooltip text to display. If this is not provided the tooltip text is
|
||||
* acquired from the elements data-tooltip attribute. This text will be
|
||||
* automatically localized
|
||||
* @param {TooltipManager.TOOLTIP_DIRECTIONS} [options.direction] An explicit tooltip expansion direction. If this
|
||||
* is not provided the direction is acquired from the data-tooltip-direction
|
||||
* attribute of the element or one of its parents.
|
||||
* @param {string} [options.cssClass] An optional, space-separated list of CSS classes to apply to the activated
|
||||
* tooltip. If this is not provided, the CSS classes are acquired from the
|
||||
* data-tooltip-class attribute of the element or one of its parents.
|
||||
* @param {boolean} [options.locked] An optional boolean to lock the tooltip after creation. Defaults to false.
|
||||
* @param {HTMLElement} [options.content] Explicit HTML content to inject into the tooltip rather than using tooltip
|
||||
* text.
|
||||
*/
|
||||
activate(element, {text, direction, cssClass, locked=false, content}={}) {
|
||||
if ( text && content ) throw new Error("Cannot provide both text and content options to TooltipManager#activate.");
|
||||
// Deactivate currently active element
|
||||
this.deactivate();
|
||||
// Check if the element still exists in the DOM.
|
||||
if ( !document.body.contains(element) ) return;
|
||||
// Mark the new element as active
|
||||
this.#active = true;
|
||||
this.element = element;
|
||||
element.setAttribute("aria-describedby", "tooltip");
|
||||
if ( content ) {
|
||||
this.tooltip.innerHTML = ""; // Clear existing content.
|
||||
this.tooltip.appendChild(content);
|
||||
}
|
||||
else this.tooltip.innerHTML = text || game.i18n.localize(element.dataset.tooltip);
|
||||
|
||||
// Activate display of the tooltip
|
||||
this.tooltip.removeAttribute("class");
|
||||
this.tooltip.classList.add("active");
|
||||
cssClass ??= element.closest("[data-tooltip-class]")?.dataset.tooltipClass;
|
||||
if ( cssClass ) this.tooltip.classList.add(...cssClass.split(" "));
|
||||
|
||||
// Set tooltip position
|
||||
direction ??= element.closest("[data-tooltip-direction]")?.dataset.tooltipDirection;
|
||||
if ( !direction ) direction = this._determineDirection();
|
||||
this._setAnchor(direction);
|
||||
|
||||
if ( locked || element.dataset.hasOwnProperty("locked") ) this.lockTooltip();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Deactivate the tooltip from a previously hovered HTML element.
|
||||
*/
|
||||
deactivate() {
|
||||
// Deactivate display of the tooltip
|
||||
this.#active = false;
|
||||
this.tooltip.classList.remove("active");
|
||||
|
||||
// Clear any existing (de)activation workflow
|
||||
this.clearPending();
|
||||
this.#clearDeactivation();
|
||||
|
||||
// Update the tooltipped element
|
||||
if ( !this.element ) return;
|
||||
this.element.removeAttribute("aria-describedby");
|
||||
this.element = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clear any pending activation workflow.
|
||||
* @internal
|
||||
*/
|
||||
clearPending() {
|
||||
window.clearTimeout(this.#activationTimeout);
|
||||
this.#pending = this.#activationTimeout = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Lock the current tooltip.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
lockTooltip() {
|
||||
const clone = this.tooltip.cloneNode(false);
|
||||
// Steal the content from the original tooltip rather than cloning it, so that listeners are preserved.
|
||||
while ( this.tooltip.firstChild ) clone.appendChild(this.tooltip.firstChild);
|
||||
clone.removeAttribute("id");
|
||||
clone.classList.add("locked-tooltip", "active");
|
||||
document.body.appendChild(clone);
|
||||
this.deactivate();
|
||||
clone.addEventListener("contextmenu", this._onLockedTooltipDismiss.bind(this));
|
||||
this.#locked.elements.add(clone);
|
||||
|
||||
// If the tooltip's contents were injected via setting innerHTML, then immediately requesting the bounding box will
|
||||
// return incorrect values as the browser has not had a chance to reflow yet. For that reason we defer computing the
|
||||
// bounding box until the next frame.
|
||||
requestAnimationFrame(() => this.#computeLockedBoundingBox());
|
||||
return clone;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a request to lock the current tooltip.
|
||||
* @param {MouseEvent} event The click event.
|
||||
* @protected
|
||||
*/
|
||||
_onLockTooltip(event) {
|
||||
if ( (event.button !== 1) || !this.#active || Tour.tourInProgress ) return;
|
||||
event.preventDefault();
|
||||
this.lockTooltip();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle dismissing a locked tooltip.
|
||||
* @param {MouseEvent} event The click event.
|
||||
* @protected
|
||||
*/
|
||||
_onLockedTooltipDismiss(event) {
|
||||
event.preventDefault();
|
||||
const target = event.currentTarget;
|
||||
this.dismissLockedTooltip(target);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Dismiss a given locked tooltip.
|
||||
* @param {HTMLElement} element The locked tooltip to dismiss.
|
||||
*/
|
||||
dismissLockedTooltip(element) {
|
||||
this.#locked.elements.delete(element);
|
||||
element.remove();
|
||||
this.#computeLockedBoundingBox();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Compute the unified bounding box from the set of locked tooltip elements.
|
||||
*/
|
||||
#computeLockedBoundingBox() {
|
||||
let bb = null;
|
||||
for ( const element of this.#locked.elements.values() ) {
|
||||
const {x, y, width, height} = element.getBoundingClientRect();
|
||||
const rect = new PIXI.Rectangle(x, y, width, height);
|
||||
if ( bb ) bb.enlarge(rect);
|
||||
else bb = rect;
|
||||
}
|
||||
this.#locked.boundingBox = bb;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check whether the user is moving away from the locked tooltips and dismiss them if so.
|
||||
* @param {MouseEvent} event The mouse move event.
|
||||
*/
|
||||
#testLockedTooltipProximity(event) {
|
||||
if ( !this.#locked.elements.size ) return;
|
||||
const {clientX: x, clientY: y, movementX, movementY} = event;
|
||||
const buffer = this.#locked.boundingBox?.clone?.().pad(this.constructor.LOCKED_TOOLTIP_BUFFER_PX);
|
||||
|
||||
// If the cursor is close enough to the bounding box, or we have no movement information, do nothing.
|
||||
if ( !buffer || buffer.contains(x, y) || !Number.isFinite(movementX) || !Number.isFinite(movementY) ) return;
|
||||
|
||||
// Otherwise, check if the cursor is moving away from the tooltip, and dismiss it if so.
|
||||
if ( ((movementX > 0) && (x > buffer.right))
|
||||
|| ((movementX < 0) && (x < buffer.x))
|
||||
|| ((movementY > 0) && (y > buffer.bottom))
|
||||
|| ((movementY < 0) && (y < buffer.y)) ) this.dismissLockedTooltips();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Dismiss the set of active locked tooltips.
|
||||
*/
|
||||
dismissLockedTooltips() {
|
||||
for ( const element of this.#locked.elements.values() ) {
|
||||
element.remove();
|
||||
}
|
||||
this.#locked.elements = new Set();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a locked tooltip at the given position.
|
||||
* @param {object} position A position object with coordinates for where the tooltip should be placed
|
||||
* @param {string} position.top Explicit top position for the tooltip
|
||||
* @param {string} position.right Explicit right position for the tooltip
|
||||
* @param {string} position.bottom Explicit bottom position for the tooltip
|
||||
* @param {string} position.left Explicit left position for the tooltip
|
||||
* @param {string} text Explicit tooltip text or HTML to display.
|
||||
* @param {object} [options={}] Additional options which can override tooltip behavior.
|
||||
* @param {array} [options.cssClass] An optional, space-separated list of CSS classes to apply to the activated
|
||||
* tooltip.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
createLockedTooltip(position, text, {cssClass}={}) {
|
||||
this.#clearDeactivation();
|
||||
this.tooltip.innerHTML = text;
|
||||
this.tooltip.style.top = position.top || "";
|
||||
this.tooltip.style.right = position.right || "";
|
||||
this.tooltip.style.bottom = position.bottom || "";
|
||||
this.tooltip.style.left = position.left || "";
|
||||
|
||||
const clone = this.lockTooltip();
|
||||
if ( cssClass ) clone.classList.add(...cssClass.split(" "));
|
||||
return clone;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* If an explicit tooltip expansion direction was not specified, figure out a valid direction based on the bounds
|
||||
* of the target element and the screen.
|
||||
* @protected
|
||||
*/
|
||||
_determineDirection() {
|
||||
const pos = this.element.getBoundingClientRect();
|
||||
const dirs = this.constructor.TOOLTIP_DIRECTIONS;
|
||||
return dirs[pos.y + this.tooltip.offsetHeight > window.innerHeight ? "UP" : "DOWN"];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set tooltip position relative to an HTML element using an explicitly provided data-tooltip-direction.
|
||||
* @param {TooltipManager.TOOLTIP_DIRECTIONS} direction The tooltip expansion direction specified by the element
|
||||
* or a parent element.
|
||||
* @protected
|
||||
*/
|
||||
_setAnchor(direction) {
|
||||
const directions = this.constructor.TOOLTIP_DIRECTIONS;
|
||||
const pad = this.constructor.TOOLTIP_MARGIN_PX;
|
||||
const pos = this.element.getBoundingClientRect();
|
||||
let style = {};
|
||||
switch ( direction ) {
|
||||
case directions.DOWN:
|
||||
style.textAlign = "center";
|
||||
style.left = pos.left - (this.tooltip.offsetWidth / 2) + (pos.width / 2);
|
||||
style.top = pos.bottom + pad;
|
||||
break;
|
||||
case directions.LEFT:
|
||||
style.textAlign = "left";
|
||||
style.right = window.innerWidth - pos.left + pad;
|
||||
style.top = pos.top + (pos.height / 2) - (this.tooltip.offsetHeight / 2);
|
||||
break;
|
||||
case directions.RIGHT:
|
||||
style.textAlign = "right";
|
||||
style.left = pos.right + pad;
|
||||
style.top = pos.top + (pos.height / 2) - (this.tooltip.offsetHeight / 2);
|
||||
break;
|
||||
case directions.UP:
|
||||
style.textAlign = "center";
|
||||
style.left = pos.left - (this.tooltip.offsetWidth / 2) + (pos.width / 2);
|
||||
style.bottom = window.innerHeight - pos.top + pad;
|
||||
break;
|
||||
case directions.CENTER:
|
||||
style.textAlign = "center";
|
||||
style.left = pos.left - (this.tooltip.offsetWidth / 2) + (pos.width / 2);
|
||||
style.top = pos.top + (pos.height / 2) - (this.tooltip.offsetHeight / 2);
|
||||
break;
|
||||
}
|
||||
return this._setStyle(style);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply inline styling rules to the tooltip for positioning and text alignment.
|
||||
* @param {object} [position={}] An object of positioning data, supporting top, right, bottom, left, and textAlign
|
||||
* @protected
|
||||
*/
|
||||
_setStyle(position={}) {
|
||||
const pad = this.constructor.TOOLTIP_MARGIN_PX;
|
||||
position = {top: null, right: null, bottom: null, left: null, textAlign: "left", ...position};
|
||||
const style = this.tooltip.style;
|
||||
|
||||
// Left or Right
|
||||
const maxW = window.innerWidth - this.tooltip.offsetWidth;
|
||||
if ( position.left ) position.left = Math.clamp(position.left, pad, maxW - pad);
|
||||
if ( position.right ) position.right = Math.clamp(position.right, pad, maxW - pad);
|
||||
|
||||
// Top or Bottom
|
||||
const maxH = window.innerHeight - this.tooltip.offsetHeight;
|
||||
if ( position.top ) position.top = Math.clamp(position.top, pad, maxH - pad);
|
||||
if ( position.bottom ) position.bottom = Math.clamp(position.bottom, pad, maxH - pad);
|
||||
|
||||
// Assign styles
|
||||
for ( let k of ["top", "right", "bottom", "left"] ) {
|
||||
const v = position[k];
|
||||
style[k] = v ? `${v}px` : null;
|
||||
}
|
||||
|
||||
this.tooltip.classList.remove(...["center", "left", "right"].map(dir => `text-${dir}`));
|
||||
this.tooltip.classList.add(`text-${position.textAlign}`);
|
||||
}
|
||||
}
|
||||
555
resources/app/client/core/tour.js
Normal file
555
resources/app/client/core/tour.js
Normal file
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* @typedef {Object} TourStep A step in a Tour
|
||||
* @property {string} id A machine-friendly id of the Tour Step
|
||||
* @property {string} title The title of the step, displayed in the tooltip header
|
||||
* @property {string} content Raw HTML content displayed during the step
|
||||
* @property {string} [selector] A DOM selector which denotes an element to highlight during this step.
|
||||
* If omitted, the step is displayed in the center of the screen.
|
||||
* @property {TooltipManager.TOOLTIP_DIRECTIONS} [tooltipDirection] How the tooltip for the step should be displayed
|
||||
* relative to the target element. If omitted, the best direction will be attempted to be auto-selected.
|
||||
* @property {boolean} [restricted] Whether the Step is restricted to the GM only. Defaults to false.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TourConfig Tour configuration data
|
||||
* @property {string} namespace The namespace this Tour belongs to. Typically, the name of the package which
|
||||
* implements the tour should be used
|
||||
* @property {string} id A machine-friendly id of the Tour, must be unique within the provided namespace
|
||||
* @property {string} title A human-readable name for this Tour. Localized.
|
||||
* @property {TourStep[]} steps The list of Tour Steps
|
||||
* @property {string} [description] A human-readable description of this Tour. Localized.
|
||||
* @property {object} [localization] A map of localizations for the Tour that should be merged into the default localizations
|
||||
* @property {boolean} [restricted] Whether the Tour is restricted to the GM only. Defaults to false.
|
||||
* @property {boolean} [display] Whether the Tour should be displayed in the Manage Tours UI. Defaults to false.
|
||||
* @property {boolean} [canBeResumed] Whether the Tour can be resumed or if it always needs to start from the beginning. Defaults to false.
|
||||
* @property {string[]} [suggestedNextTours] A list of namespaced Tours that might be suggested to the user when this Tour is completed.
|
||||
* The first non-completed Tour in the array will be recommended.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A Tour that shows a series of guided steps.
|
||||
* @param {TourConfig} config The configuration of the Tour
|
||||
* @tutorial tours
|
||||
*/
|
||||
class Tour {
|
||||
constructor(config, {id, namespace}={}) {
|
||||
this.config = foundry.utils.deepClone(config);
|
||||
if ( this.config.localization ) foundry.utils.mergeObject(game.i18n._fallback, this.config.localization);
|
||||
this.#id = id ?? config.id;
|
||||
this.#namespace = namespace ?? config.namespace;
|
||||
this.#stepIndex = this._loadProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* A singleton reference which tracks the currently active Tour.
|
||||
* @type {Tour|null}
|
||||
*/
|
||||
static #activeTour = null;
|
||||
|
||||
/**
|
||||
* @enum {string}
|
||||
*/
|
||||
static STATUS = {
|
||||
UNSTARTED: "unstarted",
|
||||
IN_PROGRESS: "in-progress",
|
||||
COMPLETED: "completed"
|
||||
};
|
||||
|
||||
/**
|
||||
* Indicates if a Tour is currently in progress.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static get tourInProgress() {
|
||||
return !!Tour.#activeTour;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active Tour, if any
|
||||
* @returns {Tour|null}
|
||||
*/
|
||||
static get activeTour() {
|
||||
return Tour.#activeTour;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a movement action to either progress or regress the Tour.
|
||||
* @param @param {string[]} movementDirections The Directions being moved in
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static onMovementAction(movementDirections) {
|
||||
if ( (movementDirections.includes(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT))
|
||||
&& (Tour.activeTour.hasNext) ) {
|
||||
Tour.activeTour.next();
|
||||
return true;
|
||||
}
|
||||
else if ( (movementDirections.includes(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT))
|
||||
&& (Tour.activeTour.hasPrevious) ) {
|
||||
Tour.activeTour.previous();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration of the tour. This object is cloned to avoid mutating the original configuration.
|
||||
* @type {TourConfig}
|
||||
*/
|
||||
config;
|
||||
|
||||
/**
|
||||
* The HTMLElement which is the focus of the current tour step.
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
targetElement;
|
||||
|
||||
/**
|
||||
* The HTMLElement that fades out the rest of the screen
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
fadeElement;
|
||||
|
||||
/**
|
||||
* The HTMLElement that blocks input while a Tour is active
|
||||
*/
|
||||
overlayElement;
|
||||
|
||||
/**
|
||||
* Padding around a Highlighted Element
|
||||
* @type {number}
|
||||
*/
|
||||
static HIGHLIGHT_PADDING = 10;
|
||||
|
||||
/**
|
||||
* The unique identifier of the tour.
|
||||
* @type {string}
|
||||
*/
|
||||
get id() {
|
||||
return this.#id;
|
||||
}
|
||||
|
||||
set id(value) {
|
||||
if ( this.#id ) throw new Error("The Tour has already been assigned an ID");
|
||||
this.#id = value;
|
||||
}
|
||||
|
||||
#id;
|
||||
|
||||
/**
|
||||
* The human-readable title for the tour.
|
||||
* @type {string}
|
||||
*/
|
||||
get title() {
|
||||
return game.i18n.localize(this.config.title);
|
||||
}
|
||||
|
||||
/**
|
||||
* The human-readable description of the tour.
|
||||
* @type {string}
|
||||
*/
|
||||
get description() {
|
||||
return game.i18n.localize(this.config.description);
|
||||
}
|
||||
|
||||
/**
|
||||
* The package namespace for the tour.
|
||||
* @type {string}
|
||||
*/
|
||||
get namespace() {
|
||||
return this.#namespace;
|
||||
}
|
||||
|
||||
set namespace(value) {
|
||||
if ( this.#namespace ) throw new Error("The Tour has already been assigned a namespace");
|
||||
this.#namespace = value;
|
||||
}
|
||||
|
||||
#namespace;
|
||||
|
||||
/**
|
||||
* The key the Tour is stored under in game.tours, of the form `${namespace}.${id}`
|
||||
* @returns {string}
|
||||
*/
|
||||
get key() {
|
||||
return `${this.#namespace}.${this.#id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration of tour steps
|
||||
* @type {TourStep[]}
|
||||
*/
|
||||
get steps() {
|
||||
return this.config.steps.filter(step => !step.restricted || game.user.isGM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current Step, or null if the tour has not yet started.
|
||||
* @type {TourStep|null}
|
||||
*/
|
||||
get currentStep() {
|
||||
return this.steps[this.#stepIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The index of the current step; -1 if the tour has not yet started, or null if the tour is finished.
|
||||
* @type {number|null}
|
||||
*/
|
||||
get stepIndex() {
|
||||
return this.#stepIndex;
|
||||
}
|
||||
|
||||
/** @private */
|
||||
#stepIndex = -1;
|
||||
|
||||
/**
|
||||
* Returns True if there is a next TourStep
|
||||
* @type {boolean}
|
||||
*/
|
||||
get hasNext() {
|
||||
return this.#stepIndex < this.steps.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns True if there is a previous TourStep
|
||||
* @type {boolean}
|
||||
*/
|
||||
get hasPrevious() {
|
||||
return this.#stepIndex > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether this Tour is currently eligible to be started?
|
||||
* This is useful for tours which can only be used in certain circumstances, like if the canvas is active.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get canStart() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current status of the Tour
|
||||
* @returns {STATUS}
|
||||
*/
|
||||
get status() {
|
||||
if ( this.#stepIndex === -1 ) return Tour.STATUS.UNSTARTED;
|
||||
else if (this.#stepIndex === this.steps.length) return Tour.STATUS.COMPLETED;
|
||||
else return Tour.STATUS.IN_PROGRESS;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Tour Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Advance the tour to a completed state.
|
||||
*/
|
||||
async complete() {
|
||||
return this.progress(this.steps.length);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Exit the tour at the current step.
|
||||
*/
|
||||
exit() {
|
||||
if ( this.currentStep ) this._postStep();
|
||||
Tour.#activeTour = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reset the Tour to an un-started state.
|
||||
*/
|
||||
async reset() {
|
||||
return this.progress(-1);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Start the Tour at its current step, or at the beginning if the tour has not yet been started.
|
||||
*/
|
||||
async start() {
|
||||
game.tooltip.clearPending();
|
||||
switch ( this.status ) {
|
||||
case Tour.STATUS.IN_PROGRESS:
|
||||
return this.progress((this.config.canBeResumed && this.hasPrevious) ? this.#stepIndex : 0);
|
||||
case Tour.STATUS.UNSTARTED:
|
||||
case Tour.STATUS.COMPLETED:
|
||||
return this.progress(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Progress the Tour to the next step.
|
||||
*/
|
||||
async next() {
|
||||
if ( this.status === Tour.STATUS.COMPLETED ) {
|
||||
throw new Error(`Tour ${this.id} has already been completed`);
|
||||
}
|
||||
if ( !this.hasNext ) return this.complete();
|
||||
return this.progress(this.#stepIndex + 1);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Rewind the Tour to the previous step.
|
||||
*/
|
||||
async previous() {
|
||||
if ( !this.hasPrevious ) return;
|
||||
return this.progress(this.#stepIndex - 1);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Progresses to a given Step
|
||||
* @param {number} stepIndex The step to progress to
|
||||
*/
|
||||
async progress(stepIndex) {
|
||||
|
||||
// Ensure we are provided a valid tour step
|
||||
if ( !Number.between(stepIndex, -1, this.steps.length) ) {
|
||||
throw new Error(`Step index ${stepIndex} is not valid for Tour ${this.id} with ${this.steps.length} steps.`);
|
||||
}
|
||||
|
||||
// Ensure that only one Tour is active at a given time
|
||||
if ( Tour.#activeTour && (Tour.#activeTour !== this) ) {
|
||||
if ( (stepIndex !== -1) && (stepIndex !== this.steps.length) ) throw new Error(`You cannot begin the ${this.title} Tour because the `
|
||||
+ `${Tour.#activeTour.title} Tour is already in progress`);
|
||||
else Tour.#activeTour = null;
|
||||
}
|
||||
else Tour.#activeTour = this;
|
||||
|
||||
// Tear down the prior step
|
||||
if ( stepIndex > 0 ) {
|
||||
await this._postStep();
|
||||
console.debug(`Tour [${this.namespace}.${this.id}] | Completed step ${this.#stepIndex+1} of ${this.steps.length}`);
|
||||
}
|
||||
|
||||
// Change the step and save progress
|
||||
this.#stepIndex = stepIndex;
|
||||
this._saveProgress();
|
||||
|
||||
// If the TourManager is active, update the UI
|
||||
const tourManager = Object.values(ui.windows).find(x => x instanceof ToursManagement);
|
||||
if ( tourManager ) {
|
||||
tourManager._cachedData = null;
|
||||
tourManager._render(true);
|
||||
}
|
||||
|
||||
if ( this.status === Tour.STATUS.UNSTARTED ) return Tour.#activeTour = null;
|
||||
if ( this.status === Tour.STATUS.COMPLETED ) {
|
||||
Tour.#activeTour = null;
|
||||
const suggestedTour = game.tours.get((this.config.suggestedNextTours || []).find(tourId => {
|
||||
const tour = game.tours.get(tourId);
|
||||
return tour && (tour.status !== Tour.STATUS.COMPLETED);
|
||||
}));
|
||||
|
||||
if ( !suggestedTour ) return;
|
||||
return Dialog.confirm({
|
||||
title: game.i18n.localize("TOURS.SuggestedTitle"),
|
||||
content: game.i18n.format("TOURS.SuggestedDescription", { currentTitle: this.title, nextTitle: suggestedTour.title }),
|
||||
yes: () => suggestedTour.start(),
|
||||
defaultYes: true
|
||||
});
|
||||
}
|
||||
|
||||
// Set up the next step
|
||||
await this._preStep();
|
||||
|
||||
// Identify the target HTMLElement
|
||||
this.targetElement = null;
|
||||
const step = this.currentStep;
|
||||
if ( step.selector ) {
|
||||
this.targetElement = this._getTargetElement(step.selector);
|
||||
if ( !this.targetElement ) console.warn(`Tour [${this.id}] target element "${step.selector}" was not found`);
|
||||
}
|
||||
|
||||
// Display the step
|
||||
try {
|
||||
await this._renderStep();
|
||||
}
|
||||
catch(e) {
|
||||
this.exit();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Query the DOM for the target element using the provided selector
|
||||
* @param {string} selector A CSS selector
|
||||
* @returns {Element|null} The target element, or null if not found
|
||||
* @protected
|
||||
*/
|
||||
_getTargetElement(selector) {
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Creates and returns a Tour by loading a JSON file
|
||||
* @param {string} filepath The path to the JSON file
|
||||
* @returns {Promise<Tour>}
|
||||
*/
|
||||
static async fromJSON(filepath) {
|
||||
const json = await foundry.utils.fetchJsonWithTimeout(foundry.utils.getRoute(filepath, {prefix: ROUTE_PREFIX}));
|
||||
return new this(json);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set-up operations performed before a step is shown.
|
||||
* @abstract
|
||||
* @protected
|
||||
*/
|
||||
async _preStep() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clean-up operations performed after a step is completed.
|
||||
* @abstract
|
||||
* @protected
|
||||
*/
|
||||
async _postStep() {
|
||||
if ( this.currentStep && !this.currentStep.selector ) this.targetElement?.remove();
|
||||
else game.tooltip.deactivate();
|
||||
if ( this.fadeElement ) {
|
||||
this.fadeElement.remove();
|
||||
this.fadeElement = undefined;
|
||||
}
|
||||
if ( this.overlayElement ) this.overlayElement = this.overlayElement.remove();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Renders the current Step of the Tour
|
||||
* @protected
|
||||
*/
|
||||
async _renderStep() {
|
||||
const step = this.currentStep;
|
||||
const data = {
|
||||
title: game.i18n.localize(step.title),
|
||||
content: game.i18n.localize(step.content).split("\n"),
|
||||
step: this.#stepIndex + 1,
|
||||
totalSteps: this.steps.length,
|
||||
hasNext: this.hasNext,
|
||||
hasPrevious: this.hasPrevious
|
||||
};
|
||||
const content = await renderTemplate("templates/apps/tour-step.html", data);
|
||||
|
||||
if ( step.selector ) {
|
||||
if ( !this.targetElement ) {
|
||||
throw new Error(`The expected targetElement ${step.selector} does not exist`);
|
||||
}
|
||||
this.targetElement.scrollIntoView();
|
||||
game.tooltip.activate(this.targetElement, {text: content, cssClass: "tour", direction: step.tooltipDirection});
|
||||
}
|
||||
else {
|
||||
// Display a general mid-screen Step
|
||||
const wrapper = document.createElement("aside");
|
||||
wrapper.innerHTML = content;
|
||||
wrapper.classList.add("tour-center-step");
|
||||
wrapper.classList.add("tour");
|
||||
document.body.appendChild(wrapper);
|
||||
this.targetElement = wrapper;
|
||||
}
|
||||
|
||||
// Fade out rest of screen
|
||||
this.fadeElement = document.createElement("div");
|
||||
this.fadeElement.classList.add("tour-fadeout");
|
||||
const targetBoundingRect = this.targetElement.getBoundingClientRect();
|
||||
|
||||
this.fadeElement.style.width = `${targetBoundingRect.width + (step.selector ? Tour.HIGHLIGHT_PADDING : 0)}px`;
|
||||
this.fadeElement.style.height = `${targetBoundingRect.height + (step.selector ? Tour.HIGHLIGHT_PADDING : 0)}px`;
|
||||
this.fadeElement.style.top = `${targetBoundingRect.top - ((step.selector ? Tour.HIGHLIGHT_PADDING : 0) / 2)}px`;
|
||||
this.fadeElement.style.left = `${targetBoundingRect.left - ((step.selector ? Tour.HIGHLIGHT_PADDING : 0) / 2)}px`;
|
||||
document.body.appendChild(this.fadeElement);
|
||||
|
||||
// Add Overlay to block input
|
||||
this.overlayElement = document.createElement("div");
|
||||
this.overlayElement.classList.add("tour-overlay");
|
||||
document.body.appendChild(this.overlayElement);
|
||||
|
||||
// Activate Listeners
|
||||
const buttons = step.selector ? game.tooltip.tooltip.querySelectorAll(".step-button")
|
||||
: this.targetElement.querySelectorAll(".step-button");
|
||||
for ( let button of buttons ) {
|
||||
button.addEventListener("click", event => this._onButtonClick(event, buttons));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle Tour Button clicks
|
||||
* @param {Event} event A click event
|
||||
* @param {HTMLElement[]} buttons The step buttons
|
||||
* @private
|
||||
*/
|
||||
_onButtonClick(event, buttons) {
|
||||
event.preventDefault();
|
||||
|
||||
// Disable all the buttons to prevent double-clicks
|
||||
for ( let button of buttons ) {
|
||||
button.classList.add("disabled");
|
||||
}
|
||||
|
||||
// Handle action
|
||||
const action = event.currentTarget.dataset.action;
|
||||
switch ( action ) {
|
||||
case "exit": return this.exit();
|
||||
case "previous": return this.previous();
|
||||
case "next": return this.next();
|
||||
default: throw new Error(`Unexpected Tour button action - ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Saves the current progress of the Tour to a world setting
|
||||
* @private
|
||||
*/
|
||||
_saveProgress() {
|
||||
let progress = game.settings.get("core", "tourProgress");
|
||||
if ( !(this.namespace in progress) ) progress[this.namespace] = {};
|
||||
progress[this.namespace][this.id] = this.#stepIndex;
|
||||
game.settings.set("core", "tourProgress", progress);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the User's current progress of this Tour
|
||||
* @returns {null|number}
|
||||
* @private
|
||||
*/
|
||||
_loadProgress() {
|
||||
let progress = game.settings.get("core", "tourProgress");
|
||||
return progress?.[this.namespace]?.[this.id] ?? -1;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reloads the Tour's current step from the saved progress
|
||||
* @internal
|
||||
*/
|
||||
_reloadProgress() {
|
||||
this.#stepIndex = this._loadProgress();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user