Files
Foundry-VTT-Docker/resources/app/client/apps/hud/chatbubble.js
2025-01-04 00:34:03 +01:00

235 lines
8.0 KiB
JavaScript

/**
* @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);
}
}