Initial
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
46
resources/app/client/core/tours.js
Normal file
46
resources/app/client/core/tours.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* A singleton Tour Collection class responsible for registering and activating Tours, accessible as game.tours
|
||||
* @see {Game#tours}
|
||||
* @extends Map
|
||||
*/
|
||||
class Tours extends foundry.utils.Collection {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
if ( game.tours ) throw new Error("You can only have one TourManager instance");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register a new Tour
|
||||
* @param {string} namespace The namespace of the Tour
|
||||
* @param {string} id The machine-readable id of the Tour
|
||||
* @param {Tour} tour The constructed Tour
|
||||
* @returns {void}
|
||||
*/
|
||||
register(namespace, id, tour) {
|
||||
if ( !namespace || !id ) throw new Error("You must specify both the namespace and id portion of the Tour");
|
||||
if ( !(tour instanceof Tour) ) throw new Error("You must pass in a Tour instance");
|
||||
|
||||
// Set the namespace and id of the tour if not already set.
|
||||
if ( id && !tour.id ) tour.id = id;
|
||||
if ( namespace && !tour.namespace ) tour.namespace = namespace;
|
||||
tour._reloadProgress();
|
||||
|
||||
// Register the Tour if it is not already registered, ensuring the key matches the config
|
||||
if ( this.has(tour.key) ) throw new Error(`Tour "${key}" has already been registered`);
|
||||
this.set(`${namespace}.${id}`, tour);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @override
|
||||
*/
|
||||
set(key, tour) {
|
||||
if ( key !== tour.key ) throw new Error(`The key "${key}" does not match what has been configured for the Tour`);
|
||||
return super.set(key, tour);
|
||||
}
|
||||
}
|
||||
146
resources/app/client/core/utils.js
Normal file
146
resources/app/client/core/utils.js
Normal file
@@ -0,0 +1,146 @@
|
||||
|
||||
/**
|
||||
* Export data content to be saved to a local file
|
||||
* @param {string} data Data content converted to a string
|
||||
* @param {string} type The type of
|
||||
* @param {string} filename The filename of the resulting download
|
||||
*/
|
||||
function saveDataToFile(data, type, filename) {
|
||||
const blob = new Blob([data], {type: type});
|
||||
|
||||
// Create an element to trigger the download
|
||||
let a = document.createElement('a');
|
||||
a.href = window.URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
|
||||
// Dispatch a click event to the element
|
||||
a.dispatchEvent(new MouseEvent("click", {bubbles: true, cancelable: true, view: window}));
|
||||
setTimeout(() => window.URL.revokeObjectURL(a.href), 100);
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
|
||||
/**
|
||||
* Read text data from a user provided File object
|
||||
* @param {File} file A File object
|
||||
* @return {Promise.<String>} A Promise which resolves to the loaded text data
|
||||
*/
|
||||
function readTextFromFile(file) {
|
||||
const reader = new FileReader();
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.onload = ev => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.onerror = ev => {
|
||||
reader.abort();
|
||||
reject();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve a Document by its Universally Unique Identifier (uuid).
|
||||
* @param {string} uuid The uuid of the Document to retrieve.
|
||||
* @param {object} [options] Options to configure how a UUID is resolved.
|
||||
* @param {Document} [options.relative] A Document to resolve relative UUIDs against.
|
||||
* @param {boolean} [options.invalid=false] Allow retrieving an invalid Document.
|
||||
* @returns {Promise<Document|null>} Returns the Document if it could be found, otherwise null.
|
||||
*/
|
||||
async function fromUuid(uuid, options={}) {
|
||||
if ( !uuid ) return null;
|
||||
/** @deprecated since v11 */
|
||||
if ( foundry.utils.getType(options) !== "Object" ) {
|
||||
foundry.utils.logCompatibilityWarning("Passing a relative document as the second parameter to fromUuid is "
|
||||
+ "deprecated. Please pass it within an options object instead.", {since: 11, until: 13});
|
||||
options = {relative: options};
|
||||
}
|
||||
const {relative, invalid=false} = options;
|
||||
let {type, id, primaryId, collection, embedded, doc} = foundry.utils.parseUuid(uuid, {relative});
|
||||
if ( collection instanceof CompendiumCollection ) {
|
||||
if ( type === "Folder" ) return collection.folders.get(id);
|
||||
doc = await collection.getDocument(primaryId ?? id);
|
||||
}
|
||||
else doc = doc ?? collection?.get(primaryId ?? id, {invalid});
|
||||
if ( embedded.length ) doc = _resolveEmbedded(doc, embedded, {invalid});
|
||||
return doc || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve a Document by its Universally Unique Identifier (uuid) synchronously. If the uuid resolves to a compendium
|
||||
* document, that document's index entry will be returned instead.
|
||||
* @param {string} uuid The uuid of the Document to retrieve.
|
||||
* @param {object} [options] Options to configure how a UUID is resolved.
|
||||
* @param {Document} [options.relative] A Document to resolve relative UUIDs against.
|
||||
* @param {boolean} [options.invalid=false] Allow retrieving an invalid Document.
|
||||
* @param {boolean} [options.strict=true] Throw an error if the UUID cannot be resolved synchronously.
|
||||
* @returns {Document|object|null} The Document or its index entry if it resides in a Compendium, otherwise
|
||||
* null.
|
||||
* @throws If the uuid resolves to a Document that cannot be retrieved synchronously, and the strict option is true.
|
||||
*/
|
||||
function fromUuidSync(uuid, options={}) {
|
||||
if ( !uuid ) return null;
|
||||
/** @deprecated since v11 */
|
||||
if ( foundry.utils.getType(options) !== "Object" ) {
|
||||
foundry.utils.logCompatibilityWarning("Passing a relative document as the second parameter to fromUuidSync is "
|
||||
+ "deprecated. Please pass it within an options object instead.", {since: 11, until: 13});
|
||||
options = {relative: options};
|
||||
}
|
||||
const {relative, invalid=false, strict=true} = options;
|
||||
let {type, id, primaryId, collection, embedded, doc} = foundry.utils.parseUuid(uuid, {relative});
|
||||
if ( (collection instanceof CompendiumCollection) && embedded.length ) {
|
||||
if ( !strict ) return null;
|
||||
throw new Error(
|
||||
`fromUuidSync was invoked on UUID '${uuid}' which references an Embedded Document and cannot be retrieved `
|
||||
+ "synchronously.");
|
||||
}
|
||||
|
||||
const baseId = primaryId ?? id;
|
||||
if ( collection instanceof CompendiumCollection ) {
|
||||
if ( type === "Folder" ) return collection.folders.get(id);
|
||||
doc = doc ?? collection.get(baseId, {invalid}) ?? collection.index.get(baseId);
|
||||
if ( doc ) doc.pack = collection.collection;
|
||||
}
|
||||
else {
|
||||
doc = doc ?? collection?.get(baseId, {invalid});
|
||||
if ( embedded.length ) doc = _resolveEmbedded(doc, embedded, {invalid});
|
||||
}
|
||||
return doc || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Resolve a series of embedded document UUID parts against a parent Document.
|
||||
* @param {Document} parent The parent Document.
|
||||
* @param {string[]} parts A series of Embedded Document UUID parts.
|
||||
* @param {object} [options] Additional options to configure Embedded Document resolution.
|
||||
* @param {boolean} [options.invalid=false] Allow retrieving an invalid Embedded Document.
|
||||
* @returns {Document} The resolved Embedded Document.
|
||||
* @private
|
||||
*/
|
||||
function _resolveEmbedded(parent, parts, {invalid=false}={}) {
|
||||
let doc = parent;
|
||||
while ( doc && (parts.length > 1) ) {
|
||||
const [embeddedName, embeddedId] = parts.splice(0, 2);
|
||||
doc = doc.getEmbeddedDocument(embeddedName, embeddedId, {invalid});
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the Document class implementation which is configured for use.
|
||||
* @param {string} documentName The canonical Document name, for example "Actor"
|
||||
* @returns {typeof foundry.abstract.Document} The configured Document class implementation
|
||||
*/
|
||||
function getDocumentClass(documentName) {
|
||||
return CONFIG[documentName]?.documentClass;
|
||||
}
|
||||
275
resources/app/client/core/video.js
Normal file
275
resources/app/client/core/video.js
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* A helper class to provide common functionality for working with HTML5 video objects
|
||||
* A singleton instance of this class is available as ``game.video``
|
||||
*/
|
||||
class VideoHelper {
|
||||
constructor() {
|
||||
if ( game.video instanceof this.constructor ) {
|
||||
throw new Error("You may not re-initialize the singleton VideoHelper. Use game.video instead.");
|
||||
}
|
||||
|
||||
/**
|
||||
* A user gesture must be registered before video playback can begin.
|
||||
* This Set records the video elements which await such a gesture.
|
||||
* @type {Set}
|
||||
*/
|
||||
this.pending = new Set();
|
||||
|
||||
/**
|
||||
* A mapping of base64 video thumbnail images
|
||||
* @type {Map<string,string>}
|
||||
*/
|
||||
this.thumbs = new Map();
|
||||
|
||||
/**
|
||||
* A flag for whether video playback is currently locked by awaiting a user gesture
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.locked = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Store a Promise while the YouTube API is initializing.
|
||||
* @type {Promise}
|
||||
*/
|
||||
#youTubeReady;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The YouTube URL regex.
|
||||
* @type {RegExp}
|
||||
*/
|
||||
#youTubeRegex = /^https:\/\/(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=([^&]+)|(?:embed\/)?([^?]+))/;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the HTML element which provides the source for a loaded texture.
|
||||
* @param {PIXI.Sprite|SpriteMesh} mesh The rendered mesh
|
||||
* @returns {HTMLImageElement|HTMLVideoElement|null} The source HTML element
|
||||
*/
|
||||
getSourceElement(mesh) {
|
||||
if ( !mesh.texture.valid ) return null;
|
||||
return mesh.texture.baseTexture.resource.source;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the video element source corresponding to a Sprite or SpriteMesh.
|
||||
* @param {PIXI.Sprite|SpriteMesh|PIXI.Texture} object The PIXI source
|
||||
* @returns {HTMLVideoElement|null} The source video element or null
|
||||
*/
|
||||
getVideoSource(object) {
|
||||
if ( !object ) return null;
|
||||
const texture = object.texture || object;
|
||||
if ( !texture.valid ) return null;
|
||||
const source = texture.baseTexture.resource.source;
|
||||
return source?.tagName === "VIDEO" ? source : null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clone a video texture so that it can be played independently of the original base texture.
|
||||
* @param {HTMLVideoElement} source The video element source
|
||||
* @returns {Promise<PIXI.Texture>} An unlinked PIXI.Texture which can be played independently
|
||||
*/
|
||||
async cloneTexture(source) {
|
||||
const clone = source.cloneNode(true);
|
||||
const resource = new PIXI.VideoResource(clone, {autoPlay: false});
|
||||
resource.internal = true;
|
||||
await resource.load();
|
||||
return new PIXI.Texture(new PIXI.BaseTexture(resource, {
|
||||
alphaMode: await PIXI.utils.detectVideoAlphaMode()
|
||||
}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check if a source has a video extension.
|
||||
* @param {string} src The source.
|
||||
* @returns {boolean} If the source has a video extension or not.
|
||||
*/
|
||||
static hasVideoExtension(src) {
|
||||
let rgx = new RegExp(`(\\.${Object.keys(CONST.VIDEO_FILE_EXTENSIONS).join("|\\.")})(\\?.*)?`, "i");
|
||||
return rgx.test(src);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Play a single video source
|
||||
* If playback is not yet enabled, add the video to the pending queue
|
||||
* @param {HTMLElement} video The VIDEO element to play
|
||||
* @param {object} [options={}] Additional options for modifying video playback
|
||||
* @param {boolean} [options.playing] Should the video be playing? Otherwise, it will be paused
|
||||
* @param {boolean} [options.loop] Should the video loop?
|
||||
* @param {number} [options.offset] A specific timestamp between 0 and the video duration to begin playback
|
||||
* @param {number} [options.volume] Desired volume level of the video's audio channel (if any)
|
||||
*/
|
||||
async play(video, {playing=true, loop=true, offset, volume}={}) {
|
||||
|
||||
// Video offset time and looping
|
||||
video.loop = loop;
|
||||
offset ??= video.currentTime;
|
||||
|
||||
// Playback volume and muted state
|
||||
if ( volume !== undefined ) video.volume = volume;
|
||||
|
||||
// Pause playback
|
||||
if ( !playing ) return video.pause();
|
||||
|
||||
// Wait for user gesture
|
||||
if ( this.locked ) return this.pending.add([video, offset]);
|
||||
|
||||
// Begin playback
|
||||
video.currentTime = Math.clamp(offset, 0, video.duration);
|
||||
return video.play();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Stop a single video source
|
||||
* @param {HTMLElement} video The VIDEO element to stop
|
||||
*/
|
||||
stop(video) {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register an event listener to await the first mousemove gesture and begin playback once observed
|
||||
* A user interaction must involve a mouse click or keypress.
|
||||
* Listen for any of these events, and handle the first observed gesture.
|
||||
*/
|
||||
awaitFirstGesture() {
|
||||
if ( !this.locked ) return;
|
||||
const interactions = ["contextmenu", "auxclick", "pointerdown", "pointerup", "keydown"];
|
||||
interactions.forEach(event => document.addEventListener(event, this._onFirstGesture.bind(this), {once: true}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the first observed user gesture
|
||||
* We need a slight delay because unfortunately Chrome is stupid and doesn't always acknowledge the gesture fast enough.
|
||||
* @param {Event} event The mouse-move event which enables playback
|
||||
*/
|
||||
_onFirstGesture(event) {
|
||||
this.locked = false;
|
||||
if ( !this.pending.size ) return;
|
||||
console.log(`${vtt} | Activating pending video playback with user gesture.`);
|
||||
for ( const [video, offset] of Array.from(this.pending) ) {
|
||||
this.play(video, {offset, loop: video.loop});
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create and cache a static thumbnail to use for the video.
|
||||
* The thumbnail is cached using the video file path or URL.
|
||||
* @param {string} src The source video URL
|
||||
* @param {object} options Thumbnail creation options, including width and height
|
||||
* @returns {Promise<string>} The created and cached base64 thumbnail image, or a placeholder image if the canvas is
|
||||
* disabled and no thumbnail can be generated.
|
||||
*/
|
||||
async createThumbnail(src, options) {
|
||||
if ( game.settings.get("core", "noCanvas") ) return "icons/svg/video.svg";
|
||||
const t = await ImageHelper.createThumbnail(src, options);
|
||||
this.thumbs.set(src, t.thumb);
|
||||
return t.thumb;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* YouTube API */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Lazily-load the YouTube API and retrieve a Player instance for a given iframe.
|
||||
* @param {string} id The iframe ID.
|
||||
* @param {object} config A player config object. See {@link https://developers.google.com/youtube/iframe_api_reference} for reference.
|
||||
* @returns {Promise<YT.Player>}
|
||||
*/
|
||||
async getYouTubePlayer(id, config={}) {
|
||||
this.#youTubeReady ??= this.#injectYouTubeAPI();
|
||||
await this.#youTubeReady;
|
||||
return new Promise(resolve => new YT.Player(id, foundry.utils.mergeObject(config, {
|
||||
events: {
|
||||
onReady: event => resolve(event.target)
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve a YouTube video ID from a URL.
|
||||
* @param {string} url The URL.
|
||||
* @returns {string}
|
||||
*/
|
||||
getYouTubeId(url) {
|
||||
const [, id1, id2] = url?.match(this.#youTubeRegex) || [];
|
||||
return id1 || id2 || "";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Take a URL to a YouTube video and convert it into a URL suitable for embedding in a YouTube iframe.
|
||||
* @param {string} url The URL to convert.
|
||||
* @param {object} vars YouTube player parameters.
|
||||
* @returns {string} The YouTube embed URL.
|
||||
*/
|
||||
getYouTubeEmbedURL(url, vars={}) {
|
||||
const videoId = this.getYouTubeId(url);
|
||||
if ( !videoId ) return "";
|
||||
const embed = new URL(`https://www.youtube.com/embed/${videoId}`);
|
||||
embed.searchParams.append("enablejsapi", "1");
|
||||
Object.entries(vars).forEach(([k, v]) => embed.searchParams.append(k, v));
|
||||
// To loop a video with iframe parameters, we must additionally supply the playlist parameter that points to the
|
||||
// same video: https://developers.google.com/youtube/player_parameters#Parameters
|
||||
if ( vars.loop ) embed.searchParams.append("playlist", videoId);
|
||||
return embed.href;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test a URL to see if it points to a YouTube video.
|
||||
* @param {string} url The URL to test.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isYouTubeURL(url="") {
|
||||
return this.#youTubeRegex.test(url);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Inject the YouTube API into the page.
|
||||
* @returns {Promise} A Promise that resolves when the API has initialized.
|
||||
*/
|
||||
#injectYouTubeAPI() {
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://www.youtube.com/iframe_api";
|
||||
document.head.appendChild(script);
|
||||
return new Promise(resolve => {
|
||||
window.onYouTubeIframeAPIReady = () => {
|
||||
delete window.onYouTubeIframeAPIReady;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
211
resources/app/client/core/workers.js
Normal file
211
resources/app/client/core/workers.js
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @typedef {Record<string, any>} WorkerTask
|
||||
* @property {number} [taskId] An incrementing task ID used to reference task progress
|
||||
* @property {WorkerManager.WORKER_TASK_ACTIONS} action The task action being performed, from WorkerManager.WORKER_TASK_ACTIONS
|
||||
*/
|
||||
|
||||
/**
|
||||
* An asynchronous web Worker which can load user-defined functions and await execution using Promises.
|
||||
* @param {string} name The worker name to be initialized
|
||||
* @param {object} [options={}] Worker initialization options
|
||||
* @param {boolean} [options.debug=false] Should the worker run in debug mode?
|
||||
* @param {boolean} [options.loadPrimitives=false] Should the worker automatically load the primitives library?
|
||||
* @param {string[]} [options.scripts] Should the worker operates in script modes? Optional scripts.
|
||||
*/
|
||||
class AsyncWorker extends Worker {
|
||||
constructor(name, {debug=false, loadPrimitives=false, scripts}={}) {
|
||||
super(AsyncWorker.WORKER_HARNESS_JS);
|
||||
this.name = name;
|
||||
this.addEventListener("message", this.#onMessage.bind(this));
|
||||
this.addEventListener("error", this.#onError.bind(this));
|
||||
|
||||
this.#ready = this.#dispatchTask({
|
||||
action: WorkerManager.WORKER_TASK_ACTIONS.INIT,
|
||||
workerName: name,
|
||||
debug,
|
||||
loadPrimitives,
|
||||
scripts
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A path reference to the JavaScript file which provides companion worker-side functionality.
|
||||
* @type {string}
|
||||
*/
|
||||
static WORKER_HARNESS_JS = "scripts/worker.js";
|
||||
|
||||
/**
|
||||
* A queue of active tasks that this Worker is executing.
|
||||
* @type {Map<number, {resolve: (result: any) => void, reject: (error: Error) => void}>}
|
||||
*/
|
||||
#tasks = new Map();
|
||||
|
||||
/**
|
||||
* An auto-incrementing task index.
|
||||
* @type {number}
|
||||
*/
|
||||
#taskIndex = 0;
|
||||
|
||||
/**
|
||||
* A Promise which resolves once the Worker is ready to accept tasks
|
||||
* @type {Promise}
|
||||
*/
|
||||
get ready() {
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
#ready;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Task Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Load a function onto a given Worker.
|
||||
* The function must be a pure function with no external dependencies or requirements on global scope.
|
||||
* @param {string} functionName The name of the function to load
|
||||
* @param {Function} functionRef A reference to the function that should be loaded
|
||||
* @returns {Promise<unknown>} A Promise which resolves once the Worker has loaded the function.
|
||||
*/
|
||||
async loadFunction(functionName, functionRef) {
|
||||
return this.#dispatchTask({
|
||||
action: WorkerManager.WORKER_TASK_ACTIONS.LOAD,
|
||||
functionName,
|
||||
functionBody: functionRef.toString()
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Execute a task on a specific Worker.
|
||||
* @param {string} functionName The named function to execute on the worker. This function must first have been
|
||||
* loaded.
|
||||
* @param {Array<*>} [args] An array of parameters with which to call the requested function
|
||||
* @param {Array<*>} [transfer] An array of transferable objects which are transferred to the worker thread.
|
||||
* See https://developer.mozilla.org/en-US/docs/Glossary/Transferable_objects
|
||||
* @returns {Promise<unknown>} A Promise which resolves with the returned result of the function once complete.
|
||||
*/
|
||||
async executeFunction(functionName, args=[], transfer=[]) {
|
||||
const action = WorkerManager.WORKER_TASK_ACTIONS.EXECUTE;
|
||||
return this.#dispatchTask({action, functionName, args}, transfer);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Dispatch a task to a named Worker, awaiting confirmation of the result.
|
||||
* @param {WorkerTask} taskData Data to dispatch to the Worker as part of the task.
|
||||
* @param {Array<*>} transfer An array of transferable objects which are transferred to the worker thread.
|
||||
* @returns {Promise} A Promise which wraps the task transaction.
|
||||
*/
|
||||
async #dispatchTask(taskData={}, transfer=[]) {
|
||||
const taskId = taskData.taskId = this.#taskIndex++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#tasks.set(taskId, {resolve, reject});
|
||||
this.postMessage(taskData, transfer);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle messages emitted by the Worker thread.
|
||||
* @param {MessageEvent} event The dispatched message event
|
||||
*/
|
||||
#onMessage(event) {
|
||||
const response = event.data;
|
||||
const task = this.#tasks.get(response.taskId);
|
||||
if ( !task ) return;
|
||||
this.#tasks.delete(response.taskId);
|
||||
if ( response.error ) return task.reject(response.error);
|
||||
return task.resolve(response.result);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle errors emitted by the Worker thread.
|
||||
* @param {ErrorEvent} error The dispatched error event
|
||||
*/
|
||||
#onError(error) {
|
||||
error.message = `An error occurred in Worker ${this.name}: ${error.message}`;
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A client-side class responsible for managing a set of web workers.
|
||||
* This interface is accessed as a singleton instance via game.workers.
|
||||
* @see Game#workers
|
||||
*/
|
||||
class WorkerManager extends Map {
|
||||
constructor() {
|
||||
if ( game.workers instanceof WorkerManager ) {
|
||||
throw new Error("The singleton WorkerManager instance has already been constructed as Game#workers");
|
||||
}
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported worker task actions
|
||||
* @enum {string}
|
||||
*/
|
||||
static WORKER_TASK_ACTIONS = Object.freeze({
|
||||
INIT: "init",
|
||||
LOAD: "load",
|
||||
EXECUTE: "execute"
|
||||
});
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Worker Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new named Worker.
|
||||
* @param {string} name The named Worker to create
|
||||
* @param {object} [config={}] Worker configuration parameters passed to the AsyncWorker constructor
|
||||
* @returns {Promise<AsyncWorker>} The created AsyncWorker which is ready to accept tasks
|
||||
*/
|
||||
async createWorker(name, config={}) {
|
||||
if (this.has(name)) {
|
||||
throw new Error(`A Worker already exists with the name "${name}"`);
|
||||
}
|
||||
const worker = new AsyncWorker(name, config);
|
||||
this.set(name, worker);
|
||||
await worker.ready;
|
||||
return worker;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retire a current Worker, terminating it immediately.
|
||||
* @see Worker#terminate
|
||||
* @param {string} name The named worker to terminate
|
||||
*/
|
||||
retireWorker(name) {
|
||||
const worker = this.get(name);
|
||||
if ( !worker ) return;
|
||||
worker.terminate();
|
||||
this.delete(name);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since 11
|
||||
* @ignore
|
||||
*/
|
||||
getWorker(name) {
|
||||
foundry.utils.logCompatibilityWarning("WorkerManager#getWorker is deprecated in favor of WorkerManager#get",
|
||||
{since: 11, until: 13});
|
||||
const w = this.get(name);
|
||||
if ( !w ) throw new Error(`No worker with name ${name} currently exists!`);
|
||||
return w;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
Reference in New Issue
Block a user