This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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