Initial
This commit is contained in:
234
resources/app/client/apps/hud/chatbubble.js
Normal file
234
resources/app/client/apps/hud/chatbubble.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* @typedef {Object} ChatBubbleOptions
|
||||
* @property {string[]} [cssClasses] An optional array of CSS classes to apply to the resulting bubble
|
||||
* @property {boolean} [pan=true] Pan to the token speaker for this bubble, if allowed by the client
|
||||
* @property {boolean} [requireVisible=false] Require that the token be visible in order for the bubble to be rendered
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Chat Bubble Class
|
||||
* This application displays a temporary message sent from a particular Token in the active Scene.
|
||||
* The message is displayed on the HUD layer just above the Token.
|
||||
*/
|
||||
class ChatBubbles {
|
||||
constructor() {
|
||||
this.template = "templates/hud/chat-bubble.html";
|
||||
|
||||
/**
|
||||
* Track active Chat Bubbles
|
||||
* @type {object}
|
||||
*/
|
||||
this.bubbles = {};
|
||||
|
||||
/**
|
||||
* Track which Token was most recently panned to highlight
|
||||
* Use this to avoid repeat panning
|
||||
* @type {Token}
|
||||
* @private
|
||||
*/
|
||||
this._panned = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the chat bubbles HTML container in which rendered bubbles should live
|
||||
* @returns {jQuery}
|
||||
*/
|
||||
get container() {
|
||||
return $("#chat-bubbles");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a chat bubble message for a certain token which is synchronized for display across all connected clients.
|
||||
* @param {TokenDocument} token The speaking Token Document
|
||||
* @param {string} message The spoken message text
|
||||
* @param {ChatBubbleOptions} [options] Options which affect the bubble appearance
|
||||
* @returns {Promise<jQuery|null>} A promise which resolves with the created bubble HTML, or null
|
||||
*/
|
||||
async broadcast(token, message, options={}) {
|
||||
if ( token instanceof Token ) token = token.document;
|
||||
if ( !(token instanceof TokenDocument) || !message ) {
|
||||
throw new Error("You must provide a Token instance and a message string");
|
||||
}
|
||||
game.socket.emit("chatBubble", {
|
||||
sceneId: token.parent.id,
|
||||
tokenId: token.id,
|
||||
message,
|
||||
options
|
||||
});
|
||||
return this.say(token.object, message, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Speak a message as a particular Token, displaying it as a chat bubble
|
||||
* @param {Token} token The speaking Token
|
||||
* @param {string} message The spoken message text
|
||||
* @param {ChatBubbleOptions} [options] Options which affect the bubble appearance
|
||||
* @returns {Promise<JQuery|null>} A Promise which resolves to the created bubble HTML element, or null
|
||||
*/
|
||||
async say(token, message, {cssClasses=[], requireVisible=false, pan=true}={}) {
|
||||
|
||||
// Ensure that a bubble is allowed for this token
|
||||
if ( !token || !message ) return null;
|
||||
let allowBubbles = game.settings.get("core", "chatBubbles");
|
||||
if ( !allowBubbles ) return null;
|
||||
if ( requireVisible && !token.visible ) return null;
|
||||
|
||||
// Clear any existing bubble for the speaker
|
||||
await this._clearBubble(token);
|
||||
|
||||
// Create the HTML and call the chatBubble hook
|
||||
const actor = ChatMessage.implementation.getSpeakerActor({scene: token.scene.id, token: token.id});
|
||||
message = await TextEditor.enrichHTML(message, { rollData: actor?.getRollData() });
|
||||
let html = $(await this._renderHTML({token, message, cssClasses: cssClasses.join(" ")}));
|
||||
|
||||
const allowed = Hooks.call("chatBubble", token, html, message, {cssClasses, pan});
|
||||
if ( allowed === false ) return null;
|
||||
|
||||
// Set initial dimensions
|
||||
let dimensions = this._getMessageDimensions(message);
|
||||
this._setPosition(token, html, dimensions);
|
||||
|
||||
// Append to DOM
|
||||
this.container.append(html);
|
||||
|
||||
// Optionally pan to the speaker
|
||||
const panToSpeaker = game.settings.get("core", "chatBubblesPan") && pan && (this._panned !== token);
|
||||
const promises = [];
|
||||
if ( panToSpeaker ) {
|
||||
const scale = Math.max(1, canvas.stage.scale.x);
|
||||
promises.push(canvas.animatePan({x: token.document.x, y: token.document.y, scale, duration: 1000}));
|
||||
this._panned = token;
|
||||
}
|
||||
|
||||
// Get animation duration and settings
|
||||
const duration = this._getDuration(html);
|
||||
const scroll = dimensions.unconstrained - dimensions.height;
|
||||
|
||||
// Animate the bubble
|
||||
promises.push(new Promise(resolve => {
|
||||
html.fadeIn(250, () => {
|
||||
if ( scroll > 0 ) {
|
||||
html.find(".bubble-content").animate({top: -1 * scroll}, duration - 1000, "linear", resolve);
|
||||
}
|
||||
setTimeout(() => html.fadeOut(250, () => html.remove()), duration);
|
||||
});
|
||||
}));
|
||||
|
||||
// Return the chat bubble HTML after all animations have completed
|
||||
await Promise.all(promises);
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate Socket event listeners which apply to the ChatBubbles UI.
|
||||
* @param {Socket} socket The active web socket connection
|
||||
* @internal
|
||||
*/
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("chatBubble", ({sceneId, tokenId, message, options}) => {
|
||||
if ( !canvas.ready ) return;
|
||||
const scene = game.scenes.get(sceneId);
|
||||
if ( !scene?.isView ) return;
|
||||
const token = scene.tokens.get(tokenId);
|
||||
if ( !token ) return;
|
||||
return canvas.hud.bubbles.say(token.object, message, options);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clear any existing chat bubble for a certain Token
|
||||
* @param {Token} token
|
||||
* @private
|
||||
*/
|
||||
async _clearBubble(token) {
|
||||
let existing = $(`.chat-bubble[data-token-id="${token.id}"]`);
|
||||
if ( !existing.length ) return;
|
||||
return new Promise(resolve => {
|
||||
existing.fadeOut(100, () => {
|
||||
existing.remove();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render the HTML template for the chat bubble
|
||||
* @param {object} data Template data
|
||||
* @returns {Promise<string>} The rendered HTML
|
||||
* @private
|
||||
*/
|
||||
async _renderHTML(data) {
|
||||
return renderTemplate(this.template, data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Before displaying the chat message, determine it's constrained and unconstrained dimensions
|
||||
* @param {string} message The message content
|
||||
* @returns {object} The rendered message dimensions
|
||||
* @private
|
||||
*/
|
||||
_getMessageDimensions(message) {
|
||||
let div = $(`<div class="chat-bubble" style="visibility:hidden">${message}</div>`);
|
||||
$("body").append(div);
|
||||
let dims = {
|
||||
width: div[0].clientWidth + 8,
|
||||
height: div[0].clientHeight
|
||||
};
|
||||
div.css({maxHeight: "none"});
|
||||
dims.unconstrained = div[0].clientHeight;
|
||||
div.remove();
|
||||
return dims;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign styling parameters to the chat bubble, toggling either a left or right display (randomly)
|
||||
* @param {Token} token The speaking Token
|
||||
* @param {JQuery} html Chat bubble content
|
||||
* @param {Rectangle} dimensions Positioning data
|
||||
* @private
|
||||
*/
|
||||
_setPosition(token, html, dimensions) {
|
||||
let cls = Math.random() > 0.5 ? "left" : "right";
|
||||
html.addClass(cls);
|
||||
const pos = {
|
||||
height: dimensions.height,
|
||||
width: dimensions.width,
|
||||
top: token.y - dimensions.height - 8
|
||||
};
|
||||
if ( cls === "right" ) pos.left = token.x - (dimensions.width - token.w);
|
||||
else pos.left = token.x;
|
||||
html.css(pos);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine the length of time for which to display a chat bubble.
|
||||
* Research suggests that average reading speed is 200 words per minute.
|
||||
* Since these are short-form messages, we multiply reading speed by 1.5.
|
||||
* Clamp the result between 1 second (minimum) and 20 seconds (maximum)
|
||||
* @param {jQuery} html The HTML message
|
||||
* @returns {number} The number of milliseconds for which to display the message
|
||||
*/
|
||||
_getDuration(html) {
|
||||
const words = html.text().split(/\s+/).reduce((n, w) => n + Number(!!w.trim().length), 0);
|
||||
const ms = (words * 60 * 1000) / 300;
|
||||
return Math.clamp(1000, ms, 20000);
|
||||
}
|
||||
}
|
||||
73
resources/app/client/apps/hud/container.js
Normal file
73
resources/app/client/apps/hud/container.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The Heads-Up Display is a canvas-sized Application which renders HTML overtop of the game canvas.
|
||||
*/
|
||||
class HeadsUpDisplay extends Application {
|
||||
|
||||
/**
|
||||
* Token HUD
|
||||
* @type {TokenHUD}
|
||||
*/
|
||||
token = new CONFIG.Token.hudClass();
|
||||
|
||||
/**
|
||||
* Tile HUD
|
||||
* @type {TileHUD}
|
||||
*/
|
||||
tile = new CONFIG.Tile.hudClass();
|
||||
|
||||
/**
|
||||
* Drawing HUD
|
||||
* @type {DrawingHUD}
|
||||
*/
|
||||
drawing = new CONFIG.Drawing.hudClass();
|
||||
|
||||
/**
|
||||
* Chat Bubbles
|
||||
* @type {ChatBubbles}
|
||||
*/
|
||||
bubbles = new CONFIG.Canvas.chatBubblesClass();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.id = "hud";
|
||||
options.template = "templates/hud/hud.html";
|
||||
options.popOut = false;
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
if ( !canvas.ready ) return {};
|
||||
return {
|
||||
width: canvas.dimensions.width,
|
||||
height: canvas.dimensions.height
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, options) {
|
||||
await super._render(force, options);
|
||||
this.align();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Align the position of the HUD layer to the current position of the canvas
|
||||
*/
|
||||
align() {
|
||||
const hud = this.element[0];
|
||||
const {x, y} = canvas.primary.getGlobalPosition();
|
||||
const scale = canvas.stage.scale.x;
|
||||
hud.style.left = `${x}px`;
|
||||
hud.style.top = `${y}px`;
|
||||
hud.style.transform = `scale(${scale})`;
|
||||
}
|
||||
}
|
||||
1051
resources/app/client/apps/hud/controls.js
vendored
Normal file
1051
resources/app/client/apps/hud/controls.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
423
resources/app/client/apps/hud/hotbar.js
Normal file
423
resources/app/client/apps/hud/hotbar.js
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* The global action bar displayed at the bottom of the game view.
|
||||
* The Hotbar is a UI element at the bottom of the screen which contains Macros as interactive buttons.
|
||||
* The Hotbar supports 5 pages of global macros which can be dragged and dropped to organize as you wish.
|
||||
*
|
||||
* Left-clicking a Macro button triggers its effect.
|
||||
* Right-clicking the button displays a context menu of Macro options.
|
||||
* The number keys 1 through 0 activate numbered hotbar slots.
|
||||
* Pressing the delete key while hovering over a Macro will remove it from the bar.
|
||||
*
|
||||
* @see {@link Macros}
|
||||
* @see {@link Macro}
|
||||
*/
|
||||
class Hotbar extends Application {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
game.macros.apps.push(this);
|
||||
|
||||
/**
|
||||
* The currently viewed macro page
|
||||
* @type {number}
|
||||
*/
|
||||
this.page = 1;
|
||||
|
||||
/**
|
||||
* The currently displayed set of macros
|
||||
* @type {Macro[]}
|
||||
*/
|
||||
this.macros = [];
|
||||
|
||||
/**
|
||||
* Track collapsed state
|
||||
* @type {boolean}
|
||||
*/
|
||||
this._collapsed = false;
|
||||
|
||||
/**
|
||||
* Track which hotbar slot is the current hover target, if any
|
||||
* @type {number|null}
|
||||
*/
|
||||
this._hover = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "hotbar",
|
||||
template: "templates/hud/hotbar.html",
|
||||
popOut: false,
|
||||
dragDrop: [{ dragSelector: ".macro-icon", dropSelector: "#macro-list" }]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether the hotbar is locked.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get locked() {
|
||||
return game.settings.get("core", "hotbarLock");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options={}) {
|
||||
this.macros = this._getMacrosByPage(this.page);
|
||||
return {
|
||||
page: this.page,
|
||||
macros: this.macros,
|
||||
barClass: this._collapsed ? "collapsed" : "",
|
||||
locked: this.locked
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Array of Macro (or null) values that should be displayed on a numbered page of the bar
|
||||
* @param {number} page
|
||||
* @returns {Macro[]}
|
||||
* @private
|
||||
*/
|
||||
_getMacrosByPage(page) {
|
||||
const macros = game.user.getHotbarMacros(page);
|
||||
for ( let [i, slot] of macros.entries() ) {
|
||||
slot.key = i<9 ? i+1 : 0;
|
||||
slot.icon = slot.macro ? slot.macro.img : null;
|
||||
slot.cssClass = slot.macro ? "active" : "inactive";
|
||||
slot.tooltip = slot.macro ? slot.macro.name : null;
|
||||
}
|
||||
return macros;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Collapse the Hotbar, minimizing its display.
|
||||
* @returns {Promise} A promise which resolves once the collapse animation completes
|
||||
*/
|
||||
async collapse() {
|
||||
if ( this._collapsed ) return true;
|
||||
const toggle = this.element.find("#bar-toggle");
|
||||
const icon = toggle.children("i");
|
||||
const bar = this.element.find("#action-bar");
|
||||
return new Promise(resolve => {
|
||||
bar.slideUp(200, () => {
|
||||
bar.addClass("collapsed");
|
||||
icon.removeClass("fa-caret-down").addClass("fa-caret-up");
|
||||
this._collapsed = true;
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Expand the Hotbar, displaying it normally.
|
||||
* @returns {Promise} A promise which resolves once the expand animation completes
|
||||
*/
|
||||
async expand() {
|
||||
if ( !this._collapsed ) return true;
|
||||
const toggle = this.element.find("#bar-toggle");
|
||||
const icon = toggle.children("i");
|
||||
const bar = this.element.find("#action-bar");
|
||||
return new Promise(resolve => {
|
||||
bar.slideDown(200, () => {
|
||||
bar.css("display", "");
|
||||
bar.removeClass("collapsed");
|
||||
icon.removeClass("fa-caret-up").addClass("fa-caret-down");
|
||||
this._collapsed = false;
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Change to a specific numbered page from 1 to 5
|
||||
* @param {number} page The page number to change to.
|
||||
*/
|
||||
changePage(page) {
|
||||
this.page = Math.clamp(page ?? 1, 1, 5);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Change the page of the hotbar by cycling up (positive) or down (negative)
|
||||
* @param {number} direction The direction to cycle
|
||||
*/
|
||||
cyclePage(direction) {
|
||||
direction = Number.isNumeric(direction) ? Math.sign(direction) : 1;
|
||||
if ( direction > 0 ) {
|
||||
this.page = this.page < 5 ? this.page+1 : 1;
|
||||
} else {
|
||||
this.page = this.page > 1 ? this.page-1 : 5;
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
// Macro actions
|
||||
html.find("#bar-toggle").click(this._onToggleBar.bind(this));
|
||||
html.find("#macro-directory").click(ev => ui.macros.renderPopout(true));
|
||||
html.find(".macro").click(this._onClickMacro.bind(this));
|
||||
html.find(".page-control").click(this._onClickPageControl.bind(this));
|
||||
|
||||
// Activate context menu
|
||||
this._contextMenu(html);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_contextMenu(html) {
|
||||
ContextMenu.create(this, html, ".macro", this._getEntryContextOptions());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Macro entry context options
|
||||
* @returns {object[]} The Macro entry context options
|
||||
* @private
|
||||
*/
|
||||
_getEntryContextOptions() {
|
||||
return [
|
||||
{
|
||||
name: "MACRO.Edit",
|
||||
icon: '<i class="fas fa-edit"></i>',
|
||||
condition: li => {
|
||||
const macro = game.macros.get(li.data("macro-id"));
|
||||
return macro ? macro.isOwner : false;
|
||||
},
|
||||
callback: li => {
|
||||
const macro = game.macros.get(li.data("macro-id"));
|
||||
macro.sheet.render(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "MACRO.Remove",
|
||||
icon: '<i class="fas fa-times"></i>',
|
||||
condition: li => !!li.data("macro-id"),
|
||||
callback: li => game.user.assignHotbarMacro(null, Number(li.data("slot")))
|
||||
},
|
||||
{
|
||||
name: "MACRO.Delete",
|
||||
icon: '<i class="fas fa-trash"></i>',
|
||||
condition: li => {
|
||||
const macro = game.macros.get(li.data("macro-id"));
|
||||
return macro ? macro.isOwner : false;
|
||||
},
|
||||
callback: li => {
|
||||
const macro = game.macros.get(li.data("macro-id"));
|
||||
return Dialog.confirm({
|
||||
title: `${game.i18n.localize("MACRO.Delete")} ${macro.name}`,
|
||||
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("MACRO.DeleteWarning")}</p>`,
|
||||
yes: macro.delete.bind(macro)
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle left-click events to
|
||||
* @param {MouseEvent} event The originating click event
|
||||
* @protected
|
||||
*/
|
||||
async _onClickMacro(event) {
|
||||
event.preventDefault();
|
||||
const li = event.currentTarget;
|
||||
|
||||
// Case 1 - create a temporary Macro
|
||||
if ( li.classList.contains("inactive") ) {
|
||||
const cls = getDocumentClass("Macro");
|
||||
const macro = new cls({name: cls.defaultName({type: "chat"}), type: "chat", scope: "global"});
|
||||
macro.sheet._hotbarSlot = li.dataset.slot;
|
||||
macro.sheet.render(true);
|
||||
}
|
||||
|
||||
// Case 2 - trigger a Macro
|
||||
else {
|
||||
const macro = game.macros.get(li.dataset.macroId);
|
||||
return macro.execute();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle pagination controls
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onClickPageControl(event) {
|
||||
const action = event.currentTarget.dataset.action;
|
||||
switch ( action ) {
|
||||
case "page-up":
|
||||
this.cyclePage(1);
|
||||
break;
|
||||
|
||||
case "page-down":
|
||||
this.cyclePage(-1);
|
||||
break;
|
||||
|
||||
case "lock":
|
||||
this._toggleHotbarLock();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_canDragStart(selector) {
|
||||
return !this.locked;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDragStart(event) {
|
||||
const li = event.currentTarget.closest(".macro");
|
||||
const macro = game.macros.get(li.dataset.macroId);
|
||||
if ( !macro ) return false;
|
||||
const dragData = foundry.utils.mergeObject(macro.toDragData(), {slot: li.dataset.slot});
|
||||
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_canDragDrop(selector) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onDrop(event) {
|
||||
event.preventDefault();
|
||||
const li = event.target.closest(".macro");
|
||||
const slot = Number(li.dataset.slot);
|
||||
const data = TextEditor.getDragEventData(event);
|
||||
if ( Hooks.call("hotbarDrop", this, data, slot) === false ) return;
|
||||
|
||||
// Forbid overwriting macros if the hotbar is locked.
|
||||
const existingMacro = game.macros.get(game.user.hotbar[slot]);
|
||||
if ( existingMacro && this.locked ) return ui.notifications.warn("MACRO.CannotOverwrite", { localize: true });
|
||||
|
||||
// Get the dropped document
|
||||
const cls = getDocumentClass(data.type);
|
||||
const doc = await cls?.fromDropData(data);
|
||||
if ( !doc ) return;
|
||||
|
||||
// Get the Macro to add to the bar
|
||||
let macro;
|
||||
if ( data.type === "Macro" ) macro = game.macros.has(doc.id) ? doc : await cls.create(doc.toObject());
|
||||
else if ( data.type === "RollTable" ) macro = await this._createRollTableRollMacro(doc);
|
||||
else macro = await this._createDocumentSheetToggle(doc);
|
||||
|
||||
// Assign the macro to the hotbar
|
||||
if ( !macro ) return;
|
||||
return game.user.assignHotbarMacro(macro, slot, {fromSlot: data.slot});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a Macro which rolls a RollTable when executed
|
||||
* @param {Document} table The RollTable document
|
||||
* @returns {Promise<Macro>} A created Macro document to add to the bar
|
||||
* @private
|
||||
*/
|
||||
async _createRollTableRollMacro(table) {
|
||||
const command = `const table = await fromUuid("${table.uuid}");\nawait table.draw();`;
|
||||
return Macro.implementation.create({
|
||||
name: `${game.i18n.localize("TABLE.Roll")} ${table.name}`,
|
||||
type: "script",
|
||||
img: table.img,
|
||||
command
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a Macro document which can be used to toggle display of a Journal Entry.
|
||||
* @param {Document} doc A Document which should be toggled
|
||||
* @returns {Promise<Macro>} A created Macro document to add to the bar
|
||||
* @protected
|
||||
*/
|
||||
async _createDocumentSheetToggle(doc) {
|
||||
const name = doc.name || `${game.i18n.localize(doc.constructor.metadata.label)} ${doc.id}`;
|
||||
return Macro.implementation.create({
|
||||
name: `${game.i18n.localize("Display")} ${name}`,
|
||||
type: CONST.MACRO_TYPES.SCRIPT,
|
||||
img: "icons/svg/book.svg",
|
||||
command: `await Hotbar.toggleDocumentSheet("${doc.uuid}");`
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events to toggle display of the macro bar
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onToggleBar(event) {
|
||||
event.preventDefault();
|
||||
if ( this._collapsed ) return this.expand();
|
||||
else return this.collapse();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle the hotbar's lock state.
|
||||
* @returns {Promise<Hotbar>}
|
||||
* @protected
|
||||
*/
|
||||
async _toggleHotbarLock() {
|
||||
await game.settings.set("core", "hotbarLock", !this.locked);
|
||||
return this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling a document sheet.
|
||||
* @param {string} uuid The Document UUID to display
|
||||
* @returns {Promise<void>|Application|*}
|
||||
*/
|
||||
static async toggleDocumentSheet(uuid) {
|
||||
const doc = await fromUuid(uuid);
|
||||
if ( !doc ) {
|
||||
return ui.notifications.warn(game.i18n.format("WARNING.ObjectDoesNotExist", {
|
||||
name: game.i18n.localize("Document"),
|
||||
identifier: uuid
|
||||
}));
|
||||
}
|
||||
const sheet = doc.sheet;
|
||||
return sheet.rendered ? sheet.close() : sheet.render(true);
|
||||
}
|
||||
}
|
||||
312
resources/app/client/apps/hud/hud.js
Normal file
312
resources/app/client/apps/hud/hud.js
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* An abstract base class for displaying a heads-up-display interface bound to a Placeable Object on the canvas
|
||||
* @interface
|
||||
* @template {PlaceableObject} ActiveHUDObject
|
||||
* @template {CanvasDocument} ActiveHUDDocument
|
||||
* @template {PlaceablesLayer} ActiveHUDLayer
|
||||
*/
|
||||
class BasePlaceableHUD extends Application {
|
||||
|
||||
/**
|
||||
* Reference a PlaceableObject this HUD is currently bound to.
|
||||
* @type {ActiveHUDObject}
|
||||
*/
|
||||
object;
|
||||
|
||||
/**
|
||||
* Track whether a control icon is hovered or not
|
||||
* @type {boolean}
|
||||
*/
|
||||
#hoverControlIcon = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["placeable-hud"],
|
||||
popOut: false
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convenience access to the Document which this HUD modifies.
|
||||
* @returns {ActiveHUDDocument}
|
||||
*/
|
||||
get document() {
|
||||
return this.object?.document;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convenience access for the canvas layer which this HUD modifies
|
||||
* @type {ActiveHUDLayer}
|
||||
*/
|
||||
get layer() {
|
||||
return this.object?.layer;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Bind the HUD to a new PlaceableObject and display it
|
||||
* @param {PlaceableObject} object A PlaceableObject instance to which the HUD should be bound
|
||||
*/
|
||||
bind(object) {
|
||||
const states = this.constructor.RENDER_STATES;
|
||||
if ( [states.CLOSING, states.RENDERING].includes(this._state) ) return;
|
||||
if ( this.object ) this.clear();
|
||||
|
||||
// Record the new object
|
||||
if ( !(object instanceof PlaceableObject) || (object.scene !== canvas.scene) ) {
|
||||
throw new Error("You may only bind a HUD instance to a PlaceableObject in the currently viewed Scene.");
|
||||
}
|
||||
this.object = object;
|
||||
|
||||
// Render the HUD
|
||||
this.render(true);
|
||||
this.element.hide().fadeIn(200);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clear the HUD by fading out it's active HTML and recording the new display state
|
||||
*/
|
||||
clear() {
|
||||
let states = this.constructor.RENDER_STATES;
|
||||
if ( this._state <= states.NONE ) return;
|
||||
this._state = states.CLOSING;
|
||||
|
||||
// Unbind
|
||||
this.object = null;
|
||||
this.element.hide();
|
||||
this._element = null;
|
||||
this._state = states.NONE;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _render(...args) {
|
||||
await super._render(...args);
|
||||
this.setPosition();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options = {}) {
|
||||
const data = this.object.document.toObject();
|
||||
return foundry.utils.mergeObject(data, {
|
||||
id: this.id,
|
||||
classes: this.options.classes.join(" "),
|
||||
appId: this.appId,
|
||||
isGM: game.user.isGM,
|
||||
isGamePaused: game.paused,
|
||||
icons: CONFIG.controlIcons
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
setPosition({left, top, width, height, scale} = {}) {
|
||||
const position = {
|
||||
width: width || this.object.width,
|
||||
height: height || this.object.height,
|
||||
left: left ?? this.object.x,
|
||||
top: top ?? this.object.y
|
||||
};
|
||||
this.element.css(position);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
// Attribute Bars
|
||||
html.find(".attribute input")
|
||||
.click(this._onAttributeClick)
|
||||
.keydown(this._onAttributeKeydown.bind(this))
|
||||
.focusout(this._onAttributeUpdate.bind(this));
|
||||
|
||||
// Control icons hover detection
|
||||
html.find(".control-icon")
|
||||
.mouseleave(() => this.#hoverControlIcon = false)
|
||||
.mouseenter(() => this.#hoverControlIcon = true)
|
||||
.click(this._onClickControl.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse clicks to control a HUD control button
|
||||
* @param {PointerEvent} event The originating click event
|
||||
* @protected
|
||||
*/
|
||||
_onClickControl(event) {
|
||||
const button = event.currentTarget;
|
||||
switch ( button.dataset.action ) {
|
||||
case "visibility":
|
||||
return this._onToggleVisibility(event);
|
||||
case "locked":
|
||||
return this._onToggleLocked(event);
|
||||
case "sort-up":
|
||||
return this._onSort(event, true);
|
||||
case "sort-down":
|
||||
return this._onSort(event, false);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle initial click to focus an attribute update field
|
||||
* @param {MouseEvent} event The mouse click event
|
||||
* @protected
|
||||
*/
|
||||
_onAttributeClick(event) {
|
||||
event.currentTarget.select();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Force field handling on an Enter keypress even if the value of the field did not change.
|
||||
* This is important to suppose use cases with negative number values.
|
||||
* @param {KeyboardEvent} event The originating keydown event
|
||||
* @protected
|
||||
*/
|
||||
_onAttributeKeydown(event) {
|
||||
if ( (event.code === "Enter") || (event.code === "NumpadEnter") ) event.currentTarget.blur();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle attribute updates
|
||||
* @param {FocusEvent} event The originating focusout event
|
||||
*/
|
||||
_onAttributeUpdate(event) {
|
||||
event.preventDefault();
|
||||
if ( !this.object ) return;
|
||||
const input = event.currentTarget;
|
||||
this._updateAttribute(input.name, event.currentTarget.value.trim());
|
||||
if ( !this.#hoverControlIcon ) this.clear();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle attribute bar update
|
||||
* @param {string} name The name of the attribute
|
||||
* @param {string} input The raw string input value for the update
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _updateAttribute(name, input) {
|
||||
const current = foundry.utils.getProperty(this.object.document, name);
|
||||
const {value} = this._parseAttributeInput(name, current, input);
|
||||
await this.object.document.update({[name]: value});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Parse an attribute bar input string into a new value for the attribute field.
|
||||
* @param {string} name The name of the attribute
|
||||
* @param {object|number} attr The current value of the attribute
|
||||
* @param {string} input The raw string input value
|
||||
* @returns {{value: number, [delta]: number, isDelta: boolean, isBar: boolean}} The parsed input value
|
||||
* @protected
|
||||
*/
|
||||
_parseAttributeInput(name, attr, input) {
|
||||
const isBar = (typeof attr === "object") && ("max" in attr);
|
||||
const isEqual = input.startsWith("=");
|
||||
const isDelta = input.startsWith("+") || input.startsWith("-");
|
||||
const current = isBar ? attr.value : attr;
|
||||
let v;
|
||||
|
||||
// Explicit equality
|
||||
if ( isEqual ) input = input.slice(1);
|
||||
|
||||
// Percentage change
|
||||
if ( input.endsWith("%") ) {
|
||||
const p = Number(input.slice(0, -1)) / 100;
|
||||
if ( isBar ) v = attr.max * p;
|
||||
else v = Math.abs(current) * p;
|
||||
}
|
||||
|
||||
// Additive delta
|
||||
else v = Number(input);
|
||||
|
||||
// Return parsed input
|
||||
const value = isDelta ? current + v : v;
|
||||
const delta = isDelta ? v : undefined;
|
||||
return {value, delta, isDelta, isBar};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle the visible state of all controlled objects in the Layer
|
||||
* @param {PointerEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
async _onToggleVisibility(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Toggle the visible state
|
||||
const isHidden = this.object.document.hidden;
|
||||
const updates = this.layer.controlled.map(o => {
|
||||
return {_id: o.id, hidden: !isHidden};
|
||||
});
|
||||
|
||||
// Update all objects
|
||||
return canvas.scene.updateEmbeddedDocuments(this.object.document.documentName, updates);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle locked state of all controlled objects in the Layer
|
||||
* @param {PointerEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
async _onToggleLocked(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Toggle the visible state
|
||||
const isLocked = this.object.document.locked;
|
||||
const updates = this.layer.controlled.map(o => {
|
||||
return {_id: o.id, locked: !isLocked};
|
||||
});
|
||||
|
||||
// Update all objects
|
||||
event.currentTarget.classList.toggle("active", !isLocked);
|
||||
return canvas.scene.updateEmbeddedDocuments(this.object.document.documentName, updates);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle sorting the z-order of the object
|
||||
* @param {PointerEvent} event The originating mouse click event
|
||||
* @param {boolean} up Move the object upwards in the vertical stack?
|
||||
* If false, the object is moved downwards.
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _onSort(event, up) {
|
||||
event.preventDefault();
|
||||
this.layer._sendToBackOrBringToFront(up);
|
||||
}
|
||||
}
|
||||
82
resources/app/client/apps/hud/menu.js
Normal file
82
resources/app/client/apps/hud/menu.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The main menu application which is toggled via the ESC key.
|
||||
* @extends {Application}
|
||||
*/
|
||||
class MainMenu extends Application {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "menu",
|
||||
template: "templates/hud/menu.html",
|
||||
popOut: false
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* The structure of menu items
|
||||
* @returns {Record<string, {label: string, icon: string, enabled: boolean, onClick: Function}>}
|
||||
*/
|
||||
get items() {
|
||||
return {
|
||||
reload: {
|
||||
label: "MENU.Reload",
|
||||
icon: '<i class="fas fa-redo"></i>',
|
||||
enabled: true,
|
||||
onClick: () => window.location.reload()
|
||||
},
|
||||
logout: {
|
||||
label: "MENU.Logout",
|
||||
icon: '<i class="fas fa-user"></i>',
|
||||
enabled: true,
|
||||
onClick: () => game.logOut()
|
||||
},
|
||||
players: {
|
||||
label: "MENU.Players",
|
||||
icon: '<i class="fas fa-users"></i>',
|
||||
enabled: game.user.isGM && !game.data.demoMode,
|
||||
onClick: () => window.location.href = "./players"
|
||||
},
|
||||
world: {
|
||||
label: "GAME.ReturnSetup",
|
||||
icon: '<i class="fas fa-globe"></i>',
|
||||
enabled: game.user.hasRole("GAMEMASTER") && !game.data.demoMode,
|
||||
onClick: () => {
|
||||
this.close();
|
||||
game.shutDown();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
return {
|
||||
items: this.items
|
||||
};
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
for ( let [k, v] of Object.entries(this.items) ) {
|
||||
html.find(`.menu-${k}`).click(v.onClick);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle display of the menu (or render it in the first place)
|
||||
*/
|
||||
toggle() {
|
||||
let menu = this.element;
|
||||
if ( !this.rendered ) this.render(true);
|
||||
else menu.slideToggle(150);
|
||||
}
|
||||
}
|
||||
310
resources/app/client/apps/hud/navigation.js
Normal file
310
resources/app/client/apps/hud/navigation.js
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* The UI element which displays the Scene documents which are currently enabled for quick navigation.
|
||||
*/
|
||||
class SceneNavigation extends Application {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
game.scenes.apps.push(this);
|
||||
|
||||
/**
|
||||
* Navigation collapsed state
|
||||
* @type {boolean}
|
||||
*/
|
||||
this._collapsed = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "navigation",
|
||||
template: "templates/hud/navigation.html",
|
||||
popOut: false,
|
||||
dragDrop: [{dragSelector: ".scene"}]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return an Array of Scenes which are displayed in the Navigation bar
|
||||
* @returns {Scene[]}
|
||||
*/
|
||||
get scenes() {
|
||||
const scenes = game.scenes.filter(s => {
|
||||
return (s.navigation && s.visible) || s.active || s.isView;
|
||||
});
|
||||
scenes.sort((a, b) => a.navOrder - b.navOrder);
|
||||
return scenes;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/* Application Rendering
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
render(force, context = {}) {
|
||||
let {renderContext, renderData} = context;
|
||||
if ( renderContext ) {
|
||||
const events = ["createScene", "updateScene", "deleteScene"];
|
||||
if ( !events.includes(renderContext) ) return this;
|
||||
const updateKeys = ["name", "ownership", "active", "navigation", "navName", "navOrder"];
|
||||
if ( (renderContext === "updateScene") && !renderData.some(d => updateKeys.some(k => k in d)) ) return this;
|
||||
}
|
||||
return super.render(force, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, options) {
|
||||
await super._render(force, options);
|
||||
const loading = document.getElementById("loading");
|
||||
const nav = this.element[0];
|
||||
loading.style.top = `${nav.offsetTop + nav.offsetHeight}px`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
const scenes = this.scenes.map(scene => {
|
||||
return {
|
||||
id: scene.id,
|
||||
active: scene.active,
|
||||
name: TextEditor.truncateText(scene.navName || scene.name, {maxLength: 32}),
|
||||
tooltip: scene.navName && game.user.isGM ? scene.name : null,
|
||||
users: game.users.reduce((arr, u) => {
|
||||
if ( u.active && ( u.viewedScene === scene.id) ) arr.push({letter: u.name[0], color: u.color.css});
|
||||
return arr;
|
||||
}, []),
|
||||
visible: game.user.isGM || scene.isOwner || scene.active,
|
||||
css: [
|
||||
scene.isView ? "view" : null,
|
||||
scene.active ? "active" : null,
|
||||
scene.ownership.default === 0 ? "gm" : null
|
||||
].filterJoin(" ")
|
||||
};
|
||||
});
|
||||
return {collapsed: this._collapsed, scenes: scenes};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A hook event that fires when the SceneNavigation menu is expanded or collapsed.
|
||||
* @function collapseSceneNavigation
|
||||
* @memberof hookEvents
|
||||
* @param {SceneNavigation} sceneNavigation The SceneNavigation application
|
||||
* @param {boolean} collapsed Whether the SceneNavigation is now collapsed or not
|
||||
*/
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Expand the SceneNavigation menu, sliding it down if it is currently collapsed
|
||||
*/
|
||||
expand() {
|
||||
if ( !this._collapsed ) return true;
|
||||
const nav = this.element;
|
||||
const icon = nav.find("#nav-toggle i.fas");
|
||||
const ul = nav.children("#scene-list");
|
||||
return new Promise(resolve => {
|
||||
ul.slideDown(200, () => {
|
||||
nav.removeClass("collapsed");
|
||||
icon.removeClass("fa-caret-down").addClass("fa-caret-up");
|
||||
this._collapsed = false;
|
||||
Hooks.callAll("collapseSceneNavigation", this, this._collapsed);
|
||||
return resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Collapse the SceneNavigation menu, sliding it up if it is currently expanded
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async collapse() {
|
||||
if ( this._collapsed ) return true;
|
||||
const nav = this.element;
|
||||
const icon = nav.find("#nav-toggle i.fas");
|
||||
const ul = nav.children("#scene-list");
|
||||
return new Promise(resolve => {
|
||||
ul.slideUp(200, () => {
|
||||
nav.addClass("collapsed");
|
||||
icon.removeClass("fa-caret-up").addClass("fa-caret-down");
|
||||
this._collapsed = true;
|
||||
Hooks.callAll("collapseSceneNavigation", this, this._collapsed);
|
||||
return resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
// Click event listener
|
||||
const scenes = html.find(".scene");
|
||||
scenes.click(this._onClickScene.bind(this));
|
||||
html.find("#nav-toggle").click(this._onToggleNav.bind(this));
|
||||
|
||||
// Activate Context Menu
|
||||
const contextOptions = this._getContextMenuOptions();
|
||||
Hooks.call("getSceneNavigationContext", html, contextOptions);
|
||||
if ( contextOptions ) new ContextMenu(html, ".scene", contextOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the set of ContextMenu options which should be applied for Scenes in the menu
|
||||
* @returns {object[]} The Array of context options passed to the ContextMenu instance
|
||||
* @private
|
||||
*/
|
||||
_getContextMenuOptions() {
|
||||
return [
|
||||
{
|
||||
name: "SCENES.Activate",
|
||||
icon: '<i class="fas fa-bullseye"></i>',
|
||||
condition: li => game.user.isGM && !game.scenes.get(li.data("sceneId")).active,
|
||||
callback: li => {
|
||||
let scene = game.scenes.get(li.data("sceneId"));
|
||||
scene.activate();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SCENES.Configure",
|
||||
icon: '<i class="fas fa-cogs"></i>',
|
||||
condition: game.user.isGM,
|
||||
callback: li => {
|
||||
let scene = game.scenes.get(li.data("sceneId"));
|
||||
scene.sheet.render(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SCENES.Notes",
|
||||
icon: '<i class="fas fa-scroll"></i>',
|
||||
condition: li => {
|
||||
if ( !game.user.isGM ) return false;
|
||||
const scene = game.scenes.get(li.data("sceneId"));
|
||||
return !!scene.journal;
|
||||
},
|
||||
callback: li => {
|
||||
const scene = game.scenes.get(li.data("sceneId"));
|
||||
const entry = scene.journal;
|
||||
if ( entry ) {
|
||||
const sheet = entry.sheet;
|
||||
const options = {};
|
||||
if ( scene.journalEntryPage ) options.pageId = scene.journalEntryPage;
|
||||
sheet.render(true, options);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SCENES.Preload",
|
||||
icon: '<i class="fas fa-download"></i>',
|
||||
condition: game.user.isGM,
|
||||
callback: li => {
|
||||
let sceneId = li.attr("data-scene-id");
|
||||
game.scenes.preload(sceneId, true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SCENES.ToggleNav",
|
||||
icon: '<i class="fas fa-compass"></i>',
|
||||
condition: li => {
|
||||
const scene = game.scenes.get(li.data("sceneId"));
|
||||
return game.user.isGM && (!scene.active);
|
||||
},
|
||||
callback: li => {
|
||||
const scene = game.scenes.get(li.data("sceneId"));
|
||||
scene.update({navigation: !scene.navigation});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle left-click events on the scenes in the navigation menu
|
||||
* @param {PointerEvent} event
|
||||
* @private
|
||||
*/
|
||||
_onClickScene(event) {
|
||||
event.preventDefault();
|
||||
let sceneId = event.currentTarget.dataset.sceneId;
|
||||
game.scenes.get(sceneId).view();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDragStart(event) {
|
||||
const sceneId = event.currentTarget.dataset.sceneId;
|
||||
const scene = game.scenes.get(sceneId);
|
||||
event.dataTransfer.setData("text/plain", JSON.stringify(scene.toDragData()));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onDrop(event) {
|
||||
const data = TextEditor.getDragEventData(event);
|
||||
if ( data.type !== "Scene" ) return;
|
||||
|
||||
// Identify the document, the drop target, and the set of siblings
|
||||
const scene = await Scene.implementation.fromDropData(data);
|
||||
const dropTarget = event.target.closest(".scene") || null;
|
||||
const sibling = dropTarget ? game.scenes.get(dropTarget.dataset.sceneId) : null;
|
||||
if ( sibling && (sibling.id === scene.id) ) return;
|
||||
const siblings = this.scenes.filter(s => s.id !== scene.id);
|
||||
|
||||
// Update the navigation sorting for each Scene
|
||||
return scene.sortRelative({
|
||||
target: sibling,
|
||||
siblings: siblings,
|
||||
sortKey: "navOrder"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle navigation menu toggle click events
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onToggleNav(event) {
|
||||
event.preventDefault();
|
||||
if ( this._collapsed ) return this.expand();
|
||||
else return this.collapse();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display progress of some major operation like loading Scene textures.
|
||||
* @param {object} options Options for how the progress bar is displayed
|
||||
* @param {string} options.label A text label to display
|
||||
* @param {number} options.pct A percentage of progress between 0 and 100
|
||||
*/
|
||||
static displayProgressBar({label, pct} = {}) {
|
||||
const loader = document.getElementById("loading");
|
||||
pct = Math.clamp(pct, 0, 100);
|
||||
loader.querySelector("#context").textContent = label;
|
||||
loader.querySelector("#loading-bar").style.width = `${pct}%`;
|
||||
loader.querySelector("#progress").textContent = `${pct}%`;
|
||||
loader.style.display = "block";
|
||||
if ( (pct === 100) && !loader.hidden ) $(loader).fadeOut(2000);
|
||||
}
|
||||
}
|
||||
18
resources/app/client/apps/hud/pause.js
Normal file
18
resources/app/client/apps/hud/pause.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Pause notification in the HUD
|
||||
* @extends {Application}
|
||||
*/
|
||||
class Pause extends Application {
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.id = "pause";
|
||||
options.template = "templates/hud/pause.html";
|
||||
options.popOut = false;
|
||||
return options;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getData(options={}) {
|
||||
return { paused: game.paused };
|
||||
}
|
||||
}
|
||||
289
resources/app/client/apps/hud/players.js
Normal file
289
resources/app/client/apps/hud/players.js
Normal file
@@ -0,0 +1,289 @@
|
||||
|
||||
/**
|
||||
* The UI element which displays the list of Users who are currently playing within the active World.
|
||||
* @extends {Application}
|
||||
*/
|
||||
class PlayerList extends Application {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
game.users.apps.push(this);
|
||||
|
||||
/**
|
||||
* An internal toggle for whether to show offline players or hide them
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
this._showOffline = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "players",
|
||||
template: "templates/user/players.html",
|
||||
popOut: false
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Application Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether the players list is in a configuration where it is hidden.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isHidden() {
|
||||
if ( game.webrtc.mode === AVSettings.AV_MODES.DISABLED ) return false;
|
||||
const { client, verticalDock } = game.webrtc.settings;
|
||||
return verticalDock && client.hidePlayerList && !client.hideDock && !ui.webrtc.hidden;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
render(force, context={}) {
|
||||
this._positionInDOM();
|
||||
const { renderContext, renderData } = context;
|
||||
if ( renderContext ) {
|
||||
const events = ["createUser", "updateUser", "deleteUser"];
|
||||
if ( !events.includes(renderContext) ) return this;
|
||||
if ( renderContext === "updateUser" ) {
|
||||
const updateKeys = ["name", "pronouns", "ownership", "ownership.default", "active", "navigation"];
|
||||
if ( !renderData.some(d => updateKeys.some(k => k in d)) ) return this;
|
||||
}
|
||||
}
|
||||
return super.render(force, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options={}) {
|
||||
|
||||
// Process user data by adding extra characteristics
|
||||
const users = game.users.filter(u => this._showOffline || u.active).map(user => {
|
||||
const u = user.toObject(false);
|
||||
u.active = user.active;
|
||||
u.isGM = user.isGM;
|
||||
u.isSelf = user.isSelf;
|
||||
u.charname = user.character?.name.split(" ")[0] || "";
|
||||
u.color = u.active ? u.color.css : "#333333";
|
||||
u.border = u.active ? user.border.css : "#000000";
|
||||
u.displayName = this._getDisplayName(u);
|
||||
return u;
|
||||
}).sort((a, b) => {
|
||||
if ( (b.role >= CONST.USER_ROLES.ASSISTANT) && (b.role > a.role) ) return 1;
|
||||
return a.name.localeCompare(b.name, game.i18n.lang);
|
||||
});
|
||||
|
||||
// Return the data for rendering
|
||||
return {
|
||||
users,
|
||||
hide: this.isHidden,
|
||||
showOffline: this._showOffline
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare a displayed name string for the User which includes their name, pronouns, character, or GM tag.
|
||||
* @returns {string}
|
||||
* @protected
|
||||
*/
|
||||
_getDisplayName(user) {
|
||||
const displayNamePart = [user.name];
|
||||
if ( user.pronouns ) displayNamePart.push(`(${user.pronouns})`);
|
||||
if ( user.isGM ) displayNamePart.push(`[${game.i18n.localize("USER.GM")}]`);
|
||||
else if ( user.charname ) displayNamePart.push(`[${user.charname}]`);
|
||||
return displayNamePart.join(" ");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Position this Application in the main DOM appropriately.
|
||||
* @protected
|
||||
*/
|
||||
_positionInDOM() {
|
||||
document.body.classList.toggle("players-hidden", this.isHidden);
|
||||
if ( (game.webrtc.mode === AVSettings.AV_MODES.DISABLED) || this.isHidden || !this.element.length ) return;
|
||||
const element = this.element[0];
|
||||
const cameraViews = ui.webrtc.element[0];
|
||||
const uiTop = document.getElementById("ui-top");
|
||||
const uiLeft = document.getElementById("ui-left");
|
||||
const { client, verticalDock } = game.webrtc.settings;
|
||||
const inDock = verticalDock && !client.hideDock && !ui.webrtc.hidden;
|
||||
|
||||
if ( inDock && !cameraViews?.contains(element) ) {
|
||||
cameraViews.appendChild(element);
|
||||
uiTop.classList.remove("offset");
|
||||
} else if ( !inDock && !uiLeft.contains(element) ) {
|
||||
uiLeft.appendChild(element);
|
||||
uiTop.classList.add("offset");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
|
||||
// Toggle online/offline
|
||||
html.find("h3").click(this._onToggleOfflinePlayers.bind(this));
|
||||
|
||||
// Context menu
|
||||
const contextOptions = this._getUserContextOptions();
|
||||
Hooks.call("getUserContextOptions", html, contextOptions);
|
||||
new ContextMenu(html, ".player", contextOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the default context options available for the Players application
|
||||
* @returns {object[]}
|
||||
* @private
|
||||
*/
|
||||
_getUserContextOptions() {
|
||||
return [
|
||||
{
|
||||
name: game.i18n.localize("PLAYERS.ConfigTitle"),
|
||||
icon: '<i class="fas fa-male"></i>',
|
||||
condition: li => game.user.isGM || (li[0].dataset.userId === game.user.id),
|
||||
callback: li => {
|
||||
const user = game.users.get(li[0].dataset.userId);
|
||||
user?.sheet.render(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: game.i18n.localize("PLAYERS.ViewAvatar"),
|
||||
icon: '<i class="fas fa-image"></i>',
|
||||
condition: li => {
|
||||
const user = game.users.get(li[0].dataset.userId);
|
||||
return user.avatar !== CONST.DEFAULT_TOKEN;
|
||||
},
|
||||
callback: li => {
|
||||
let user = game.users.get(li.data("user-id"));
|
||||
new ImagePopout(user.avatar, {
|
||||
title: user.name,
|
||||
uuid: user.uuid
|
||||
}).render(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: game.i18n.localize("PLAYERS.PullToScene"),
|
||||
icon: '<i class="fas fa-directions"></i>',
|
||||
condition: li => game.user.isGM && (li[0].dataset.userId !== game.user.id),
|
||||
callback: li => game.socket.emit("pullToScene", canvas.scene.id, li.data("user-id"))
|
||||
},
|
||||
{
|
||||
name: game.i18n.localize("PLAYERS.Kick"),
|
||||
icon: '<i class="fas fa-door-open"></i>',
|
||||
condition: li => {
|
||||
const user = game.users.get(li[0].dataset.userId);
|
||||
return game.user.isGM && user.active && !user.isSelf;
|
||||
},
|
||||
callback: li => {
|
||||
const user = game.users.get(li[0].dataset.userId);
|
||||
return this.#kickUser(user);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: game.i18n.localize("PLAYERS.Ban"),
|
||||
icon: '<i class="fas fa-ban"></i>',
|
||||
condition: li => {
|
||||
const user = game.users.get(li[0].dataset.userId);
|
||||
return game.user.isGM && !user.isSelf && (user.role !== CONST.USER_ROLES.NONE);
|
||||
},
|
||||
callback: li => {
|
||||
const user = game.users.get(li[0].dataset.userId);
|
||||
return this.#banUser(user);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: game.i18n.localize("PLAYERS.UnBan"),
|
||||
icon: '<i class="fas fa-ban"></i>',
|
||||
condition: li => {
|
||||
const user = game.users.get(li[0].dataset.userId);
|
||||
return game.user.isGM && !user.isSelf && (user.role === CONST.USER_ROLES.NONE);
|
||||
},
|
||||
callback: li => {
|
||||
const user = game.users.get(li[0].dataset.userId);
|
||||
return this.#unbanUser(user);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: game.i18n.localize("WEBRTC.TooltipShowUser"),
|
||||
icon: '<i class="fas fa-eye"></i>',
|
||||
condition: li => {
|
||||
const userId = li.data("userId");
|
||||
return game.webrtc.settings.client.users[userId]?.blocked;
|
||||
},
|
||||
callback: async li => {
|
||||
const userId = li.data("userId");
|
||||
await game.webrtc.settings.set("client", `users.${userId}.blocked`, false);
|
||||
ui.webrtc.render();
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle display of the Players hud setting for whether to display offline players
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onToggleOfflinePlayers(event) {
|
||||
event.preventDefault();
|
||||
this._showOffline = !this._showOffline;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Temporarily remove a User from the World by banning and then un-banning them.
|
||||
* @param {User} user The User to kick
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #kickUser(user) {
|
||||
const role = user.role;
|
||||
await user.update({role: CONST.USER_ROLES.NONE});
|
||||
await user.update({role}, {diff: false});
|
||||
ui.notifications.info(`${user.name} has been <strong>kicked</strong> from the World.`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Ban a User by changing their role to "NONE".
|
||||
* @param {User} user The User to ban
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #banUser(user) {
|
||||
if ( user.role === CONST.USER_ROLES.NONE ) return;
|
||||
await user.update({role: CONST.USER_ROLES.NONE});
|
||||
ui.notifications.info(`${user.name} has been <strong>banned</strong> from the World.`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Unban a User by changing their role to "PLAYER".
|
||||
* @param {User} user The User to unban
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #unbanUser(user) {
|
||||
if ( user.role !== CONST.USER_ROLES.NONE ) return;
|
||||
await user.update({role: CONST.USER_ROLES.PLAYER});
|
||||
ui.notifications.info(`${user.name} has been <strong>unbanned</strong> from the World.`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user