Initial
This commit is contained in:
55
resources/app/client/apps/sidebar/apps/chat-popout.js
Normal file
55
resources/app/client/apps/sidebar/apps/chat-popout.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* A simple application which supports popping a ChatMessage out to a separate UI window.
|
||||
* @extends {Application}
|
||||
* @param {ChatMessage} object The {@link ChatMessage} object that is being popped out.
|
||||
* @param {ApplicationOptions} [options] Application configuration options.
|
||||
*/
|
||||
class ChatPopout extends Application {
|
||||
constructor(message, options) {
|
||||
super(options);
|
||||
|
||||
/**
|
||||
* The displayed Chat Message document
|
||||
* @type {ChatMessage}
|
||||
*/
|
||||
this.message = message;
|
||||
|
||||
// Register the application
|
||||
this.message.apps[this.appId] = this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
width: 300,
|
||||
height: "auto",
|
||||
classes: ["chat-popout"]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get id() {
|
||||
return `chat-popout-${this.message.id}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get title() {
|
||||
let title = this.message.flavor ?? this.message.speaker.alias;
|
||||
return TextEditor.previewHTML(title, 32);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _renderInner(_data) {
|
||||
const html = await this.message.getHTML();
|
||||
html.find(".message-delete").remove();
|
||||
return html;
|
||||
}
|
||||
}
|
||||
189
resources/app/client/apps/sidebar/apps/client-settings.js
Normal file
189
resources/app/client/apps/sidebar/apps/client-settings.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* The Application responsible for displaying and editing the client and world settings for this world.
|
||||
* This form renders the settings defined via the game.settings.register API which have config = true
|
||||
*/
|
||||
class SettingsConfig extends PackageConfiguration {
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
title: game.i18n.localize("SETTINGS.Title"),
|
||||
id: "client-settings",
|
||||
categoryTemplate: "templates/sidebar/apps/settings-config-category.html",
|
||||
submitButton: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_prepareCategoryData() {
|
||||
const gs = game.settings;
|
||||
const canConfigure = game.user.can("SETTINGS_MODIFY");
|
||||
let categories = new Map();
|
||||
let total = 0;
|
||||
|
||||
const getCategory = category => {
|
||||
let cat = categories.get(category.id);
|
||||
if ( !cat ) {
|
||||
cat = {
|
||||
id: category.id,
|
||||
title: category.title,
|
||||
menus: [],
|
||||
settings: [],
|
||||
count: 0
|
||||
};
|
||||
categories.set(category.id, cat);
|
||||
}
|
||||
return cat;
|
||||
};
|
||||
|
||||
// Classify all menus
|
||||
for ( let menu of gs.menus.values() ) {
|
||||
if ( menu.restricted && !canConfigure ) continue;
|
||||
if ( (menu.key === "core.permissions") && !game.user.hasRole("GAMEMASTER") ) continue;
|
||||
const category = getCategory(this._categorizeEntry(menu.namespace));
|
||||
category.menus.push(menu);
|
||||
total++;
|
||||
}
|
||||
|
||||
// Classify all settings
|
||||
for ( let setting of gs.settings.values() ) {
|
||||
if ( !setting.config || (!canConfigure && (setting.scope !== "client")) ) continue;
|
||||
|
||||
// Update setting data
|
||||
const s = foundry.utils.deepClone(setting);
|
||||
s.id = `${s.namespace}.${s.key}`;
|
||||
s.name = game.i18n.localize(s.name);
|
||||
s.hint = game.i18n.localize(s.hint);
|
||||
s.value = game.settings.get(s.namespace, s.key);
|
||||
s.type = setting.type instanceof Function ? setting.type.name : "String";
|
||||
s.isCheckbox = setting.type === Boolean;
|
||||
s.isSelect = s.choices !== undefined;
|
||||
s.isRange = (setting.type === Number) && s.range;
|
||||
s.isNumber = setting.type === Number;
|
||||
s.filePickerType = s.filePicker === true ? "any" : s.filePicker;
|
||||
s.dataField = setting.type instanceof foundry.data.fields.DataField ? setting.type : null;
|
||||
s.input = setting.input;
|
||||
|
||||
// Categorize setting
|
||||
const category = getCategory(this._categorizeEntry(setting.namespace));
|
||||
category.settings.push(s);
|
||||
total++;
|
||||
}
|
||||
|
||||
// Sort categories by priority and assign Counts
|
||||
for ( let category of categories.values() ) {
|
||||
category.count = category.menus.length + category.settings.length;
|
||||
}
|
||||
categories = Array.from(categories.values()).sort(this._sortCategories.bind(this));
|
||||
return {categories, total, user: game.user, canConfigure};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find(".submenu button").click(this._onClickSubmenu.bind(this));
|
||||
html.find('[name="core.fontSize"]').change(this._previewFontScaling.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle activating the button to configure User Role permissions
|
||||
* @param {Event} event The initial button click event
|
||||
* @private
|
||||
*/
|
||||
_onClickSubmenu(event) {
|
||||
event.preventDefault();
|
||||
const menu = game.settings.menus.get(event.currentTarget.dataset.key);
|
||||
if ( !menu ) return ui.notifications.error("No submenu found for the provided key");
|
||||
const app = new menu.type();
|
||||
return app.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Preview font scaling as the setting is changed.
|
||||
* @param {Event} event The triggering event.
|
||||
* @private
|
||||
*/
|
||||
_previewFontScaling(event) {
|
||||
const scale = Number(event.currentTarget.value);
|
||||
game.scaleFonts(scale);
|
||||
this.setPosition();
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async close(options={}) {
|
||||
game.scaleFonts();
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
let requiresClientReload = false;
|
||||
let requiresWorldReload = false;
|
||||
for ( let [k, v] of Object.entries(foundry.utils.flattenObject(formData)) ) {
|
||||
let s = game.settings.settings.get(k);
|
||||
let current = game.settings.get(s.namespace, s.key);
|
||||
if ( v === current ) continue;
|
||||
requiresClientReload ||= (s.scope === "client") && s.requiresReload;
|
||||
requiresWorldReload ||= (s.scope === "world") && s.requiresReload;
|
||||
await game.settings.set(s.namespace, s.key, v);
|
||||
}
|
||||
if ( requiresClientReload || requiresWorldReload ) this.constructor.reloadConfirm({world: requiresWorldReload});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button click to reset default settings
|
||||
* @param {Event} event The initial button click event
|
||||
* @private
|
||||
*/
|
||||
_onResetDefaults(event) {
|
||||
event.preventDefault();
|
||||
const form = this.element.find("form")[0];
|
||||
for ( let [k, v] of game.settings.settings.entries() ) {
|
||||
if ( !v.config ) continue;
|
||||
const input = form[k];
|
||||
if ( !input ) continue;
|
||||
if ( input.type === "checkbox" ) input.checked = v.default;
|
||||
else input.value = v.default;
|
||||
$(input).change();
|
||||
}
|
||||
ui.notifications.info("SETTINGS.ResetInfo", {localize: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Confirm if the user wishes to reload the application.
|
||||
* @param {object} [options] Additional options to configure the prompt.
|
||||
* @param {boolean} [options.world=false] Whether to reload all connected clients as well.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async reloadConfirm({world=false}={}) {
|
||||
const reload = await foundry.applications.api.DialogV2.confirm({
|
||||
id: "reload-world-confirm",
|
||||
modal: true,
|
||||
rejectClose: false,
|
||||
window: { title: "SETTINGS.ReloadPromptTitle" },
|
||||
position: { width: 400 },
|
||||
content: `<p>${game.i18n.localize("SETTINGS.ReloadPromptBody")}</p>`
|
||||
});
|
||||
if ( !reload ) return;
|
||||
if ( world && game.user.can("SETTINGS_MODIFY") ) game.socket.emit("reload");
|
||||
foundry.utils.debouncedReload();
|
||||
}
|
||||
}
|
||||
236
resources/app/client/apps/sidebar/apps/compendium.js
Normal file
236
resources/app/client/apps/sidebar/apps/compendium.js
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* An interface for displaying the content of a CompendiumCollection.
|
||||
* @param {CompendiumCollection} collection The {@link CompendiumCollection} object represented by this interface.
|
||||
* @param {ApplicationOptions} [options] Application configuration options.
|
||||
*/
|
||||
class Compendium extends DocumentDirectory {
|
||||
constructor(...args) {
|
||||
if ( args[0] instanceof Collection ) {
|
||||
foundry.utils.logCompatibilityWarning("Compendium constructor should now be passed a CompendiumCollection "
|
||||
+ "instance via {collection: compendiumCollection}", {
|
||||
since: 11,
|
||||
until: 13
|
||||
});
|
||||
args[1] ||= {};
|
||||
args[1].collection = args.shift();
|
||||
}
|
||||
super(...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get entryType() {
|
||||
return this.metadata.type;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static entryPartial = "templates/sidebar/partials/compendium-index-partial.html";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
template: "templates/apps/compendium.html",
|
||||
width: 350,
|
||||
height: window.innerHeight - 100,
|
||||
top: 70,
|
||||
left: 120,
|
||||
popOut: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
get id() {
|
||||
return `compendium-${this.collection.collection}`;
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get title() {
|
||||
const title = game.i18n.localize(this.collection.title);
|
||||
return this.collection.locked ? `${title} [${game.i18n.localize("PACKAGE.Locked")}]` : title;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get tabName() {
|
||||
return "Compendium";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get canCreateEntry() {
|
||||
const cls = getDocumentClass(this.collection.documentName);
|
||||
const isOwner = this.collection.testUserPermission(game.user, "OWNER");
|
||||
return !this.collection.locked && isOwner && cls.canUserCreate(game.user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get canCreateFolder() {
|
||||
return this.canCreateEntry;
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience redirection back to the metadata object of the associated CompendiumCollection
|
||||
* @returns {object}
|
||||
*/
|
||||
get metadata() {
|
||||
return this.collection.metadata;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
initialize() {
|
||||
this.collection.initializeTree();
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
/* Rendering */
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
render(force, options) {
|
||||
if ( !this.collection.visible ) {
|
||||
if ( force ) ui.notifications.warn("COMPENDIUM.CannotViewWarning", {localize: true});
|
||||
return this;
|
||||
}
|
||||
return super.render(force, options);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
const context = await super.getData(options);
|
||||
return foundry.utils.mergeObject(context, {
|
||||
collection: this.collection,
|
||||
index: this.collection.index,
|
||||
name: game.i18n.localize(this.metadata.label),
|
||||
footerButtons: []
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_entryAlreadyExists(document) {
|
||||
return (document.pack === this.collection.collection) && this.collection.index.has(document.id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _createDroppedEntry(document, folderId) {
|
||||
document = document.clone({folder: folderId || null}, {keepId: true});
|
||||
return this.collection.importDocument(document);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getEntryDragData(entryId) {
|
||||
return {
|
||||
type: this.collection.documentName,
|
||||
uuid: this.collection.getUuid(entryId)
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onCreateEntry(event) {
|
||||
// If this is an Adventure, use the Adventure Exporter application
|
||||
if ( this.collection.documentName === "Adventure" ) {
|
||||
const adventure = new Adventure({name: "New Adventure"}, {pack: this.collection.collection});
|
||||
return new CONFIG.Adventure.exporterClass(adventure).render(true);
|
||||
}
|
||||
return super._onCreateEntry(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getFolderDragData(folderId) {
|
||||
const folder = this.collection.folders.get(folderId);
|
||||
if ( !folder ) return null;
|
||||
return {
|
||||
type: "Folder",
|
||||
uuid: folder.uuid
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getFolderContextOptions() {
|
||||
const toRemove = ["OWNERSHIP.Configure", "FOLDER.Export"];
|
||||
return super._getFolderContextOptions().filter(o => !toRemove.includes(o.name));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getEntryContextOptions() {
|
||||
const isAdventure = this.collection.documentName === "Adventure";
|
||||
return [
|
||||
{
|
||||
name: "COMPENDIUM.ImportEntry",
|
||||
icon: '<i class="fas fa-download"></i>',
|
||||
condition: () => !isAdventure && this.collection.documentClass.canUserCreate(game.user),
|
||||
callback: li => {
|
||||
const collection = game.collections.get(this.collection.documentName);
|
||||
const id = li.data("document-id");
|
||||
return collection.importFromCompendium(this.collection, id, {}, {renderSheet: true});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "ADVENTURE.ExportEdit",
|
||||
icon: '<i class="fa-solid fa-edit"></i>',
|
||||
condition: () => isAdventure && game.user.isGM && !this.collection.locked,
|
||||
callback: async li => {
|
||||
const id = li.data("document-id");
|
||||
const document = await this.collection.getDocument(id);
|
||||
return new CONFIG.Adventure.exporterClass(document.clone({}, {keepId: true})).render(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SCENES.GenerateThumb",
|
||||
icon: '<i class="fas fa-image"></i>',
|
||||
condition: () => !this.collection.locked && (this.collection.documentName === "Scene"),
|
||||
callback: async li => {
|
||||
const scene = await this.collection.getDocument(li.data("document-id"));
|
||||
scene.createThumbnail().then(data => {
|
||||
scene.update({thumb: data.thumb}, {diff: false});
|
||||
ui.notifications.info(game.i18n.format("SCENES.GenerateThumbSuccess", {name: scene.name}));
|
||||
}).catch(err => ui.notifications.error(err.message));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "COMPENDIUM.DeleteEntry",
|
||||
icon: '<i class="fas fa-trash"></i>',
|
||||
condition: () => game.user.isGM && !this.collection.locked,
|
||||
callback: async li => {
|
||||
const id = li.data("document-id");
|
||||
const document = await this.collection.getDocument(id);
|
||||
return document.deleteDialog();
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
414
resources/app/client/apps/sidebar/apps/dependency-resolution.js
Normal file
414
resources/app/client/apps/sidebar/apps/dependency-resolution.js
Normal file
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* A class responsible for prompting the user about dependency resolution for their modules.
|
||||
*/
|
||||
class DependencyResolution extends FormApplication {
|
||||
/**
|
||||
* @typedef {object} DependencyResolutionInfo
|
||||
* @property {Module} module The module.
|
||||
* @property {boolean} checked Has the user toggled the checked state of this dependency in this application.
|
||||
* @property {string} [reason] Some reason associated with the dependency.
|
||||
* @property {boolean} [required] Whether this module is a hard requirement and cannot be unchecked.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {FormApplicationOptions} DependencyResolutionAppOptions
|
||||
* @property {boolean} enabling Whether the root dependency is being enabled or disabled.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {ModuleManagement} manager The module management application.
|
||||
* @param {Module} root The module that is the root of the dependency resolution.
|
||||
* @param {DependencyResolutionAppOptions} [options] Additional options that configure resolution behavior.
|
||||
*/
|
||||
constructor(manager, root, options={}) {
|
||||
super(root, options);
|
||||
this.#manager = manager;
|
||||
|
||||
// Always include the root module.
|
||||
this.#modules.set(root.id, root);
|
||||
|
||||
// Determine initial state.
|
||||
if ( options.enabling ) this.#initializeEnabling();
|
||||
else this.#initializeDisabling();
|
||||
}
|
||||
|
||||
/**
|
||||
* The full set of modules considered for dependency resolution stemming from the root module.
|
||||
* @type {Set<Module>}
|
||||
*/
|
||||
#candidates = new Set();
|
||||
|
||||
/**
|
||||
* The set of all modules dependent on a given module.
|
||||
* @type {Map<Module, Set<Module>>}
|
||||
*/
|
||||
#dependents = new Map();
|
||||
|
||||
/**
|
||||
* The module management application.
|
||||
* @type {ModuleManagement}
|
||||
*/
|
||||
#manager;
|
||||
|
||||
/**
|
||||
* A subset of the games modules that are currently active in the module manager.
|
||||
* @type {Map<string, Module>}
|
||||
*/
|
||||
#modules = new Map();
|
||||
|
||||
/**
|
||||
* Track the changes being made by the user as part of dependency resolution.
|
||||
* @type {Map<Module, DependencyResolutionInfo>}
|
||||
*/
|
||||
#resolution = new Map();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether there are additional dependencies that need resolving by the user.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get needsResolving() {
|
||||
if ( this.options.enabling ) return this.#candidates.size > 0;
|
||||
return (this.#candidates.size > 1) || !!this.#getUnavailableSubtypes();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {DependencyResolutionAppOptions}
|
||||
*/
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
enabling: true,
|
||||
template: "templates/setup/impacted-dependencies.html"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options={}) {
|
||||
const required = [];
|
||||
const optional = [];
|
||||
let subtypes;
|
||||
|
||||
if ( this.options.enabling ) {
|
||||
const context = this.#getDependencyContext();
|
||||
required.push(...context.required);
|
||||
optional.push(...context.optional);
|
||||
} else {
|
||||
optional.push(...this.#getUnusedContext());
|
||||
subtypes = this.#getUnavailableSubtypes();
|
||||
}
|
||||
|
||||
return {
|
||||
required, optional, subtypes,
|
||||
enabling: this.options.enabling
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find('input[type="checkbox"]').on("change", this._onChangeCheckbox.bind(this));
|
||||
html.find("[data-action]").on("click", this._onAction.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, options) {
|
||||
await super._render(force, options);
|
||||
this.setPosition({ height: "auto" });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the user toggling a dependency.
|
||||
* @param {Event} event The checkbox change event.
|
||||
* @protected
|
||||
*/
|
||||
_onChangeCheckbox(event) {
|
||||
const target = event.currentTarget;
|
||||
const module = this.#modules.get(target.name);
|
||||
const checked = target.checked;
|
||||
const resolution = this.#resolution.get(module);
|
||||
resolution.checked = checked;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button presses.
|
||||
* @param {PointerEvent} event The triggering event.
|
||||
* @protected
|
||||
*/
|
||||
_onAction(event) {
|
||||
const action = event.currentTarget.dataset.action;
|
||||
switch ( action ) {
|
||||
case "cancel":
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getSubmitData(updateData={}) {
|
||||
const fd = new FormDataExtended(this.form, { disabled: true });
|
||||
return foundry.utils.mergeObject(fd.object, updateData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
formData[this.object.id] = true;
|
||||
this.#manager._onSelectDependencies(formData, this.options.enabling);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return any modules that the root module is required by.
|
||||
* @returns {Set<Module>}
|
||||
* @internal
|
||||
*/
|
||||
_getRootRequiredBy() {
|
||||
const requiredBy = new Set();
|
||||
if ( this.options.enabling ) return requiredBy;
|
||||
const dependents = this.#dependents.get(this.object);
|
||||
for ( const dependent of (dependents ?? []) ) {
|
||||
if ( dependent.relationships.requires.find(({ id }) => id === this.object.id) ) {
|
||||
requiredBy.add(dependent);
|
||||
}
|
||||
}
|
||||
return requiredBy;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build the structure of modules that are dependent on other modules.
|
||||
*/
|
||||
#buildDependents() {
|
||||
const addDependent = (module, dep) => {
|
||||
dep = this.#modules.get(dep.id);
|
||||
if ( !dep ) return;
|
||||
if ( !this.#dependents.has(dep) ) this.#dependents.set(dep, new Set());
|
||||
const dependents = this.#dependents.get(dep);
|
||||
dependents.add(module);
|
||||
};
|
||||
|
||||
for ( const module of this.#modules.values() ) {
|
||||
for ( const dep of module.relationships.requires ) addDependent(module, dep);
|
||||
for ( const dep of module.relationships.recommends ) addDependent(module, dep);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Recurse down the dependency tree and gather modules that are required or optional.
|
||||
* @param {Set<Module>} [skip] If any of these modules are encountered in the graph, skip them.
|
||||
* @returns {Map<Module, DependencyResolutionInfo>}
|
||||
*/
|
||||
#getDependencies(skip=new Set()) {
|
||||
const resolution = new Map();
|
||||
|
||||
const addDependency = (module, { required=false, reason, dependent }={}) => {
|
||||
if ( !resolution.has(module) ) resolution.set(module, { module, checked: true });
|
||||
const info = resolution.get(module);
|
||||
if ( !info.required ) info.required = required;
|
||||
if ( reason ) {
|
||||
if ( info.reason ) info.reason += "<br>";
|
||||
info.reason += `${dependent.title}: ${reason}`;
|
||||
}
|
||||
};
|
||||
|
||||
const addDependencies = (module, deps, required=false) => {
|
||||
for ( const { id, reason } of deps ) {
|
||||
const dep = this.#modules.get(id);
|
||||
if ( !dep ) continue;
|
||||
const info = resolution.get(dep);
|
||||
|
||||
// Avoid cycles in the dependency graph.
|
||||
if ( info && (info.required === true || info.required === required) ) continue;
|
||||
|
||||
// Add every dependency we see so tha user can toggle them on and off, but do not traverse the graph any further
|
||||
// if we have indicated this dependency should be skipped.
|
||||
addDependency(dep, { reason, required, dependent: module });
|
||||
if ( skip.has(dep) ) continue;
|
||||
|
||||
addDependencies(dep, dep.relationships.requires, true);
|
||||
addDependencies(dep, dep.relationships.recommends);
|
||||
}
|
||||
};
|
||||
|
||||
addDependencies(this.object, this.object.relationships.requires, true);
|
||||
addDependencies(this.object, this.object.relationships.recommends);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the set of all modules that would be unused (i.e. have no dependents) if the given set of modules were
|
||||
* disabled.
|
||||
* @param {Set<Module>} disabling The set of modules that are candidates for disablement.
|
||||
* @returns {Set<Module>}
|
||||
*/
|
||||
#getUnused(disabling) {
|
||||
const unused = new Set();
|
||||
for ( const module of this.#modules.values() ) {
|
||||
const dependents = this.#dependents.get(module);
|
||||
if ( !dependents ) continue;
|
||||
|
||||
// What dependents are left if we remove the set of to-be-disabled modules?
|
||||
const remaining = dependents.difference(disabling);
|
||||
if ( !remaining.size ) unused.add(module);
|
||||
}
|
||||
return unused;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Find the maximum dependents that can be pruned if the root module is disabled.
|
||||
* Starting at the root module, add all modules that would become unused to the set of modules to disable. For each
|
||||
* module added in this way, check again for new modules that would become unused. Repeat until there are no more
|
||||
* unused modules.
|
||||
*/
|
||||
#initializeDisabling() {
|
||||
const disabling = new Set([this.object]);
|
||||
|
||||
// Initialize modules.
|
||||
for ( const module of game.modules ) {
|
||||
if ( this.#manager._isModuleChecked(module.id) ) this.#modules.set(module.id, module);
|
||||
}
|
||||
|
||||
// Initialize dependents.
|
||||
this.#buildDependents();
|
||||
|
||||
// Set a maximum iteration limit of 100 to prevent accidental infinite recursion.
|
||||
for ( let i = 0; i < 100; i++ ) {
|
||||
const unused = this.#getUnused(disabling);
|
||||
if ( !unused.size ) break;
|
||||
unused.forEach(disabling.add, disabling);
|
||||
}
|
||||
|
||||
this.#candidates = disabling;
|
||||
|
||||
// Initialize resolution state.
|
||||
for ( const module of disabling ) {
|
||||
this.#resolution.set(module, { module, checked: true, required: false });
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Find the full list of recursive dependencies for the root module.
|
||||
*/
|
||||
#initializeEnabling() {
|
||||
// Initialize modules.
|
||||
for ( const module of game.modules ) {
|
||||
if ( !this.#manager._isModuleChecked(module.id) ) this.#modules.set(module.id, module);
|
||||
}
|
||||
|
||||
// Traverse the dependency graph and locate dependencies that need activation.
|
||||
this.#resolution = this.#getDependencies();
|
||||
for ( const module of this.#resolution.keys() ) this.#candidates.add(module);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The list of modules that the user currently has selected, including the root module.
|
||||
* @returns {Set<Module>}
|
||||
*/
|
||||
#getSelectedModules() {
|
||||
const selected = new Set([this.object]);
|
||||
for ( const module of this.#candidates ) {
|
||||
const { checked } = this.#resolution.get(module);
|
||||
if ( checked ) selected.add(module);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* After the user has adjusted their choices, re-calculate the dependency graph.
|
||||
* Display all modules which are still in the set of reachable dependencies, preserving their checked states. If a
|
||||
* module is no longer reachable in the dependency graph (because there are no more checked modules that list it as
|
||||
* a dependency), do not display it to the user.
|
||||
* @returns {{required: DependencyResolutionInfo[], optional: DependencyResolutionInfo[]}}
|
||||
*/
|
||||
#getDependencyContext() {
|
||||
const skip = Array.from(this.#resolution.values()).reduce((acc, info) => {
|
||||
if ( info.checked === false ) acc.add(info.module);
|
||||
return acc;
|
||||
}, new Set());
|
||||
|
||||
const dependencies = this.#getDependencies(skip);
|
||||
const required = [];
|
||||
const optional = [];
|
||||
|
||||
for ( const module of this.#candidates ) {
|
||||
if ( !dependencies.has(module) ) continue;
|
||||
const info = this.#resolution.get(module);
|
||||
if ( info.required ) required.push(info);
|
||||
else optional.push(info);
|
||||
}
|
||||
|
||||
return { required, optional };
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* After the user has adjusted their choices, re-calculate which modules are still unused.
|
||||
* Display all modules which are still unused, preserving their checked states. If a module is no longer unused
|
||||
* (because a module that uses it was recently unchecked), do not display it to the user.
|
||||
* @returns {DependencyResolutionInfo[]}
|
||||
*/
|
||||
#getUnusedContext() {
|
||||
// Re-calculate unused modules after we remove those the user unchecked.
|
||||
const unused = this.#getUnused(this.#getSelectedModules());
|
||||
const context = [];
|
||||
for ( const module of this.#candidates ) {
|
||||
if ( unused.has(module) ) context.push(this.#resolution.get(module));
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a formatted string of the Documents that would be rendered unavailable if the currently-selected modules were
|
||||
* to be disabled.
|
||||
* @returns {string}
|
||||
*/
|
||||
#getUnavailableSubtypes() {
|
||||
const allCounts = {};
|
||||
for ( const module of this.#getSelectedModules() ) {
|
||||
const counts = game.issues.getSubTypeCountsFor(module);
|
||||
if ( !counts ) continue;
|
||||
Object.entries(counts).forEach(([documentName, subtypes]) => {
|
||||
const documentCounts = allCounts[documentName] ??= {};
|
||||
Object.entries(subtypes).forEach(([subtype, count]) => {
|
||||
documentCounts[subtype] = (documentCounts[subtype] ?? 0) + count;
|
||||
});
|
||||
});
|
||||
}
|
||||
return this.#manager._formatDocumentSummary(allCounts, true);
|
||||
}
|
||||
}
|
||||
79
resources/app/client/apps/sidebar/apps/invitation-links.js
Normal file
79
resources/app/client/apps/sidebar/apps/invitation-links.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Game Invitation Links Reference
|
||||
* @extends {Application}
|
||||
*/
|
||||
class InvitationLinks extends Application {
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "invitation-links",
|
||||
template: "templates/sidebar/apps/invitation-links.html",
|
||||
title: game.i18n.localize("INVITATIONS.Title"),
|
||||
width: 400
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
let addresses = game.data.addresses;
|
||||
// Check for IPv6 detection, and don't display connectivity info if so
|
||||
if ( addresses.remote === undefined ) return addresses;
|
||||
|
||||
// Otherwise, handle remote connection test
|
||||
if ( addresses.remoteIsAccessible == null ) {
|
||||
addresses.remoteClass = "unknown-connection";
|
||||
addresses.remoteTitle = game.i18n.localize("INVITATIONS.UnknownConnection");
|
||||
addresses.failedCheck = true;
|
||||
} else if ( addresses.remoteIsAccessible ) {
|
||||
addresses.remoteClass = "connection";
|
||||
addresses.remoteTitle = game.i18n.localize("INVITATIONS.OpenConnection");
|
||||
addresses.canConnect = true;
|
||||
} else {
|
||||
addresses.remoteClass = "no-connection";
|
||||
addresses.remoteTitle = game.i18n.localize("INVITATIONS.ClosedConnection");
|
||||
addresses.canConnect = false;
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find(".invite-link").click(ev => {
|
||||
ev.preventDefault();
|
||||
ev.target.select();
|
||||
game.clipboard.copyPlainText(ev.currentTarget.value);
|
||||
ui.notifications.info("INVITATIONS.Copied", {localize: true});
|
||||
});
|
||||
html.find(".refresh").click(ev => {
|
||||
ev.preventDefault();
|
||||
const icon = ev.currentTarget;
|
||||
icon.className = "fas fa-sync fa-pulse";
|
||||
let me = this;
|
||||
setTimeout(function(){
|
||||
game.socket.emit("refreshAddresses", addresses => {
|
||||
game.data.addresses = addresses;
|
||||
me.render(true);
|
||||
});
|
||||
}, 250)
|
||||
});
|
||||
html.find(".show-hide").click(ev => {
|
||||
ev.preventDefault();
|
||||
const icon = ev.currentTarget;
|
||||
const showLink = icon.classList.contains("show-link");
|
||||
if ( showLink ) {
|
||||
icon.classList.replace("fa-eye", "fa-eye-slash");
|
||||
icon.classList.replace("show-link", "hide-link");
|
||||
}
|
||||
else {
|
||||
icon.classList.replace("fa-eye-slash", "fa-eye");
|
||||
icon.classList.replace("hide-link", "show-link");
|
||||
}
|
||||
icon.closest("form").querySelector('#remote-link').type = showLink ? "text" : "password";
|
||||
});
|
||||
}
|
||||
}
|
||||
543
resources/app/client/apps/sidebar/apps/keybindings-config.js
Normal file
543
resources/app/client/apps/sidebar/apps/keybindings-config.js
Normal file
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* Allows for viewing and editing of Keybinding Actions
|
||||
*/
|
||||
class KeybindingsConfig extends PackageConfiguration {
|
||||
|
||||
/**
|
||||
* Categories present in the app. Within each category is an array of package data
|
||||
* @type {{categories: object[], total: number}}
|
||||
* @protected
|
||||
*/
|
||||
#cachedData;
|
||||
|
||||
/**
|
||||
* A Map of pending Edits. The Keys are bindingIds
|
||||
* @type {Map<string, KeybindingActionBinding[]>}
|
||||
* @private
|
||||
*/
|
||||
#pendingEdits = new Map();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
title: game.i18n.localize("SETTINGS.Keybindings"),
|
||||
id: "keybindings",
|
||||
categoryTemplate: "templates/sidebar/apps/keybindings-config-category.html",
|
||||
scrollY: [".scrollable"]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static get categoryOrder() {
|
||||
const categories = super.categoryOrder;
|
||||
categories.splice(2, 0, "core-mouse");
|
||||
return categories;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_categorizeEntry(namespace) {
|
||||
const category = super._categorizeEntry(namespace);
|
||||
if ( namespace === "core" ) category.title = game.i18n.localize("KEYBINDINGS.CoreKeybindings");
|
||||
return category;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_prepareCategoryData() {
|
||||
if ( this.#cachedData ) return this.#cachedData;
|
||||
|
||||
// Classify all Actions
|
||||
let categories = new Map();
|
||||
let totalActions = 0;
|
||||
const ctrlString = KeyboardManager.CONTROL_KEY_STRING;
|
||||
for ( let [actionId, action] of game.keybindings.actions ) {
|
||||
if ( action.restricted && !game.user.isGM ) continue;
|
||||
totalActions++;
|
||||
|
||||
// Determine what category the action belongs to
|
||||
let category = this._categorizeEntry(action.namespace);
|
||||
|
||||
// Carry over bindings for future rendering
|
||||
const actionData = foundry.utils.deepClone(action);
|
||||
actionData.category = category.title;
|
||||
actionData.id = actionId;
|
||||
actionData.name = game.i18n.localize(action.name);
|
||||
actionData.hint = game.i18n.localize(action.hint);
|
||||
actionData.cssClass = action.restricted ? "gm" : "";
|
||||
actionData.notes = [
|
||||
action.restricted ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
|
||||
action.reservedModifiers.length > 0 ? game.i18n.format("KEYBINDINGS.ReservedModifiers", {
|
||||
modifiers: action.reservedModifiers.map(m => m === "Control" ? ctrlString : m.titleCase()).join(", ")
|
||||
}) : "",
|
||||
game.i18n.localize(action.hint)
|
||||
].filterJoin("<br>");
|
||||
actionData.uneditable = action.uneditable;
|
||||
|
||||
// Prepare binding-level data
|
||||
actionData.bindings = (game.keybindings.bindings.get(actionId) ?? []).map((b, i) => {
|
||||
const uneditable = action.uneditable.includes(b);
|
||||
const binding = foundry.utils.deepClone(b);
|
||||
binding.id = `${actionId}.binding.${i}`;
|
||||
binding.display = KeybindingsConfig._humanizeBinding(binding);
|
||||
binding.cssClasses = uneditable ? "uneditable" : "";
|
||||
binding.isEditable = !uneditable;
|
||||
binding.isFirst = i === 0;
|
||||
const conflicts = this._detectConflictingActions(actionId, action, binding);
|
||||
binding.conflicts = game.i18n.format("KEYBINDINGS.Conflict", {
|
||||
conflicts: conflicts.map(action => game.i18n.localize(action.name)).join(", ")
|
||||
});
|
||||
binding.hasConflicts = conflicts.length > 0;
|
||||
return binding;
|
||||
});
|
||||
actionData.noBindings = actionData.bindings.length === 0;
|
||||
|
||||
// Register a category the first time it is seen, otherwise add to it
|
||||
if ( !categories.has(category.id) ) {
|
||||
categories.set(category.id, {
|
||||
id: category.id,
|
||||
title: category.title,
|
||||
actions: [actionData],
|
||||
count: 0
|
||||
});
|
||||
|
||||
} else categories.get(category.id).actions.push(actionData);
|
||||
}
|
||||
|
||||
// Add Mouse Controls
|
||||
totalActions += this._addMouseControlsReference(categories);
|
||||
|
||||
// Sort Actions by priority and assign Counts
|
||||
for ( let category of categories.values() ) {
|
||||
category.actions = category.actions.sort(ClientKeybindings._compareActions);
|
||||
category.count = category.actions.length;
|
||||
}
|
||||
categories = Array.from(categories.values()).sort(this._sortCategories.bind(this));
|
||||
return this.#cachedData = {categories, total: totalActions};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add faux-keybind actions that represent the possible Mouse Controls
|
||||
* @param {Map} categories The current Map of Categories to add to
|
||||
* @returns {number} The number of Actions added
|
||||
* @private
|
||||
*/
|
||||
_addMouseControlsReference(categories) {
|
||||
let coreMouseCategory = game.i18n.localize("KEYBINDINGS.CoreMouse");
|
||||
|
||||
const defineMouseAction = (id, name, keys, gmOnly=false) => {
|
||||
return {
|
||||
category: coreMouseCategory,
|
||||
id: id,
|
||||
name: game.i18n.localize(name),
|
||||
notes: gmOnly ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
|
||||
bindings: [
|
||||
{
|
||||
display: keys.map(k => game.i18n.localize(k)).join(" + "),
|
||||
cssClasses: "uneditable",
|
||||
isEditable: false,
|
||||
hasConflicts: false,
|
||||
isFirst: false
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const actions = [
|
||||
["canvas-select", "CONTROLS.CanvasSelect", ["CONTROLS.LeftClick"]],
|
||||
["canvas-select-many", "CONTROLS.CanvasSelectMany", ["Shift", "CONTROLS.LeftClick"]],
|
||||
["canvas-drag", "CONTROLS.CanvasLeftDrag", ["CONTROLS.LeftClick", "CONTROLS.Drag"]],
|
||||
["canvas-select-cancel", "CONTROLS.CanvasSelectCancel", ["CONTROLS.RightClick"]],
|
||||
["canvas-pan-mouse", "CONTROLS.CanvasPan", ["CONTROLS.RightClick", "CONTROLS.Drag"]],
|
||||
["canvas-zoom", "CONTROLS.CanvasSelectCancel", ["CONTROLS.MouseWheel"]],
|
||||
["ruler-measure", "CONTROLS.RulerMeasure", [KeyboardManager.CONTROL_KEY_STRING, "CONTROLS.LeftDrag"]],
|
||||
["ruler-measure-waypoint", "CONTROLS.RulerWaypoint", [KeyboardManager.CONTROL_KEY_STRING, "CONTROLS.LeftClick"]],
|
||||
["object-sheet", "CONTROLS.ObjectSheet", [`${game.i18n.localize("CONTROLS.Double")} ${game.i18n.localize("CONTROLS.LeftClick")}`]],
|
||||
["object-hud", "CONTROLS.ObjectHUD", ["CONTROLS.RightClick"]],
|
||||
["object-config", "CONTROLS.ObjectConfig", [`${game.i18n.localize("CONTROLS.Double")} ${game.i18n.localize("CONTROLS.RightClick")}`]],
|
||||
["object-drag", "CONTROLS.ObjectDrag", ["CONTROLS.LeftClick", "CONTROLS.Drag"]],
|
||||
["object-no-snap", "CONTROLS.ObjectNoSnap", ["CONTROLS.Drag", "Shift", "CONTROLS.Drop"]],
|
||||
["object-drag-cancel", "CONTROLS.ObjectDragCancel", [`${game.i18n.localize("CONTROLS.RightClick")} ${game.i18n.localize("CONTROLS.During")} ${game.i18n.localize("CONTROLS.Drag")}`]],
|
||||
["object-rotate-slow", "CONTROLS.ObjectRotateSlow", [KeyboardManager.CONTROL_KEY_STRING, "CONTROLS.MouseWheel"]],
|
||||
["object-rotate-fast", "CONTROLS.ObjectRotateFast", ["Shift", "CONTROLS.MouseWheel"]],
|
||||
["place-hidden-token", "CONTROLS.TokenPlaceHidden", ["Alt", "CONTROLS.Drop"], true],
|
||||
["token-target-mouse", "CONTROLS.TokenTarget", [`${game.i18n.localize("CONTROLS.Double")} ${game.i18n.localize("CONTROLS.RightClick")}`]],
|
||||
["canvas-ping", "CONTROLS.CanvasPing", ["CONTROLS.LongPress"]],
|
||||
["canvas-ping-alert", "CONTROLS.CanvasPingAlert", ["Alt", "CONTROLS.LongPress"]],
|
||||
["canvas-ping-pull", "CONTROLS.CanvasPingPull", ["Shift", "CONTROLS.LongPress"], true],
|
||||
["tooltip-lock", "CONTROLS.TooltipLock", ["CONTROLS.MiddleClick"]],
|
||||
["tooltip-dismiss", "CONTROLS.TooltipDismiss", ["CONTROLS.RightClick"]]
|
||||
];
|
||||
|
||||
let coreMouseCategoryData = {
|
||||
id: "core-mouse",
|
||||
title: coreMouseCategory,
|
||||
actions: actions.map(a => defineMouseAction(...a)),
|
||||
count: 0
|
||||
};
|
||||
coreMouseCategoryData.count = coreMouseCategoryData.actions.length;
|
||||
categories.set("core-mouse", coreMouseCategoryData);
|
||||
return coreMouseCategoryData.count;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given an Binding and its parent Action, detects other Actions that might conflict with that binding
|
||||
* @param {string} actionId The namespaced Action ID the Binding belongs to
|
||||
* @param {KeybindingActionConfig} action The Action config
|
||||
* @param {KeybindingActionBinding} binding The Binding
|
||||
* @returns {KeybindingAction[]}
|
||||
* @private
|
||||
*/
|
||||
_detectConflictingActions(actionId, action, binding) {
|
||||
|
||||
// Uneditable Core bindings are never wrong, they can never conflict with something
|
||||
if ( actionId.startsWith("core.") && action.uneditable.includes(binding) ) return [];
|
||||
|
||||
// Build fake context
|
||||
/** @type KeyboardEventContext */
|
||||
const context = KeyboardManager.getKeyboardEventContext({
|
||||
code: binding.key,
|
||||
shiftKey: binding.modifiers.includes(KeyboardManager.MODIFIER_KEYS.SHIFT),
|
||||
ctrlKey: binding.modifiers.includes(KeyboardManager.MODIFIER_KEYS.CONTROL),
|
||||
altKey: binding.modifiers.includes(KeyboardManager.MODIFIER_KEYS.ALT),
|
||||
repeat: false
|
||||
});
|
||||
|
||||
// Return matching keybinding actions (excluding this one)
|
||||
let matching = KeyboardManager._getMatchingActions(context);
|
||||
return matching.filter(a => a.action !== actionId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Transforms a Binding into a human-readable string representation
|
||||
* @param {KeybindingActionBinding} binding The Binding
|
||||
* @returns {string} A human readable string
|
||||
* @private
|
||||
*/
|
||||
static _humanizeBinding(binding) {
|
||||
const stringParts = binding.modifiers.reduce((parts, part) => {
|
||||
if ( KeyboardManager.MODIFIER_CODES[part]?.includes(binding.key) ) return parts;
|
||||
parts.unshift(KeyboardManager.getKeycodeDisplayString(part));
|
||||
return parts;
|
||||
}, [KeyboardManager.getKeycodeDisplayString(binding.key)]);
|
||||
return stringParts.join(" + ");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
const actionBindings = html.find(".action-bindings");
|
||||
actionBindings.on("dblclick", ".editable-binding", this._onDoubleClickKey.bind(this));
|
||||
actionBindings.on("click", ".control", this._onClickBindingControl.bind(this));
|
||||
actionBindings.on("keydown", ".binding-input", this._onKeydownBindingInput.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onResetDefaults(event) {
|
||||
return Dialog.confirm({
|
||||
title: game.i18n.localize("KEYBINDINGS.ResetTitle"),
|
||||
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("KEYBINDINGS.ResetWarning")}</p>`,
|
||||
yes: async () => {
|
||||
await game.keybindings.resetDefaults();
|
||||
this.#cachedData = undefined;
|
||||
this.#pendingEdits.clear();
|
||||
this.render();
|
||||
ui.notifications.info("KEYBINDINGS.ResetSuccess", {localize: true});
|
||||
},
|
||||
no: () => {},
|
||||
defaultYes: false
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle Control clicks
|
||||
* @param {MouseEvent} event
|
||||
* @private
|
||||
*/
|
||||
_onClickBindingControl(event) {
|
||||
const button = event.currentTarget;
|
||||
switch ( button.dataset.action ) {
|
||||
case "add":
|
||||
this._onClickAdd(event); break;
|
||||
case "delete":
|
||||
this._onClickDelete(event); break;
|
||||
case "edit":
|
||||
return this._onClickEditableBinding(event);
|
||||
case "save":
|
||||
return this._onClickSaveBinding(event);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle left-click events to show / hide a certain category
|
||||
* @param {MouseEvent} event
|
||||
* @private
|
||||
*/
|
||||
async _onClickAdd(event) {
|
||||
const {actionId, namespace, action} = this._getParentAction(event);
|
||||
const {bindingHtml, bindingId} = this._getParentBinding(event);
|
||||
const bindings = game.keybindings.bindings.get(actionId);
|
||||
const newBindingId = `${namespace}.${action}.binding.${bindings.length}`;
|
||||
const toInsert =
|
||||
`<li class="binding flexrow inserted" data-binding-id="${newBindingId}">
|
||||
<div class="editable-binding">
|
||||
<div class="form-fields binding-fields">
|
||||
<input type="text" class="binding-input" name="${newBindingId}" id="${newBindingId}" placeholder="Control + 1">
|
||||
<i class="far fa-keyboard binding-input-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="binding-controls flexrow">
|
||||
<a class="control save-edit" title="${game.i18n.localize("KEYBINDINGS.SaveBinding")}" data-action="save"><i class="fas fa-save"></i></a>
|
||||
<a class="control" title="${game.i18n.localize("KEYBINDINGS.DeleteBinding")}" data-action="delete"><i class="fas fa-trash-alt"></i></a>
|
||||
</div>
|
||||
</li>`;
|
||||
bindingHtml.closest(".action-bindings").insertAdjacentHTML("beforeend", toInsert);
|
||||
document.getElementById(newBindingId).focus();
|
||||
|
||||
// If this is an empty binding, delete it
|
||||
if ( bindingId === "empty" ) {
|
||||
bindingHtml.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle left-click events to show / hide a certain category
|
||||
* @param {MouseEvent} event
|
||||
* @private
|
||||
*/
|
||||
async _onClickDelete(event) {
|
||||
const {namespace, action} = this._getParentAction(event);
|
||||
const {bindingId} = this._getParentBinding(event);
|
||||
const bindingIndex = Number.parseInt(bindingId.split(".")[3]);
|
||||
this._addPendingEdit(namespace, action, bindingIndex, {index: bindingIndex, key: null});
|
||||
await this._savePendingEdits();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Inserts a Binding into the Pending Edits object, creating a new Map entry as needed
|
||||
* @param {string} namespace
|
||||
* @param {string} action
|
||||
* @param {number} bindingIndex
|
||||
* @param {KeybindingActionBinding} binding
|
||||
* @private
|
||||
*/
|
||||
_addPendingEdit(namespace, action, bindingIndex, binding) {
|
||||
// Save pending edits
|
||||
const pendingEditKey = `${namespace}.${action}`;
|
||||
if ( this.#pendingEdits.has(pendingEditKey) ) {
|
||||
// Filter out any existing pending edits for this Binding so we don't add each Key in "Shift + A"
|
||||
let currentBindings = this.#pendingEdits.get(pendingEditKey).filter(x => x.index !== bindingIndex);
|
||||
currentBindings.push(binding);
|
||||
this.#pendingEdits.set(pendingEditKey, currentBindings);
|
||||
} else {
|
||||
this.#pendingEdits.set(pendingEditKey, [binding]);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle visibility of the Edit / Save UI
|
||||
* @param {MouseEvent} event
|
||||
* @private
|
||||
*/
|
||||
_onClickEditableBinding(event) {
|
||||
const target = event.currentTarget;
|
||||
const bindingRow = target.closest("li.binding");
|
||||
target.classList.toggle("hidden");
|
||||
bindingRow.querySelector(".save-edit").classList.toggle("hidden");
|
||||
for ( let binding of bindingRow.querySelectorAll(".editable-binding") ) {
|
||||
binding.classList.toggle("hidden");
|
||||
binding.getElementsByClassName("binding-input")[0]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle visibility of the Edit UI
|
||||
* @param {MouseEvent} event
|
||||
* @private
|
||||
*/
|
||||
_onDoubleClickKey(event) {
|
||||
const target = event.currentTarget;
|
||||
|
||||
// If this is an inserted binding, don't try to swap to a non-edit mode
|
||||
if ( target.parentNode.parentNode.classList.contains("inserted") ) return;
|
||||
for ( let child of target.parentNode.getElementsByClassName("editable-binding") ) {
|
||||
child.classList.toggle("hidden");
|
||||
child.getElementsByClassName("binding-input")[0]?.focus();
|
||||
}
|
||||
const bindingRow = target.closest(".binding");
|
||||
for ( let child of bindingRow.getElementsByClassName("save-edit") ) {
|
||||
child.classList.toggle("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Save the new Binding value and update the display of the UI
|
||||
* @param {MouseEvent} event
|
||||
* @private
|
||||
*/
|
||||
async _onClickSaveBinding(event) {
|
||||
await this._savePendingEdits();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a clicked Action element, finds the parent Action
|
||||
* @param {MouseEvent|KeyboardEvent} event
|
||||
* @returns {{namespace: string, action: string, actionHtml: *}}
|
||||
* @private
|
||||
*/
|
||||
_getParentAction(event) {
|
||||
const actionHtml = event.currentTarget.closest(".action");
|
||||
const actionId = actionHtml.dataset.actionId;
|
||||
let [namespace, ...action] = actionId.split(".");
|
||||
action = action.join(".");
|
||||
return {actionId, actionHtml, namespace, action};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a Clicked binding control element, finds the parent Binding
|
||||
* @param {MouseEvent|KeyboardEvent} event
|
||||
* @returns {{bindingHtml: *, bindingId: string}}
|
||||
* @private
|
||||
*/
|
||||
_getParentBinding(event) {
|
||||
const bindingHtml = event.currentTarget.closest(".binding");
|
||||
const bindingId = bindingHtml.dataset.bindingId;
|
||||
return {bindingHtml, bindingId};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Iterates over all Pending edits, merging them in with unedited Bindings and then saving and resetting the UI
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async _savePendingEdits() {
|
||||
for ( let [id, pendingBindings] of this.#pendingEdits ) {
|
||||
let [namespace, ...action] = id.split(".");
|
||||
action = action.join(".");
|
||||
const bindingsData = game.keybindings.bindings.get(id);
|
||||
const actionData = game.keybindings.actions.get(id);
|
||||
|
||||
// Identify the set of bindings which should be saved
|
||||
const toSet = [];
|
||||
for ( const [index, binding] of bindingsData.entries() ) {
|
||||
if ( actionData.uneditable.includes(binding) ) continue;
|
||||
const {key, modifiers} = binding;
|
||||
toSet[index] = {key, modifiers};
|
||||
}
|
||||
for ( const binding of pendingBindings ) {
|
||||
const {index, key, modifiers} = binding;
|
||||
toSet[index] = {key, modifiers};
|
||||
}
|
||||
|
||||
// Try to save the binding, reporting any errors
|
||||
try {
|
||||
await game.keybindings.set(namespace, action, toSet.filter(b => !!b?.key));
|
||||
}
|
||||
catch(e) {
|
||||
ui.notifications.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset and rerender
|
||||
this.#cachedData = undefined;
|
||||
this.#pendingEdits.clear();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Processes input from the keyboard to form a list of pending Binding edits
|
||||
* @param {KeyboardEvent} event The keyboard event
|
||||
* @private
|
||||
*/
|
||||
_onKeydownBindingInput(event) {
|
||||
const context = KeyboardManager.getKeyboardEventContext(event);
|
||||
|
||||
// Stop propagation
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const {bindingHtml, bindingId} = this._getParentBinding(event);
|
||||
const {namespace, action} = this._getParentAction(event);
|
||||
|
||||
// Build pending Binding
|
||||
const bindingIdParts = bindingId.split(".");
|
||||
const bindingIndex = Number.parseInt(bindingIdParts[bindingIdParts.length - 1]);
|
||||
const {MODIFIER_KEYS, MODIFIER_CODES} = KeyboardManager;
|
||||
/** @typedef {KeybindingActionBinding} **/
|
||||
let binding = {
|
||||
index: bindingIndex,
|
||||
key: context.key,
|
||||
modifiers: []
|
||||
};
|
||||
if ( context.isAlt && !MODIFIER_CODES[MODIFIER_KEYS.ALT].includes(context.key) ) {
|
||||
binding.modifiers.push(MODIFIER_KEYS.ALT);
|
||||
}
|
||||
if ( context.isShift && !MODIFIER_CODES[MODIFIER_KEYS.SHIFT].includes(context.key) ) {
|
||||
binding.modifiers.push(MODIFIER_KEYS.SHIFT);
|
||||
}
|
||||
if ( context.isControl && !MODIFIER_CODES[MODIFIER_KEYS.CONTROL].includes(context.key) ) {
|
||||
binding.modifiers.push(MODIFIER_KEYS.CONTROL);
|
||||
}
|
||||
|
||||
// Save pending edits
|
||||
this._addPendingEdit(namespace, action, bindingIndex, binding);
|
||||
|
||||
// Predetect potential conflicts
|
||||
const conflicts = this._detectConflictingActions(`${namespace}.${action}`, game.keybindings.actions.get(`${namespace}.${action}`), binding);
|
||||
const conflictString = game.i18n.format("KEYBINDINGS.Conflict", {
|
||||
conflicts: conflicts.map(action => game.i18n.localize(action.name)).join(", ")
|
||||
});
|
||||
|
||||
// Remove existing conflicts and add a new one
|
||||
for ( const conflict of bindingHtml.getElementsByClassName("conflicts") ) {
|
||||
conflict.remove();
|
||||
}
|
||||
if ( conflicts.length > 0 ) {
|
||||
const conflictHtml = `<div class="control conflicts" title="${conflictString}"><i class="fas fa-exclamation-triangle"></i></div>`;
|
||||
bindingHtml.getElementsByClassName("binding-controls")[0].insertAdjacentHTML("afterbegin", conflictHtml);
|
||||
}
|
||||
|
||||
// Set value
|
||||
event.currentTarget.value = this.constructor._humanizeBinding(binding);
|
||||
}
|
||||
}
|
||||
411
resources/app/client/apps/sidebar/apps/module-management.js
Normal file
411
resources/app/client/apps/sidebar/apps/module-management.js
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* The Module Management Application.
|
||||
* This application provides a view of which modules are available to be used and allows for configuration of the
|
||||
* set of modules which are active within the World.
|
||||
*/
|
||||
class ModuleManagement extends FormApplication {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this._filter = this.isEditable ? "all" : "active";
|
||||
this._expanded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The named game setting which persists module configuration.
|
||||
* @type {string}
|
||||
*/
|
||||
static CONFIG_SETTING = "moduleConfiguration";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
title: game.i18n.localize("MODMANAGE.Title"),
|
||||
id: "module-management",
|
||||
template: "templates/sidebar/apps/module-management.html",
|
||||
popOut: true,
|
||||
width: 680,
|
||||
height: "auto",
|
||||
scrollY: [".package-list"],
|
||||
closeOnSubmit: false,
|
||||
filters: [{inputSelector: 'input[name="search"]', contentSelector: ".package-list"}]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get isEditable() {
|
||||
return game.user.can("SETTINGS_MODIFY");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
const editable = this.isEditable;
|
||||
const counts = {all: game.modules.size, active: 0, inactive: 0};
|
||||
|
||||
// Prepare modules
|
||||
const modules = game.modules.reduce((arr, module) => {
|
||||
const isActive = module.active;
|
||||
if ( isActive ) counts.active++;
|
||||
else if ( !editable ) return arr;
|
||||
else counts.inactive++;
|
||||
|
||||
const mod = module.toObject();
|
||||
mod.active = isActive;
|
||||
mod.css = isActive ? " active" : "";
|
||||
mod.hasPacks = mod.packs.length > 0;
|
||||
mod.hasScripts = mod.scripts.length > 0;
|
||||
mod.hasStyles = mod.styles.length > 0;
|
||||
mod.systemOnly = mod.relationships?.systems.find(s => s.id === game.system.id);
|
||||
mod.systemTag = game.system.id;
|
||||
mod.authors = mod.authors.map(a => {
|
||||
if ( a.url ) return `<a href="${a.url}" target="_blank">${a.name}</a>`;
|
||||
return a.name;
|
||||
}).join(", ");
|
||||
mod.tooltip = null; // No tooltip by default
|
||||
const requiredModules = Array.from(game.world.relationships.requires)
|
||||
.concat(Array.from(game.system.relationships.requires));
|
||||
mod.required = !!requiredModules.find(r => r.id === mod.id);
|
||||
if ( mod.required ) mod.tooltip = game.i18n.localize("MODMANAGE.RequiredModule");
|
||||
|
||||
// String formatting labels
|
||||
const authorsLabel = game.i18n.localize(`Author${module.authors.size > 1 ? "Pl" : ""}`);
|
||||
mod.labels = {authors: authorsLabel};
|
||||
mod.badge = module.getVersionBadge();
|
||||
|
||||
// Document counts.
|
||||
const subTypeCounts = game.issues.getSubTypeCountsFor(mod);
|
||||
if ( subTypeCounts ) mod.documents = this._formatDocumentSummary(subTypeCounts, isActive);
|
||||
|
||||
// If the current System is not one of the supported ones, don't return
|
||||
if ( mod.relationships?.systems.size > 0 && !mod.systemOnly ) return arr;
|
||||
|
||||
mod.enableable = true;
|
||||
this._evaluateDependencies(mod);
|
||||
this._evaluateSystemCompatibility(mod);
|
||||
mod.disabled = mod.required || !mod.enableable;
|
||||
return arr.concat([mod]);
|
||||
}, []).sort((a, b) => a.title.localeCompare(b.title, game.i18n.lang));
|
||||
|
||||
// Filters
|
||||
const filters = editable ? ["all", "active", "inactive"].map(f => ({
|
||||
id: f,
|
||||
label: game.i18n.localize(`MODMANAGE.Filter${f.titleCase()}`),
|
||||
count: counts[f] || 0
|
||||
})) : [];
|
||||
|
||||
// Return data for rendering
|
||||
return { editable, filters, modules, expanded: this._expanded };
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a module, determines if it meets minimum and maximum compatibility requirements of its dependencies.
|
||||
* If not, it is marked as being unable to be activated.
|
||||
* If the package does not meet verified requirements, it is marked with a warning instead.
|
||||
* @param {object} module The module.
|
||||
* @protected
|
||||
*/
|
||||
_evaluateDependencies(module) {
|
||||
for ( const required of module.relationships.requires ) {
|
||||
if ( required.type !== "module" ) continue;
|
||||
|
||||
// Verify the required package is installed
|
||||
const pkg = game.modules.get(required.id);
|
||||
if ( !pkg ) {
|
||||
module.enableable = false;
|
||||
required.class = "error";
|
||||
required.message = game.i18n.localize("SETUP.DependencyNotInstalled");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Test required package compatibility
|
||||
const c = required.compatibility;
|
||||
if ( !c ) continue;
|
||||
const dependencyVersion = pkg.version;
|
||||
if ( c.minimum && foundry.utils.isNewerVersion(c.minimum, dependencyVersion) ) {
|
||||
module.enableable = false;
|
||||
required.class = "error";
|
||||
required.message = game.i18n.format("SETUP.CompatibilityRequireUpdate",
|
||||
{ version: required.compatibility.minimum});
|
||||
continue;
|
||||
}
|
||||
if ( c.maximum && foundry.utils.isNewerVersion(dependencyVersion, c.maximum) ) {
|
||||
module.enableable = false;
|
||||
required.class = "error";
|
||||
required.message = game.i18n.format("SETUP.CompatibilityRequireDowngrade",
|
||||
{ version: required.compatibility.maximum});
|
||||
continue;
|
||||
}
|
||||
if ( c.verified && !foundry.utils.isNewerVersion(dependencyVersion, c.verified) ) {
|
||||
required.class = "warning";
|
||||
required.message = game.i18n.format("SETUP.CompatibilityRiskWithVersion",
|
||||
{version: required.compatibility.verified});
|
||||
}
|
||||
}
|
||||
|
||||
// Record that a module may not be able to be enabled
|
||||
if ( !module.enableable ) module.tooltip = game.i18n.localize("MODMANAGE.DependencyIssues");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a module, determine if it meets the minimum and maximum system compatibility requirements.
|
||||
* @param {object} module The module.
|
||||
* @protected
|
||||
*/
|
||||
_evaluateSystemCompatibility(module) {
|
||||
if ( !module.relationships.systems?.length ) return;
|
||||
const supportedSystem = module.relationships.systems.find(s => s.id === game.system.id);
|
||||
const {minimum, maximum} = supportedSystem?.compatibility ?? {};
|
||||
const {version} = game.system;
|
||||
if ( !minimum && !maximum ) return;
|
||||
if ( minimum && foundry.utils.isNewerVersion(minimum, version) ) {
|
||||
module.enableable = false;
|
||||
module.tooltip = game.i18n.format("MODMANAGE.SystemCompatibilityIssueMinimum", {minimum, version});
|
||||
}
|
||||
if ( maximum && foundry.utils.isNewerVersion(version, maximum) ) {
|
||||
module.enableable = false;
|
||||
module.tooltip = game.i18n.format("MODMANAGE.SystemCompatibilityIssueMaximum", {maximum, version});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find('button[name="deactivate"]').click(this._onDeactivateAll.bind(this));
|
||||
html.find(".filter").click(this._onFilterList.bind(this));
|
||||
html.find("button.expand").click(this._onExpandCollapse.bind(this));
|
||||
html.find('input[type="checkbox"]').change(this._onChangeCheckbox.bind(this));
|
||||
|
||||
// Allow users to filter modules even if they don't have permission to edit them.
|
||||
html.find('input[name="search"]').attr("disabled", false);
|
||||
html.find("button.expand").attr("disabled", false);
|
||||
|
||||
// Activate the appropriate filter.
|
||||
html.find(`a[data-filter="${this._filter}"]`).addClass("active");
|
||||
|
||||
// Initialize
|
||||
this._onExpandCollapse();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _renderInner(...args) {
|
||||
await loadTemplates(["templates/setup/parts/package-tags.hbs"]);
|
||||
return super._renderInner(...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getSubmitData(updateData={}) {
|
||||
const formData = super._getSubmitData(updateData);
|
||||
delete formData.search;
|
||||
return formData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _updateObject(event, formData) {
|
||||
const settings = game.settings.get("core", this.constructor.CONFIG_SETTING);
|
||||
const requiresReload = !foundry.utils.isEmpty(foundry.utils.diffObject(settings, formData));
|
||||
const setting = foundry.utils.mergeObject(settings, formData);
|
||||
const listFormatter = game.i18n.getListFormatter();
|
||||
|
||||
// Ensure all relationships are satisfied
|
||||
for ( let [k, v] of Object.entries(setting) ) {
|
||||
if ( v === false ) continue;
|
||||
const mod = game.modules.get(k);
|
||||
if ( !mod ) {
|
||||
delete setting[k];
|
||||
continue;
|
||||
}
|
||||
if ( !mod.relationships?.requires?.size ) continue;
|
||||
const missing = mod.relationships.requires.reduce((arr, d) => {
|
||||
if ( d.type && (d.type !== "module") ) return arr;
|
||||
if ( !setting[d.id] ) arr.push(d.id);
|
||||
return arr;
|
||||
}, []);
|
||||
if ( missing.length ) {
|
||||
const warning = game.i18n.format("MODMANAGE.DepMissing", {module: k, missing: listFormatter.format(missing)});
|
||||
this.options.closeOnSubmit = false;
|
||||
return ui.notifications.warn(warning);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the setting
|
||||
if ( requiresReload ) SettingsConfig.reloadConfirm({world: true});
|
||||
return game.settings.set("core", this.constructor.CONFIG_SETTING, setting);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the checked state of modules based on user dependency resolution.
|
||||
* @param {Record<string, boolean>} formData The dependency resolution result.
|
||||
* @param {boolean} enabling Whether the user was performing an enabling or disabling workflow.
|
||||
* @internal
|
||||
*/
|
||||
_onSelectDependencies(formData, enabling) {
|
||||
for ( const [id, checked] of Object.entries(formData) ) {
|
||||
this.form.elements[id].checked = enabling ? checked : !checked;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle changes to a module checkbox to prompt for whether to enable dependencies.
|
||||
* @param {Event} event The change event.
|
||||
* @protected
|
||||
*/
|
||||
async _onChangeCheckbox(event) {
|
||||
const input = event.target;
|
||||
const module = game.modules.get(input.name);
|
||||
const enabling = input.checked;
|
||||
const resolver = new DependencyResolution(this, module, { enabling });
|
||||
const requiredBy = resolver._getRootRequiredBy();
|
||||
|
||||
if ( requiredBy.size || resolver.needsResolving ) {
|
||||
this.form.elements[input.name].checked = !enabling;
|
||||
if ( requiredBy.size ) {
|
||||
// TODO: Rather than throwing an error, we should prompt the user to disable all dependent modules, as well as
|
||||
// all their dependents, recursively, and all unused modules that would result from those disablings.
|
||||
const listFormatter = game.i18n.getListFormatter();
|
||||
const dependents = listFormatter.format(Array.from(requiredBy).map(m => m.title));
|
||||
ui.notifications.error(game.i18n.format("MODMANAGE.RequiredDepError", { dependents }), { console: false });
|
||||
}
|
||||
else resolver.render(true);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a button-click to deactivate all modules
|
||||
* @private
|
||||
*/
|
||||
_onDeactivateAll(event) {
|
||||
event.preventDefault();
|
||||
for ( let input of this.element[0].querySelectorAll('input[type="checkbox"]') ) {
|
||||
if ( !input.disabled ) input.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle expanding or collapsing the display of descriptive elements
|
||||
* @private
|
||||
*/
|
||||
_onExpandCollapse(event) {
|
||||
event?.preventDefault();
|
||||
this._expanded = !this._expanded;
|
||||
this.form.querySelectorAll(".package-description").forEach(pack =>
|
||||
pack.classList.toggle("hidden", !this._expanded)
|
||||
);
|
||||
const icon = this.form.querySelector("i.fa");
|
||||
icon.classList.toggle("fa-angle-double-down", this._expanded);
|
||||
icon.classList.toggle("fa-angle-double-up", !this._expanded);
|
||||
icon.parentElement.title = this._expanded ?
|
||||
game.i18n.localize("Collapse") : game.i18n.localize("Expand");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle switching the module list filter.
|
||||
* @private
|
||||
*/
|
||||
_onFilterList(event) {
|
||||
event.preventDefault();
|
||||
this._filter = event.target.dataset.filter;
|
||||
|
||||
// Toggle the activity state of all filters.
|
||||
this.form.querySelectorAll("a[data-filter]").forEach(a =>
|
||||
a.classList.toggle("active", a.dataset.filter === this._filter));
|
||||
|
||||
// Iterate over modules and toggle their hidden states based on the chosen filter.
|
||||
const settings = game.settings.get("core", this.constructor.CONFIG_SETTING);
|
||||
const list = this.form.querySelector("#module-list");
|
||||
for ( const li of list.children ) {
|
||||
const name = li.dataset.moduleId;
|
||||
const isActive = settings[name] === true;
|
||||
const hidden = ((this._filter === "active") && !isActive) || ((this._filter === "inactive") && isActive);
|
||||
li.classList.toggle("hidden", hidden);
|
||||
}
|
||||
|
||||
// Re-apply any search filter query.
|
||||
const searchFilter = this._searchFilters[0];
|
||||
searchFilter.filter(null, searchFilter._input.value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onSearchFilter(event, query, rgx, html) {
|
||||
const settings = game.settings.get("core", this.constructor.CONFIG_SETTING);
|
||||
for ( let li of html.children ) {
|
||||
const name = li.dataset.moduleId;
|
||||
const isActive = settings[name] === true;
|
||||
if ( (this._filter === "active") && !isActive ) continue;
|
||||
if ( (this._filter === "inactive") && isActive ) continue;
|
||||
if ( !query ) {
|
||||
li.classList.remove("hidden");
|
||||
continue;
|
||||
}
|
||||
const title = (li.querySelector(".package-title")?.textContent || "").trim();
|
||||
const author = (li.querySelector(".author")?.textContent || "").trim();
|
||||
const match = rgx.test(SearchFilter.cleanQuery(name)) ||
|
||||
rgx.test(SearchFilter.cleanQuery(title)) ||
|
||||
rgx.test(SearchFilter.cleanQuery(author));
|
||||
li.classList.toggle("hidden", !match);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Format a document count collection for display.
|
||||
* @param {ModuleSubTypeCounts} counts An object of sub-type counts.
|
||||
* @param {boolean} isActive Whether the module is active.
|
||||
* @internal
|
||||
*/
|
||||
_formatDocumentSummary(counts, isActive) {
|
||||
return Object.entries(counts).map(([documentName, types]) => {
|
||||
let total = 0;
|
||||
const typesList = game.i18n.getListFormatter().format(Object.entries(types).map(([subType, count]) => {
|
||||
total += count;
|
||||
const label = game.i18n.localize(CONFIG[documentName].typeLabels?.[subType] ?? subType);
|
||||
return `<strong>${count}</strong> ${label}`;
|
||||
}));
|
||||
const cls = getDocumentClass(documentName);
|
||||
const label = total === 1 ? cls.metadata.label : cls.metadata.labelPlural;
|
||||
if ( isActive ) return `${typesList} ${game.i18n.localize(label)}`;
|
||||
return `<strong>${total}</strong> ${game.i18n.localize(label)}`;
|
||||
}).join(" • ");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check if a module is enabled currently in the application.
|
||||
* @param {string} id The module ID.
|
||||
* @returns {boolean}
|
||||
* @internal
|
||||
*/
|
||||
_isModuleChecked(id) {
|
||||
return !!this.form.elements[id]?.checked;
|
||||
}
|
||||
}
|
||||
365
resources/app/client/apps/sidebar/apps/support-details.js
Normal file
365
resources/app/client/apps/sidebar/apps/support-details.js
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Support Info and Report
|
||||
* @type {Application}
|
||||
*/
|
||||
class SupportDetails extends Application {
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.title = "SUPPORT.Title";
|
||||
options.id = "support-details";
|
||||
options.template = "templates/sidebar/apps/support-details.html";
|
||||
options.width = 780;
|
||||
options.height = 680;
|
||||
options.resizable = true;
|
||||
options.classes = ["sheet"];
|
||||
options.tabs = [{navSelector: ".tabs", contentSelector: "article", initial: "support"}];
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
const context = super.getData(options);
|
||||
|
||||
// Build report data
|
||||
context.report = await SupportDetails.generateSupportReport();
|
||||
|
||||
// Build document issues data.
|
||||
context.documentIssues = this._getDocumentValidationErrors();
|
||||
|
||||
// Build module issues data.
|
||||
context.moduleIssues = this._getModuleIssues();
|
||||
|
||||
// Build client issues data.
|
||||
context.clientIssues = Object.values(game.issues.usabilityIssues).map(({message, severity, params}) => {
|
||||
return {severity, message: params ? game.i18n.format(message, params) : game.i18n.localize(message)};
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find("button[data-action]").on("click", this._onClickAction.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force=false, options={}) {
|
||||
await super._render(force, options);
|
||||
if ( options.tab ) this._tabs[0].activate(options.tab);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _renderInner(data) {
|
||||
await loadTemplates({supportDetailsReport: "templates/sidebar/apps/parts/support-details-report.html"});
|
||||
return super._renderInner(data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a button click action.
|
||||
* @param {MouseEvent} event The click event.
|
||||
* @protected
|
||||
*/
|
||||
_onClickAction(event) {
|
||||
const action = event.currentTarget.dataset.action;
|
||||
switch ( action ) {
|
||||
case "copy":
|
||||
this._copyReport();
|
||||
break;
|
||||
|
||||
case "fullReport":
|
||||
this.#generateFullReport();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generate a more detailed support report and append it to the basic report.
|
||||
*/
|
||||
async #generateFullReport() {
|
||||
let fullReport = "";
|
||||
const report = document.getElementById("support-report");
|
||||
const [button] = this.element.find('[data-action="fullReport"]');
|
||||
const icon = button.querySelector("i");
|
||||
button.disabled = true;
|
||||
icon.className = "fas fa-spinner fa-spin-pulse";
|
||||
|
||||
const sizeInfo = await this.#getWorldSizeInfo();
|
||||
const { worldSizes, packSizes } = Object.entries(sizeInfo).reduce((obj, entry) => {
|
||||
const [collectionName] = entry;
|
||||
if ( collectionName.includes(".") ) obj.packSizes.push(entry);
|
||||
else obj.worldSizes.push(entry);
|
||||
return obj;
|
||||
}, { worldSizes: [], packSizes: [] });
|
||||
|
||||
fullReport += `\n${this.#drawBox(game.i18n.localize("SUPPORT.WorldData"))}\n\n`;
|
||||
fullReport += worldSizes.map(([collectionName, size]) => {
|
||||
let collection = game[collectionName];
|
||||
if ( collectionName === "fog" ) collection = game.collections.get("FogExploration");
|
||||
else if ( collectionName === "settings" ) collection = game.collections.get("Setting");
|
||||
return `${collection.name}: ${collection.size} | ${foundry.utils.formatFileSize(size, { decimalPlaces: 0 })}`;
|
||||
}).join("\n");
|
||||
|
||||
if ( packSizes.length ) {
|
||||
fullReport += `\n\n${this.#drawBox(game.i18n.localize("SUPPORT.CompendiumData"))}\n\n`;
|
||||
fullReport += packSizes.map(([collectionName, size]) => {
|
||||
const pack = game.packs.get(collectionName);
|
||||
const type = game.i18n.localize(pack.documentClass.metadata.labelPlural);
|
||||
size = foundry.utils.formatFileSize(size, { decimalPlaces: 0 });
|
||||
return `"${collectionName}": ${pack.index.size} ${type} | ${size}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
const activeModules = game.modules.filter(m => m.active);
|
||||
if ( activeModules.length ) {
|
||||
fullReport += `\n\n${this.#drawBox(game.i18n.localize("SUPPORT.ActiveModules"))}\n\n`;
|
||||
fullReport += activeModules.map(m => `${m.id} | ${m.version} | "${m.title}" | "${m.manifest}"`).join("\n");
|
||||
}
|
||||
|
||||
icon.className = "fas fa-check";
|
||||
report.innerText += fullReport;
|
||||
this.setPosition({ height: "auto" });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve information about the size of the World and any active compendiums.
|
||||
* @returns {Promise<Record<string, number>>}
|
||||
*/
|
||||
async #getWorldSizeInfo() {
|
||||
return new Promise(resolve => game.socket.emit("sizeInfo", resolve));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw an ASCII box around the given string for legibility in the full report.
|
||||
* @param {string} text The text.
|
||||
* @returns {string}
|
||||
*/
|
||||
#drawBox(text) {
|
||||
const border = `/* ${"-".repeat(44)} */`;
|
||||
return `${border}\n/* ${text}${" ".repeat(border.length - text.length - 6)}*/\n${border}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Copy the support details report to clipboard.
|
||||
* @protected
|
||||
*/
|
||||
_copyReport() {
|
||||
const report = document.getElementById("support-report");
|
||||
game.clipboard.copyPlainText(report.innerText);
|
||||
ui.notifications.info("SUPPORT.ReportCopied", {localize: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Marshal information on Documents that failed validation and format it for display.
|
||||
* @returns {object[]}
|
||||
* @protected
|
||||
*/
|
||||
_getDocumentValidationErrors() {
|
||||
const context = [];
|
||||
for ( const [documentName, documents] of Object.entries(game.issues.validationFailures) ) {
|
||||
const cls = getDocumentClass(documentName);
|
||||
const label = game.i18n.localize(cls.metadata.labelPlural);
|
||||
context.push({
|
||||
label,
|
||||
documents: Object.entries(documents).map(([id, {name, error}]) => {
|
||||
return {name: name ?? id, validationError: error.asHTML()};
|
||||
})
|
||||
});
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Marshal package-related warnings and errors and format it for display.
|
||||
* @returns {object[]}
|
||||
* @protected
|
||||
*/
|
||||
_getModuleIssues() {
|
||||
const errors = {label: game.i18n.localize("Errors"), issues: []};
|
||||
const warnings = {label: game.i18n.localize("Warnings"), issues: []};
|
||||
for ( const [moduleId, {error, warning}] of Object.entries(game.issues.packageCompatibilityIssues) ) {
|
||||
const label = game.modules.get(moduleId)?.title ?? moduleId;
|
||||
if ( error.length ) errors.issues.push({label, issues: error.map(message => ({severity: "error", message}))});
|
||||
if ( warning.length ) warnings.issues.push({
|
||||
label,
|
||||
issues: warning.map(message => ({severity: "warning", message}))
|
||||
});
|
||||
}
|
||||
const context = [];
|
||||
if ( errors.issues.length ) context.push(errors);
|
||||
if ( warnings.issues.length ) context.push(warnings);
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A bundle of metrics for Support
|
||||
* @typedef {Object} SupportReportData
|
||||
* @property {string} coreVersion
|
||||
* @property {string} systemVersion
|
||||
* @property {number} activeModuleCount
|
||||
* @property {string} os
|
||||
* @property {string} client
|
||||
* @property {string} gpu
|
||||
* @property {number|string} maxTextureSize
|
||||
* @property {string} sceneDimensions
|
||||
* @property {number} grid
|
||||
* @property {number} padding
|
||||
* @property {number} walls
|
||||
* @property {number} lights
|
||||
* @property {number} sounds
|
||||
* @property {number} tiles
|
||||
* @property {number} tokens
|
||||
* @property {number} actors
|
||||
* @property {number} items
|
||||
* @property {number} journals
|
||||
* @property {number} tables
|
||||
* @property {number} playlists
|
||||
* @property {number} packs
|
||||
* @property {number} messages
|
||||
* @property {number} performanceMode
|
||||
* @property {boolean} hasViewedScene
|
||||
* @property {string[]} worldScripts
|
||||
* @property {{width: number, height: number, [src]: string}} largestTexture
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collects a number of metrics that is useful for Support
|
||||
* @returns {Promise<SupportReportData>}
|
||||
*/
|
||||
static async generateSupportReport() {
|
||||
|
||||
// Create a WebGL Context if necessary
|
||||
let tempCanvas;
|
||||
let gl = canvas.app?.renderer?.gl;
|
||||
if ( !gl ) {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
if ( tempCanvas.getContext ) {
|
||||
gl = tempCanvas.getContext("webgl2") || tempCanvas.getContext("webgl") || tempCanvas.getContext("experimental-webgl");
|
||||
}
|
||||
}
|
||||
const rendererInfo = this.getWebGLRendererInfo(gl) ?? "Unknown Renderer";
|
||||
|
||||
let os = navigator.oscpu ?? "Unknown";
|
||||
let client = navigator.userAgent;
|
||||
|
||||
// Attempt to retrieve high-entropy Sec-CH headers.
|
||||
if ( navigator.userAgentData ) {
|
||||
const secCH = await navigator.userAgentData.getHighEntropyValues([
|
||||
"architecture", "model", "bitness", "platformVersion", "fullVersionList"
|
||||
]);
|
||||
|
||||
const { architecture, bitness, brands, platform, platformVersion, fullVersionList } = secCH;
|
||||
os = [platform, platformVersion, architecture, bitness ? `(${bitness}-bit)` : null].filterJoin(" ");
|
||||
const { brand, version } = fullVersionList?.[0] ?? brands?.[0] ?? {};
|
||||
client = `${brand}/${version}`;
|
||||
}
|
||||
|
||||
// Build report data
|
||||
const viewedScene = game.scenes.get(game.user.viewedScene);
|
||||
/** @type {Partial<SupportReportData>} **/
|
||||
const report = {
|
||||
os, client,
|
||||
coreVersion: `${game.release.display}, ${game.release.version}`,
|
||||
systemVersion: `${game.system.id}, ${game.system.version}`,
|
||||
activeModuleCount: Array.from(game.modules.values()).filter(x => x.active).length,
|
||||
performanceMode: game.settings.get("core", "performanceMode"),
|
||||
gpu: rendererInfo,
|
||||
maxTextureSize: gl && gl.getParameter ? gl.getParameter(gl.MAX_TEXTURE_SIZE) : "Could not detect",
|
||||
hasViewedScene: !!viewedScene,
|
||||
packs: game.packs.size,
|
||||
worldScripts: Array.from(game.world.esmodules).concat(...game.world.scripts).map(s => `"${s}"`).join(", ")
|
||||
};
|
||||
|
||||
// Attach Document Collection counts
|
||||
const reportCollections = ["actors", "items", "journal", "tables", "playlists", "messages"];
|
||||
for ( let c of reportCollections ) {
|
||||
const collection = game[c];
|
||||
report[c] = `${collection.size}${collection.invalidDocumentIds.size > 0 ?
|
||||
` (${collection.invalidDocumentIds.size} ${game.i18n.localize("Invalid")})` : ""}`;
|
||||
}
|
||||
|
||||
if ( viewedScene ) {
|
||||
report.sceneDimensions = `${viewedScene.dimensions.width} x ${viewedScene.dimensions.height}`;
|
||||
report.grid = viewedScene.grid.size;
|
||||
report.padding = viewedScene.padding;
|
||||
report.walls = viewedScene.walls.size;
|
||||
report.lights = viewedScene.lights.size;
|
||||
report.sounds = viewedScene.sounds.size;
|
||||
report.tiles = viewedScene.tiles.size;
|
||||
report.tokens = viewedScene.tokens.size;
|
||||
report.largestTexture = SupportDetails.#getLargestTexture();
|
||||
}
|
||||
|
||||
// Clean up temporary canvas
|
||||
if ( tempCanvas ) tempCanvas.remove();
|
||||
return report;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Find the largest texture in the scene.
|
||||
* @returns {{width: number, height: number, [src]: string}}
|
||||
*/
|
||||
static #getLargestTexture() {
|
||||
let largestTexture = { width: 0, height: 0 };
|
||||
|
||||
/**
|
||||
* Find any textures in the given DisplayObject or its children.
|
||||
* @param {DisplayObject} obj The object.
|
||||
*/
|
||||
function findTextures(obj) {
|
||||
if ( (obj instanceof PIXI.Sprite) || (obj instanceof SpriteMesh) || (obj instanceof PrimarySpriteMesh) ) {
|
||||
const texture = obj.texture?.baseTexture ?? {};
|
||||
const { width, height, resource } = texture;
|
||||
if ( Math.max(width, height) > Math.max(largestTexture.width, largestTexture.height) ) {
|
||||
largestTexture = { width, height, src: resource?.src };
|
||||
}
|
||||
}
|
||||
(obj?.children ?? []).forEach(findTextures);
|
||||
}
|
||||
|
||||
findTextures(canvas.stage);
|
||||
return largestTexture;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a WebGL renderer information string
|
||||
* @param {WebGLRenderingContext} gl The rendering context
|
||||
* @returns {string} The unmasked renderer string
|
||||
*/
|
||||
static getWebGLRendererInfo(gl) {
|
||||
if ( navigator.userAgent.match(/Firefox\/([0-9]+)\./) ) {
|
||||
return gl.getParameter(gl.RENDERER);
|
||||
} else {
|
||||
return gl.getParameter(gl.getExtension("WEBGL_debug_renderer_info").UNMASKED_RENDERER_WEBGL);
|
||||
}
|
||||
}
|
||||
}
|
||||
133
resources/app/client/apps/sidebar/apps/tours-management.js
Normal file
133
resources/app/client/apps/sidebar/apps/tours-management.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* A management app for configuring which Tours are available or have been completed.
|
||||
*/
|
||||
class ToursManagement extends PackageConfiguration {
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "tours-management",
|
||||
title: game.i18n.localize("SETTINGS.Tours"),
|
||||
categoryTemplate: "templates/sidebar/apps/tours-management-category.html"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_prepareCategoryData() {
|
||||
|
||||
// Classify all Actions
|
||||
let categories = new Map();
|
||||
let total = 0;
|
||||
for ( let tour of game.tours.values() ) {
|
||||
if ( !tour.config.display || (tour.config.restricted && !game.user.isGM) ) continue;
|
||||
total++;
|
||||
|
||||
// Determine what category the action belongs to
|
||||
let category = this._categorizeEntry(tour.namespace);
|
||||
|
||||
// Convert Tour to render data
|
||||
const tourData = {};
|
||||
tourData.category = category.title;
|
||||
tourData.id = `${tour.namespace}.${tour.id}`;
|
||||
tourData.title = game.i18n.localize(tour.title);
|
||||
tourData.description = game.i18n.localize(tour.description);
|
||||
tourData.cssClass = tour.config.restricted ? "gm" : "";
|
||||
tourData.notes = [
|
||||
tour.config.restricted ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
|
||||
tour.description
|
||||
].filterJoin("<br>");
|
||||
|
||||
switch ( tour.status ) {
|
||||
case Tour.STATUS.UNSTARTED: {
|
||||
tourData.status = game.i18n.localize("TOURS.NotStarted");
|
||||
tourData.canBePlayed = tour.canStart;
|
||||
tourData.canBeReset = false;
|
||||
tourData.startOrResume = game.i18n.localize("TOURS.Start");
|
||||
break;
|
||||
}
|
||||
case Tour.STATUS.IN_PROGRESS: {
|
||||
tourData.status = game.i18n.format("TOURS.InProgress", {
|
||||
current: tour.stepIndex + 1,
|
||||
total: tour.steps.length ?? 0
|
||||
});
|
||||
tourData.canBePlayed = tour.canStart;
|
||||
tourData.canBeReset = true;
|
||||
tourData.startOrResume = game.i18n.localize(`TOURS.${tour.config.canBeResumed ? "Resume" : "Restart"}`);
|
||||
break;
|
||||
}
|
||||
case Tour.STATUS.COMPLETED: {
|
||||
tourData.status = game.i18n.localize("TOURS.Completed");
|
||||
tourData.canBeReset = true;
|
||||
tourData.cssClass += " completed";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Register a category the first time it is seen, otherwise add to it
|
||||
if ( !categories.has(category.id) ) {
|
||||
categories.set(category.id, {
|
||||
id: category.id,
|
||||
title: category.title,
|
||||
tours: [tourData],
|
||||
count: 0
|
||||
});
|
||||
|
||||
} else categories.get(category.id).tours.push(tourData);
|
||||
}
|
||||
|
||||
// Sort Actions by priority and assign Counts
|
||||
for ( let category of categories.values() ) {
|
||||
category.count = category.tours.length;
|
||||
}
|
||||
categories = Array.from(categories.values()).sort(this._sortCategories.bind(this));
|
||||
return {categories, total};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find(".controls").on("click", ".control", this._onClickControl.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onResetDefaults(event) {
|
||||
return Dialog.confirm({
|
||||
title: game.i18n.localize("TOURS.ResetTitle"),
|
||||
content: `<p>${game.i18n.localize("TOURS.ResetWarning")}</p>`,
|
||||
yes: async () => {
|
||||
await Promise.all(game.tours.contents.map(tour => tour.reset()));
|
||||
ui.notifications.info("TOURS.ResetSuccess", {localize: true});
|
||||
this.render(true);
|
||||
},
|
||||
no: () => {},
|
||||
defaultYes: false
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle Control clicks
|
||||
* @param {MouseEvent} event
|
||||
* @private
|
||||
*/
|
||||
_onClickControl(event) {
|
||||
const button = event.currentTarget;
|
||||
const div = button.closest(".tour");
|
||||
const tour = game.tours.get(div.dataset.tour);
|
||||
switch ( button.dataset.action ) {
|
||||
case "play":
|
||||
this.close();
|
||||
return tour.start();
|
||||
case "reset": return tour.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
180
resources/app/client/apps/sidebar/apps/world-config.js
Normal file
180
resources/app/client/apps/sidebar/apps/world-config.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @typedef {FormApplicationOptions} WorldConfigOptions
|
||||
* @property {boolean} [create=false] Whether the world is being created or updated.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The World Management setup application
|
||||
* @param {World} object The world being configured.
|
||||
* @param {WorldConfigOptions} [options] Application configuration options.
|
||||
*/
|
||||
class WorldConfig extends FormApplication {
|
||||
/**
|
||||
* @override
|
||||
* @returns {WorldConfigOptions}
|
||||
*/
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "world-config",
|
||||
template: "templates/setup/world-config.hbs",
|
||||
width: 620,
|
||||
height: "auto",
|
||||
create: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A semantic alias for the World object which is being configured by this form.
|
||||
* @type {World}
|
||||
*/
|
||||
get world() {
|
||||
return this.object;
|
||||
}
|
||||
|
||||
/**
|
||||
* The website knowledge base URL.
|
||||
* @type {string}
|
||||
* @private
|
||||
*/
|
||||
static #WORLD_KB_URL = "https://foundryvtt.com/article/game-worlds/";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get title() {
|
||||
return this.options.create ? game.i18n.localize("WORLD.TitleCreate")
|
||||
: `${game.i18n.localize("WORLD.TitleEdit")}: ${this.world.title}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find('[name="title"]').on("input", this.#onTitleChange.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options={}) {
|
||||
const ac = CONST.PACKAGE_AVAILABILITY_CODES;
|
||||
const nextDate = new Date(this.world.nextSession || undefined);
|
||||
const context = {
|
||||
world: this.world,
|
||||
isCreate: this.options.create,
|
||||
submitText: game.i18n.localize(this.options.create ? "WORLD.TitleCreate" : "WORLD.SubmitEdit"),
|
||||
nextDate: nextDate.isValid() ? nextDate.toDateInputString() : "",
|
||||
nextTime: nextDate.isValid() ? nextDate.toTimeInputString() : "",
|
||||
worldKbUrl: WorldConfig.#WORLD_KB_URL,
|
||||
inWorld: !!game.world,
|
||||
themes: CONST.WORLD_JOIN_THEMES
|
||||
};
|
||||
context.showEditFields = !context.isCreate && !context.inWorld;
|
||||
if ( game.systems ) {
|
||||
context.systems = game.systems.filter(system => {
|
||||
if ( this.world.system === system.id ) return true;
|
||||
return ( system.availability <= ac.UNVERIFIED_GENERATION );
|
||||
}).sort((a, b) => a.title.localeCompare(b.title, game.i18n.lang));
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_getSubmitData(updateData={}) {
|
||||
const data = super._getSubmitData(updateData);
|
||||
|
||||
// Augment submission actions
|
||||
if ( this.options.create ) {
|
||||
data.action = "createWorld";
|
||||
if ( !data.id.length ) data.id = data.title.slugify({strict: true});
|
||||
}
|
||||
else {
|
||||
data.id = this.world.id;
|
||||
if ( !data.resetKeys ) delete data.resetKeys;
|
||||
if ( !data.safeMode ) delete data.safeMode;
|
||||
}
|
||||
|
||||
// Handle next session schedule fields
|
||||
if ( data.nextSession.some(t => !!t) ) {
|
||||
const now = new Date();
|
||||
const dateStr = `${data.nextSession[0] || now.toDateString()} ${data.nextSession[1] || now.toTimeString()}`;
|
||||
const date = new Date(dateStr);
|
||||
data.nextSession = isNaN(Number(date)) ? null : date.toISOString();
|
||||
}
|
||||
else data.nextSession = null;
|
||||
|
||||
if ( data.joinTheme === CONST.WORLD_JOIN_THEMES.default ) delete data.joinTheme;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
formData = foundry.utils.expandObject(formData);
|
||||
const form = event.target || this.form;
|
||||
form.disable = true;
|
||||
|
||||
// Validate the submission data
|
||||
try {
|
||||
this.world.validate({changes: formData, clean: true});
|
||||
formData.action = this.options.create ? "createWorld" : "editWorld";
|
||||
} catch(err) {
|
||||
ui.notifications.error(err.message.replace("\n", ". "));
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Dispatch the POST request
|
||||
let response;
|
||||
try {
|
||||
response = await foundry.utils.fetchJsonWithTimeout(foundry.utils.getRoute("setup"), {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
form.disabled = false;
|
||||
|
||||
// Display error messages
|
||||
if (response.error) return ui.notifications.error(response.error);
|
||||
}
|
||||
catch(e) {
|
||||
return ui.notifications.error(e);
|
||||
}
|
||||
|
||||
// Handle successful creation
|
||||
if ( formData.action === "createWorld" ) {
|
||||
const world = new this.world.constructor(response);
|
||||
game.worlds.set(world.id, world);
|
||||
}
|
||||
else this.world.updateSource(response);
|
||||
if ( ui.setup ) ui.setup.refresh(); // TODO old V10
|
||||
if ( ui.setupPackages ) ui.setupPackages.render(); // New v11
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the world name placeholder when the title is changed.
|
||||
* @param {Event} event The input change event
|
||||
* @private
|
||||
*/
|
||||
#onTitleChange(event) {
|
||||
let slug = this.form.elements.title.value.slugify({strict: true});
|
||||
if ( !slug.length ) slug = "world-name";
|
||||
this.form.elements.id?.setAttribute("placeholder", slug);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async activateEditor(name, options={}, initialContent="") {
|
||||
const toolbar = CONFIG.TinyMCE.toolbar.split(" ").filter(t => t !== "save").join(" ");
|
||||
foundry.utils.mergeObject(options, {toolbar});
|
||||
return super.activateEditor(name, options, initialContent);
|
||||
}
|
||||
}
|
||||
881
resources/app/client/apps/sidebar/directory-tab-mixin.js
Normal file
881
resources/app/client/apps/sidebar/directory-tab-mixin.js
Normal file
@@ -0,0 +1,881 @@
|
||||
/**
|
||||
* @typedef {Object} DirectoryMixinEntry
|
||||
* @property {string} id The unique id of the entry
|
||||
* @property {Folder|string} folder The folder id or folder object to which this entry belongs
|
||||
* @property {string} [img] An image path to display for the entry
|
||||
* @property {string} [sort] A numeric sort value which orders this entry relative to others
|
||||
* @interface
|
||||
*/
|
||||
|
||||
/**
|
||||
* Augment an Application instance with functionality that supports rendering as a directory of foldered entries.
|
||||
* @param {typeof Application} Base The base Application class definition
|
||||
* @returns {typeof DirectoryApplication} The decorated DirectoryApplication class definition
|
||||
*/
|
||||
function DirectoryApplicationMixin(Base) {
|
||||
return class DirectoryApplication extends Base {
|
||||
|
||||
/**
|
||||
* The path to the template partial which renders a single Entry within this directory
|
||||
* @type {string}
|
||||
*/
|
||||
static entryPartial = "templates/sidebar/partials/entry-partial.html";
|
||||
|
||||
/**
|
||||
* The path to the template partial which renders a single Folder within this directory
|
||||
* @type {string}
|
||||
*/
|
||||
static folderPartial = "templates/sidebar/folder-partial.html";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {DocumentDirectoryOptions}
|
||||
*/
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
renderUpdateKeys: ["name", "sort", "sorting", "folder"],
|
||||
height: "auto",
|
||||
scrollY: ["ol.directory-list"],
|
||||
dragDrop: [{dragSelector: ".directory-item", dropSelector: ".directory-list"}],
|
||||
filters: [{inputSelector: 'input[name="search"]', contentSelector: ".directory-list"}],
|
||||
contextMenuSelector: ".directory-item.document",
|
||||
entryClickSelector: ".entry-name"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The type of Entry that is contained in this DirectoryTab.
|
||||
* @type {string}
|
||||
*/
|
||||
get entryType() {
|
||||
throw new Error("You must implement the entryType getter for this DirectoryTab");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The maximum depth of folder nesting which is allowed in this DirectoryTab
|
||||
* @returns {number}
|
||||
*/
|
||||
get maxFolderDepth() {
|
||||
return this.collection.maxFolderDepth;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Can the current User create new Entries in this DirectoryTab?
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canCreateEntry() {
|
||||
return game.user.isGM;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Can the current User create new Folders in this DirectoryTab?
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canCreateFolder() {
|
||||
return this.canCreateEntry;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onSearchFilter(event, query, rgx, html) {
|
||||
const isSearch = !!query;
|
||||
let entryIds = new Set();
|
||||
const folderIds = new Set();
|
||||
const autoExpandFolderIds = new Set();
|
||||
|
||||
// Match entries and folders
|
||||
if ( isSearch ) {
|
||||
|
||||
// Include folders and their parents, auto-expanding parent folders
|
||||
const includeFolder = (folder, autoExpand = true) => {
|
||||
if ( !folder ) return;
|
||||
if ( typeof folder === "string" ) folder = this.collection.folders.get(folder);
|
||||
if ( !folder ) return;
|
||||
const folderId = folder._id;
|
||||
if ( folderIds.has(folderId) ) {
|
||||
// If this folder is not already auto-expanding, but it should be, add it to the set
|
||||
if ( autoExpand && !autoExpandFolderIds.has(folderId) ) autoExpandFolderIds.add(folderId);
|
||||
return;
|
||||
}
|
||||
folderIds.add(folderId);
|
||||
if ( autoExpand ) autoExpandFolderIds.add(folderId);
|
||||
if ( folder.folder ) includeFolder(folder.folder);
|
||||
};
|
||||
|
||||
// First match folders
|
||||
this._matchSearchFolders(rgx, includeFolder);
|
||||
|
||||
// Next match entries
|
||||
this._matchSearchEntries(rgx, entryIds, folderIds, includeFolder);
|
||||
}
|
||||
|
||||
// Toggle each directory item
|
||||
for ( let el of html.querySelectorAll(".directory-item") ) {
|
||||
if ( el.classList.contains("hidden") ) continue;
|
||||
if ( el.classList.contains("folder") ) {
|
||||
let match = isSearch && folderIds.has(el.dataset.folderId);
|
||||
el.style.display = (!isSearch || match) ? "flex" : "none";
|
||||
if ( autoExpandFolderIds.has(el.dataset.folderId) ) {
|
||||
if ( isSearch && match ) el.classList.remove("collapsed");
|
||||
}
|
||||
else el.classList.toggle("collapsed", !game.folders._expanded[el.dataset.uuid]);
|
||||
}
|
||||
else el.style.display = (!isSearch || entryIds.has(el.dataset.entryId)) ? "flex" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Identify folders in the collection which match a provided search query.
|
||||
* This method is factored out to be extended by subclasses, for example to support compendium indices.
|
||||
* @param {RegExp} query The search query
|
||||
* @param {Function} includeFolder A callback function to include the folder of any matched entry
|
||||
* @protected
|
||||
*/
|
||||
_matchSearchFolders(query, includeFolder) {
|
||||
for ( const folder of this.collection.folders ) {
|
||||
if ( query.test(SearchFilter.cleanQuery(folder.name)) ) {
|
||||
includeFolder(folder, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Identify entries in the collection which match a provided search query.
|
||||
* This method is factored out to be extended by subclasses, for example to support compendium indices.
|
||||
* @param {RegExp} query The search query
|
||||
* @param {Set<string>} entryIds The set of matched Entry IDs
|
||||
* @param {Set<string>} folderIds The set of matched Folder IDs
|
||||
* @param {Function} includeFolder A callback function to include the folder of any matched entry
|
||||
* @protected
|
||||
*/
|
||||
_matchSearchEntries(query, entryIds, folderIds, includeFolder) {
|
||||
const nameOnlySearch = (this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME);
|
||||
const entries = this.collection.index ?? this.collection.contents;
|
||||
|
||||
// Copy the folderIds to a new set so we can add to the original set without incorrectly adding child entries
|
||||
const matchedFolderIds = new Set(folderIds);
|
||||
|
||||
for ( const entry of entries ) {
|
||||
const entryId = this._getEntryId(entry);
|
||||
|
||||
// If we matched a folder, add its children entries
|
||||
if ( matchedFolderIds.has(entry.folder?._id ?? entry.folder) ) entryIds.add(entryId);
|
||||
|
||||
// Otherwise, if we are searching by name, match the entry name
|
||||
else if ( nameOnlySearch && query.test(SearchFilter.cleanQuery(this._getEntryName(entry))) ) {
|
||||
entryIds.add(entryId);
|
||||
includeFolder(entry.folder);
|
||||
}
|
||||
|
||||
}
|
||||
if ( nameOnlySearch ) return;
|
||||
|
||||
// Full Text Search
|
||||
const matches = this.collection.search({query: query.source, exclude: Array.from(entryIds)});
|
||||
for ( const match of matches ) {
|
||||
if ( entryIds.has(match._id) ) continue;
|
||||
entryIds.add(match._id);
|
||||
includeFolder(match.folder);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the name to search against for a given entry
|
||||
* @param {Document|object} entry The entry to get the name for
|
||||
* @returns {string} The name of the entry
|
||||
* @protected
|
||||
*/
|
||||
_getEntryName(entry) {
|
||||
return entry.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the ID for a given entry
|
||||
* @param {Document|object} entry The entry to get the id for
|
||||
* @returns {string} The id of the entry
|
||||
* @protected
|
||||
*/
|
||||
_getEntryId(entry) {
|
||||
return entry._id;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async getData(options) {
|
||||
const data = await super.getData(options);
|
||||
return foundry.utils.mergeObject(data, {
|
||||
tree: this.collection.tree,
|
||||
entryPartial: this.#getEntryPartial(),
|
||||
folderPartial: this.constructor.folderPartial,
|
||||
canCreateEntry: this.canCreateEntry,
|
||||
canCreateFolder: this.canCreateFolder,
|
||||
sortIcon: this.collection.sortingMode === "a" ? "fa-arrow-down-a-z" : "fa-arrow-down-short-wide",
|
||||
sortTooltip: this.collection.sortingMode === "a" ? "SIDEBAR.SortModeAlpha" : "SIDEBAR.SortModeManual",
|
||||
searchIcon: this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "fa-search" :
|
||||
"fa-file-magnifying-glass",
|
||||
searchTooltip: this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "SIDEBAR.SearchModeName" :
|
||||
"SIDEBAR.SearchModeFull"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _render(force, options) {
|
||||
await loadTemplates([this.#getEntryPartial(), this.constructor.folderPartial]);
|
||||
return super._render(force, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the entry partial.
|
||||
* @returns {string}
|
||||
*/
|
||||
#getEntryPartial() {
|
||||
/**
|
||||
* @deprecated since v11
|
||||
*/
|
||||
if ( this.constructor.documentPartial ) {
|
||||
foundry.utils.logCompatibilityWarning("Your sidebar application defines the documentPartial static property"
|
||||
+ " which is deprecated. Please use entryPartial instead.", {since: 11, until: 13});
|
||||
return this.constructor.documentPartial;
|
||||
}
|
||||
return this.constructor.entryPartial;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
const directory = html.find(".directory-list");
|
||||
const entries = directory.find(".directory-item");
|
||||
|
||||
// Handle folder depth and collapsing
|
||||
html.find(`[data-folder-depth="${this.maxFolderDepth}"] .create-folder`).remove();
|
||||
html.find(".toggle-sort").click(this.#onToggleSort.bind(this));
|
||||
html.find(".toggle-search-mode").click(this.#onToggleSearchMode.bind(this));
|
||||
html.find(".collapse-all").click(this.collapseAll.bind(this));
|
||||
|
||||
// Intersection Observer
|
||||
const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), { root: directory[0] });
|
||||
entries.each((i, li) => observer.observe(li));
|
||||
|
||||
// Entry-level events
|
||||
directory.on("click", this.options.entryClickSelector, this._onClickEntryName.bind(this));
|
||||
directory.on("click", ".folder-header", this._toggleFolder.bind(this));
|
||||
const dh = this._onDragHighlight.bind(this);
|
||||
html.find(".folder").on("dragenter", dh).on("dragleave", dh);
|
||||
this._contextMenu(html);
|
||||
|
||||
// Allow folder and entry creation
|
||||
if ( this.canCreateFolder ) html.find(".create-folder").click(this._onCreateFolder.bind(this));
|
||||
if ( this.canCreateEntry ) html.find(".create-entry").click(this._onCreateEntry.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Swap the sort mode between "a" (Alphabetical) and "m" (Manual by sort property)
|
||||
* @param {PointerEvent} event The originating pointer event
|
||||
*/
|
||||
#onToggleSort(event) {
|
||||
event.preventDefault();
|
||||
this.collection.toggleSortingMode();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Swap the search mode between "name" and "full"
|
||||
* @param {PointerEvent} event The originating pointer event
|
||||
*/
|
||||
#onToggleSearchMode(event) {
|
||||
event.preventDefault();
|
||||
this.collection.toggleSearchMode();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Collapse all subfolders in this directory
|
||||
*/
|
||||
collapseAll() {
|
||||
this.element.find("li.folder").addClass("collapsed");
|
||||
for ( let f of this.collection.folders ) {
|
||||
game.folders._expanded[f.uuid] = false;
|
||||
}
|
||||
if ( this.popOut ) this.setPosition();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new Folder in this SidebarDirectory
|
||||
* @param {PointerEvent} event The originating button click event
|
||||
* @protected
|
||||
*/
|
||||
_onCreateFolder(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const button = event.currentTarget;
|
||||
const li = button.closest(".directory-item");
|
||||
const data = {folder: li?.dataset?.folderId || null, type: this.entryType};
|
||||
const options = {top: button.offsetTop, left: window.innerWidth - 310 - FolderConfig.defaultOptions.width};
|
||||
if ( this.collection instanceof CompendiumCollection ) options.pack = this.collection.collection;
|
||||
Folder.createDialog(data, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling the collapsed or expanded state of a folder within the directory tab
|
||||
* @param {PointerEvent} event The originating click event
|
||||
* @protected
|
||||
*/
|
||||
_toggleFolder(event) {
|
||||
let folder = $(event.currentTarget.parentElement);
|
||||
let collapsed = folder.hasClass("collapsed");
|
||||
const folderUuid = folder[0].dataset.uuid;
|
||||
game.folders._expanded[folderUuid] = collapsed;
|
||||
|
||||
// Expand
|
||||
if ( collapsed ) folder.removeClass("collapsed");
|
||||
|
||||
// Collapse
|
||||
else {
|
||||
folder.addClass("collapsed");
|
||||
const subs = folder.find(".folder").addClass("collapsed");
|
||||
subs.each((i, f) => game.folders._expanded[folderUuid] = false);
|
||||
}
|
||||
|
||||
// Resize container
|
||||
if ( this.popOut ) this.setPosition();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clicking on a Document name in the Sidebar directory
|
||||
* @param {PointerEvent} event The originating click event
|
||||
* @protected
|
||||
*/
|
||||
async _onClickEntryName(event) {
|
||||
event.preventDefault();
|
||||
const element = event.currentTarget;
|
||||
const entryId = element.parentElement.dataset.entryId;
|
||||
const entry = this.collection.get(entryId);
|
||||
entry.sheet.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle new Entry creation request
|
||||
* @param {PointerEvent} event The originating button click event
|
||||
* @protected
|
||||
*/
|
||||
async _onCreateEntry(event) {
|
||||
throw new Error("You must implement the _onCreateEntry method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDragStart(event) {
|
||||
if ( ui.context ) ui.context.close({animate: false});
|
||||
const li = event.currentTarget.closest(".directory-item");
|
||||
const isFolder = li.classList.contains("folder");
|
||||
const dragData = isFolder
|
||||
? this._getFolderDragData(li.dataset.folderId)
|
||||
: this._getEntryDragData(li.dataset.entryId);
|
||||
if ( !dragData ) return;
|
||||
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the data transfer object for a Entry being dragged from this SidebarDirectory
|
||||
* @param {string} entryId The Entry's _id being dragged
|
||||
* @returns {Object}
|
||||
* @private
|
||||
*/
|
||||
_getEntryDragData(entryId) {
|
||||
const entry = this.collection.get(entryId);
|
||||
return entry?.toDragData();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the data transfer object for a Folder being dragged from this SidebarDirectory
|
||||
* @param {string} folderId The Folder _id being dragged
|
||||
* @returns {Object}
|
||||
* @private
|
||||
*/
|
||||
_getFolderDragData(folderId) {
|
||||
const folder = this.collection.folders.get(folderId);
|
||||
if ( !folder ) return null;
|
||||
return {
|
||||
type: "Folder",
|
||||
uuid: folder.uuid
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_canDragStart(selector) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Highlight folders as drop targets when a drag event enters or exits their area
|
||||
* @param {DragEvent} event The DragEvent which is in progress
|
||||
*/
|
||||
_onDragHighlight(event) {
|
||||
const li = event.currentTarget;
|
||||
if ( !li.classList.contains("folder") ) return;
|
||||
event.stopPropagation(); // Don't bubble to parent folders
|
||||
|
||||
// Remove existing drop targets
|
||||
if ( event.type === "dragenter" ) {
|
||||
for ( let t of li.closest(".directory-list").querySelectorAll(".droptarget") ) {
|
||||
t.classList.remove("droptarget");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove current drop target
|
||||
if ( event.type === "dragleave" ) {
|
||||
const el = document.elementFromPoint(event.clientX, event.clientY);
|
||||
const parent = el.closest(".folder");
|
||||
if ( parent === li ) return;
|
||||
}
|
||||
|
||||
// Add new drop target
|
||||
li.classList.toggle("droptarget", event.type === "dragenter");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDrop(event) {
|
||||
const data = TextEditor.getDragEventData(event);
|
||||
if ( !data.type ) return;
|
||||
const target = event.target.closest(".directory-item") || null;
|
||||
switch ( data.type ) {
|
||||
case "Folder":
|
||||
return this._handleDroppedFolder(target, data);
|
||||
case this.entryType:
|
||||
return this._handleDroppedEntry(target, data);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle Folder data being dropped into the directory.
|
||||
* @param {HTMLElement} target The target element
|
||||
* @param {object} data The data being dropped
|
||||
* @protected
|
||||
*/
|
||||
async _handleDroppedFolder(target, data) {
|
||||
|
||||
// Determine the closest Folder
|
||||
const closestFolder = target ? target.closest(".folder") : null;
|
||||
if ( closestFolder ) closestFolder.classList.remove("droptarget");
|
||||
const closestFolderId = closestFolder ? closestFolder.dataset.folderId : null;
|
||||
|
||||
// Obtain the dropped Folder
|
||||
let folder = await fromUuid(data.uuid);
|
||||
if ( !folder ) return;
|
||||
if ( folder?.type !== this.entryType ) {
|
||||
const typeLabel = game.i18n.localize(getDocumentClass(this.collection.documentName).metadata.label);
|
||||
ui.notifications.warn(game.i18n.format("FOLDER.InvalidDocumentType", {type: typeLabel}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort into another Folder
|
||||
const sortData = {sortKey: "sort", sortBefore: true};
|
||||
const isRelative = target && target.dataset.folderId;
|
||||
if ( isRelative ) {
|
||||
const targetFolder = await fromUuid(target.dataset.uuid);
|
||||
|
||||
// Sort relative to a collapsed Folder
|
||||
if ( target.classList.contains("collapsed") ) {
|
||||
sortData.target = targetFolder;
|
||||
sortData.parentId = targetFolder.folder?.id;
|
||||
sortData.parentUuid = targetFolder.folder?.uuid;
|
||||
}
|
||||
|
||||
// Drop into an expanded Folder
|
||||
else {
|
||||
sortData.target = null;
|
||||
sortData.parentId = targetFolder.id;
|
||||
sortData.parentUuid = targetFolder.uuid;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort relative to existing Folder contents
|
||||
else {
|
||||
sortData.parentId = closestFolderId;
|
||||
sortData.parentUuid = closestFolder?.dataset?.uuid;
|
||||
sortData.target = closestFolder && closestFolder.classList.contains("collapsed") ? closestFolder : null;
|
||||
}
|
||||
|
||||
if ( sortData.parentId ) {
|
||||
const parentFolder = await fromUuid(sortData.parentUuid);
|
||||
if ( parentFolder === folder ) return; // Prevent assigning a folder as its own parent.
|
||||
if ( parentFolder.ancestors.includes(folder) ) return; // Prevent creating a cycle.
|
||||
// Prevent going beyond max depth
|
||||
const maxDepth = f => Math.max(f.depth, ...f.children.filter(n => n.folder).map(n => maxDepth(n.folder)));
|
||||
if ( (parentFolder.depth + (maxDepth(folder) - folder.depth + 1)) > this.maxFolderDepth ) {
|
||||
ui.notifications.error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: this.maxFolderDepth}), {console: false});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine siblings
|
||||
sortData.siblings = this.collection.folders.filter(f => {
|
||||
return (f.folder?.id === sortData.parentId) && (f.type === folder.type) && (f !== folder);
|
||||
});
|
||||
|
||||
// Handle dropping of some folder that is foreign to this collection
|
||||
if ( this.collection.folders.get(folder.id) !== folder ) {
|
||||
const dropped = await this._handleDroppedForeignFolder(folder, closestFolderId, sortData);
|
||||
if ( !dropped || !dropped.sortNeeded ) return;
|
||||
folder = dropped.folder;
|
||||
}
|
||||
|
||||
// Resort the collection
|
||||
sortData.updateData = { folder: sortData.parentId };
|
||||
return folder.sortRelative(sortData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a new Folder being dropped into the directory.
|
||||
* This case is not handled by default, but subclasses may implement custom handling here.
|
||||
* @param {Folder} folder The Folder being dropped
|
||||
* @param {string} closestFolderId The closest Folder _id to the drop target
|
||||
* @param {object} sortData The sort data for the Folder
|
||||
* @param {string} sortData.sortKey The sort key to use for sorting
|
||||
* @param {boolean} sortData.sortBefore Sort before the target?
|
||||
* @returns {Promise<{folder: Folder, sortNeeded: boolean}|null>} The handled folder creation, or null
|
||||
* @protected
|
||||
*/
|
||||
async _handleDroppedForeignFolder(folder, closestFolderId, sortData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle Entry data being dropped into the directory.
|
||||
* @param {HTMLElement} target The target element
|
||||
* @param {object} data The data being dropped
|
||||
* @protected
|
||||
*/
|
||||
async _handleDroppedEntry(target, data) {
|
||||
// Determine the closest Folder
|
||||
const closestFolder = target ? target.closest(".folder") : null;
|
||||
if ( closestFolder ) closestFolder.classList.remove("droptarget");
|
||||
let folder = closestFolder ? await fromUuid(closestFolder.dataset.uuid) : null;
|
||||
|
||||
let entry = await this._getDroppedEntryFromData(data);
|
||||
if ( !entry ) return;
|
||||
|
||||
// Sort relative to another Document
|
||||
const collection = this.collection.index ?? this.collection;
|
||||
const sortData = {sortKey: "sort"};
|
||||
const isRelative = target && target.dataset.entryId;
|
||||
if ( isRelative ) {
|
||||
if ( entry.id === target.dataset.entryId ) return; // Don't drop on yourself
|
||||
const targetDocument = collection.get(target.dataset.entryId);
|
||||
sortData.target = targetDocument;
|
||||
folder = targetDocument?.folder;
|
||||
}
|
||||
|
||||
// Sort within to the closest Folder
|
||||
else sortData.target = null;
|
||||
|
||||
// Determine siblings
|
||||
if ( folder instanceof foundry.abstract.Document ) folder = folder.id;
|
||||
sortData.siblings = collection.filter(d => !this._entryIsSelf(d, entry) && this._entryBelongsToFolder(d, folder));
|
||||
|
||||
if ( !this._entryAlreadyExists(entry) ) {
|
||||
// Try to predetermine the sort order
|
||||
const sorted = SortingHelpers.performIntegerSort(entry, sortData);
|
||||
if ( sorted.length === 1 ) entry = entry.clone({sort: sorted[0].update[sortData.sortKey]}, {keepId: true});
|
||||
entry = await this._createDroppedEntry(entry, folder);
|
||||
|
||||
// No need to resort other documents if the document was created with a specific sort order
|
||||
if ( sorted.length === 1 ) return;
|
||||
}
|
||||
|
||||
// Resort the collection
|
||||
sortData.updateData = {folder: folder || null};
|
||||
return this._sortRelative(entry, sortData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine if an Entry is being compared to itself
|
||||
* @param {DirectoryMixinEntry} entry The Entry
|
||||
* @param {DirectoryMixinEntry} otherEntry The other Entry
|
||||
* @returns {boolean} Is the Entry being compared to itself?
|
||||
* @protected
|
||||
*/
|
||||
_entryIsSelf(entry, otherEntry) {
|
||||
return entry._id === otherEntry._id;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine whether an Entry belongs to the target folder
|
||||
* @param {DirectoryMixinEntry} entry The Entry
|
||||
* @param {Folder} folder The target folder
|
||||
* @returns {boolean} Is the Entry a sibling?
|
||||
* @protected
|
||||
*/
|
||||
_entryBelongsToFolder(entry, folder) {
|
||||
if ( !entry.folder && !folder ) return true;
|
||||
if ( entry.folder instanceof foundry.abstract.Document ) return entry.folder.id === folder;
|
||||
return entry.folder === folder;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check if an Entry is already present in the Collection
|
||||
* @param {DirectoryMixinEntry} entry The Entry being dropped
|
||||
* @returns {boolean} Is the Entry already present?
|
||||
* @private
|
||||
*/
|
||||
_entryAlreadyExists(entry) {
|
||||
return this.collection.get(entry.id) === entry;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the dropped Entry from the drop data
|
||||
* @param {object} data The data being dropped
|
||||
* @returns {Promise<DirectoryMixinEntry>} The dropped Entry
|
||||
* @protected
|
||||
*/
|
||||
async _getDroppedEntryFromData(data) {
|
||||
throw new Error("The _getDroppedEntryFromData method must be implemented");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a dropped Entry in this Collection
|
||||
* @param {DirectoryMixinEntry} entry The Entry being dropped
|
||||
* @param {string} [folderId] The ID of the Folder to which the Entry should be added
|
||||
* @returns {Promise<DirectoryMixinEntry>} The created Entry
|
||||
* @protected
|
||||
*/
|
||||
async _createDroppedEntry(entry, folderId) {
|
||||
throw new Error("The _createDroppedEntry method must be implemented");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Sort a relative entry within a collection
|
||||
* @param {DirectoryMixinEntry} entry The entry to sort
|
||||
* @param {object} sortData The sort data
|
||||
* @param {string} sortData.sortKey The sort key to use for sorting
|
||||
* @param {boolean} sortData.sortBefore Sort before the target?
|
||||
* @param {object} sortData.updateData Additional data to update on the entry
|
||||
* @returns {Promise<object>} The sorted entry
|
||||
*/
|
||||
async _sortRelative(entry, sortData) {
|
||||
throw new Error("The _sortRelative method must be implemented");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_contextMenu(html) {
|
||||
/**
|
||||
* A hook event that fires when the context menu for folders in this DocumentDirectory is constructed.
|
||||
* Substitute the class name in the hook event, for example "getActorDirectoryFolderContext".
|
||||
* @function getSidebarTabFolderContext
|
||||
* @memberof hookEvents
|
||||
* @param {DirectoryApplication} application The Application instance that the context menu is constructed in
|
||||
* @param {ContextMenuEntry[]} entryOptions The context menu entries
|
||||
*/
|
||||
ContextMenu.create(this, html, ".folder .folder-header", this._getFolderContextOptions(), {
|
||||
hookName: "FolderContext"
|
||||
});
|
||||
ContextMenu.create(this, html, this.options.contextMenuSelector, this._getEntryContextOptions());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the set of ContextMenu options which should be used for Folders in a SidebarDirectory
|
||||
* @returns {object[]} The Array of context options passed to the ContextMenu instance
|
||||
* @protected
|
||||
*/
|
||||
_getFolderContextOptions() {
|
||||
return [
|
||||
{
|
||||
name: "FOLDER.Edit",
|
||||
icon: '<i class="fas fa-edit"></i>',
|
||||
condition: game.user.isGM,
|
||||
callback: async header => {
|
||||
const li = header.closest(".directory-item")[0];
|
||||
const folder = await fromUuid(li.dataset.uuid);
|
||||
const r = li.getBoundingClientRect();
|
||||
const options = {top: r.top, left: r.left - FolderConfig.defaultOptions.width - 10};
|
||||
new FolderConfig(folder, options).render(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "FOLDER.CreateTable",
|
||||
icon: `<i class="${CONFIG.RollTable.sidebarIcon}"></i>`,
|
||||
condition: header => {
|
||||
const li = header.closest(".directory-item")[0];
|
||||
const folder = fromUuidSync(li.dataset.uuid);
|
||||
return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
|
||||
},
|
||||
callback: async header => {
|
||||
const li = header.closest(".directory-item")[0];
|
||||
const folder = await fromUuid(li.dataset.uuid);
|
||||
return Dialog.confirm({
|
||||
title: `${game.i18n.localize("FOLDER.CreateTable")}: ${folder.name}`,
|
||||
content: game.i18n.localize("FOLDER.CreateTableConfirm"),
|
||||
yes: () => RollTable.fromFolder(folder),
|
||||
options: {
|
||||
top: Math.min(li.offsetTop, window.innerHeight - 350),
|
||||
left: window.innerWidth - 680,
|
||||
width: 360
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "FOLDER.Remove",
|
||||
icon: '<i class="fas fa-trash"></i>',
|
||||
condition: game.user.isGM,
|
||||
callback: async header => {
|
||||
const li = header.closest(".directory-item")[0];
|
||||
const folder = await fromUuid(li.dataset.uuid);
|
||||
return Dialog.confirm({
|
||||
title: `${game.i18n.localize("FOLDER.Remove")} ${folder.name}`,
|
||||
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("FOLDER.RemoveWarning")}</p>`,
|
||||
yes: () => folder.delete({deleteSubfolders: false, deleteContents: false}),
|
||||
options: {
|
||||
top: Math.min(li.offsetTop, window.innerHeight - 350),
|
||||
left: window.innerWidth - 720,
|
||||
width: 400
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "FOLDER.Delete",
|
||||
icon: '<i class="fas fa-dumpster"></i>',
|
||||
condition: game.user.isGM && (this.entryType !== "Compendium"),
|
||||
callback: async header => {
|
||||
const li = header.closest(".directory-item")[0];
|
||||
const folder = await fromUuid(li.dataset.uuid);
|
||||
return Dialog.confirm({
|
||||
title: `${game.i18n.localize("FOLDER.Delete")} ${folder.name}`,
|
||||
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("FOLDER.DeleteWarning")}</p>`,
|
||||
yes: () => folder.delete({deleteSubfolders: true, deleteContents: true}),
|
||||
options: {
|
||||
top: Math.min(li.offsetTop, window.innerHeight - 350),
|
||||
left: window.innerWidth - 720,
|
||||
width: 400
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the set of ContextMenu options which should be used for Entries in a SidebarDirectory
|
||||
* @returns {object[]} The Array of context options passed to the ContextMenu instance
|
||||
* @protected
|
||||
*/
|
||||
_getEntryContextOptions() {
|
||||
return [
|
||||
{
|
||||
name: "FOLDER.Clear",
|
||||
icon: '<i class="fas fa-folder"></i>',
|
||||
condition: header => {
|
||||
const li = header.closest(".directory-item");
|
||||
const entry = this.collection.get(li.data("entryId"));
|
||||
return game.user.isGM && !!entry.folder;
|
||||
},
|
||||
callback: header => {
|
||||
const li = header.closest(".directory-item");
|
||||
const entry = this.collection.get(li.data("entryId"));
|
||||
entry.update({folder: null});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SIDEBAR.Delete",
|
||||
icon: '<i class="fas fa-trash"></i>',
|
||||
condition: () => game.user.isGM,
|
||||
callback: header => {
|
||||
const li = header.closest(".directory-item");
|
||||
const entry = this.collection.get(li.data("entryId"));
|
||||
if ( !entry ) return;
|
||||
return entry.deleteDialog({
|
||||
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
|
||||
left: window.innerWidth - 720
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SIDEBAR.Duplicate",
|
||||
icon: '<i class="far fa-copy"></i>',
|
||||
condition: () => game.user.isGM || this.collection.documentClass.canUserCreate(game.user),
|
||||
callback: header => {
|
||||
const li = header.closest(".directory-item");
|
||||
const original = this.collection.get(li.data("entryId"));
|
||||
return original.clone({name: `${original._source.name} (Copy)`}, {save: true, addSource: true});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
560
resources/app/client/apps/sidebar/document-directory.js
Normal file
560
resources/app/client/apps/sidebar/document-directory.js
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* @typedef {ApplicationOptions} DocumentDirectoryOptions
|
||||
* @property {string[]} [renderUpdateKeys] A list of data property keys that will trigger a rerender of the tab if
|
||||
* they are updated on a Document that this tab is responsible for.
|
||||
* @property {string} [contextMenuSelector] The CSS selector that activates the context menu for displayed Documents.
|
||||
* @property {string} [entryClickSelector] The CSS selector for the clickable area of an entry in the tab.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A shared pattern for the sidebar directory which Actors, Items, and Scenes all use
|
||||
* @extends {SidebarTab}
|
||||
* @abstract
|
||||
* @interface
|
||||
*
|
||||
* @param {DocumentDirectoryOptions} [options] Application configuration options.
|
||||
*/
|
||||
class DocumentDirectory extends DirectoryApplicationMixin(SidebarTab) {
|
||||
constructor(options={}) {
|
||||
super(options);
|
||||
|
||||
/**
|
||||
* References to the set of Documents which are displayed in the Sidebar
|
||||
* @type {ClientDocument[]}
|
||||
*/
|
||||
this.documents = null;
|
||||
|
||||
/**
|
||||
* Reference the set of Folders which exist in this Sidebar
|
||||
* @type {Folder[]}
|
||||
*/
|
||||
this.folders = null;
|
||||
|
||||
// If a collection was provided, use it instead of the default
|
||||
this.#collection = options.collection ?? this.constructor.collection;
|
||||
|
||||
// Initialize sidebar content
|
||||
this.initialize();
|
||||
|
||||
// Record the directory as an application of the collection if it is not a popout
|
||||
if ( !this.options.popOut ) this.collection.apps.push(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the named Document type that this Sidebar Directory instance displays
|
||||
* @type {string}
|
||||
*/
|
||||
static documentName = "Document";
|
||||
|
||||
/** @override */
|
||||
static entryPartial = "templates/sidebar/partials/document-partial.html";
|
||||
|
||||
/** @override */
|
||||
get entryType() {
|
||||
return this.constructor.documentName;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {DocumentDirectoryOptions}
|
||||
*/
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
template: "templates/sidebar/document-directory.html",
|
||||
renderUpdateKeys: ["name", "img", "thumb", "ownership", "sort", "sorting", "folder"]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
get title() {
|
||||
const cls = getDocumentClass(this.constructor.documentName);
|
||||
return `${game.i18n.localize(cls.metadata.labelPlural)} Directory`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
get id() {
|
||||
const cls = getDocumentClass(this.constructor.documentName);
|
||||
const pack = cls.metadata.collection;
|
||||
return `${pack}${this._original ? "-popout" : ""}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
get tabName() {
|
||||
const cls = getDocumentClass(this.constructor.documentName);
|
||||
return cls.metadata.collection;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The WorldCollection instance which this Sidebar Directory displays.
|
||||
* @type {WorldCollection}
|
||||
*/
|
||||
static get collection() {
|
||||
return game.collections.get(this.documentName);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The collection of Documents which are displayed in this Sidebar Directory
|
||||
* @type {DocumentCollection}
|
||||
*/
|
||||
get collection() {
|
||||
return this.#collection;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A per-instance reference to a collection of documents which are displayed in this Sidebar Directory. If set, supersedes the World Collection
|
||||
* @private
|
||||
*/
|
||||
#collection;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Initialization Helpers */
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the content of the directory by categorizing folders and documents into a hierarchical tree structure.
|
||||
*/
|
||||
initialize() {
|
||||
|
||||
// Assign Folders
|
||||
this.folders = this.collection.folders.contents;
|
||||
|
||||
// Assign Documents
|
||||
this.documents = this.collection.filter(e => e.visible);
|
||||
|
||||
// Build Tree
|
||||
this.collection.initializeTree();
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Application Rendering
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, context={}) {
|
||||
|
||||
// Only re-render the sidebar directory for certain types of updates
|
||||
const {renderContext, renderData} = context;
|
||||
if ( (renderContext === `update${this.entryType}`) && !renderData?.some(d => {
|
||||
return this.options.renderUpdateKeys.some(k => foundry.utils.hasProperty(d, k));
|
||||
}) ) return;
|
||||
|
||||
// Re-build the tree and render
|
||||
this.initialize();
|
||||
return super._render(force, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get canCreateEntry() {
|
||||
const cls = getDocumentClass(this.constructor.documentName);
|
||||
return cls.canUserCreate(game.user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get canCreateFolder() {
|
||||
return this.canCreateEntry;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
const context = await super.getData(options);
|
||||
const cfg = CONFIG[this.collection.documentName];
|
||||
const cls = cfg.documentClass;
|
||||
return foundry.utils.mergeObject(context, {
|
||||
documentCls: cls.documentName.toLowerCase(),
|
||||
tabName: cls.metadata.collection,
|
||||
sidebarIcon: cfg.sidebarIcon,
|
||||
folderIcon: CONFIG.Folder.sidebarIcon,
|
||||
label: game.i18n.localize(cls.metadata.label),
|
||||
labelPlural: game.i18n.localize(cls.metadata.labelPlural),
|
||||
unavailable: game.user.isGM ? cfg.collection?.instance?.invalidDocumentIds?.size : 0
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find(".show-issues").on("click", () => new SupportDetails().render(true, {tab: "documents"}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onClickEntryName(event) {
|
||||
event.preventDefault();
|
||||
const element = event.currentTarget;
|
||||
const documentId = element.parentElement.dataset.documentId;
|
||||
const document = this.collection.get(documentId) ?? await this.collection.getDocument(documentId);
|
||||
document.sheet.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onCreateEntry(event, { _skipDeprecated=false }={}) {
|
||||
/**
|
||||
* @deprecated since v11
|
||||
*/
|
||||
if ( (this._onCreateDocument !== DocumentDirectory.prototype._onCreateDocument) && !_skipDeprecated ) {
|
||||
foundry.utils.logCompatibilityWarning("DocumentDirectory#_onCreateDocument is deprecated. "
|
||||
+ "Please use DocumentDirectory#_onCreateEntry instead.", {since: 11, until: 13});
|
||||
return this._onCreateDocument(event);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const button = event.currentTarget;
|
||||
const li = button.closest(".directory-item");
|
||||
const data = {folder: li?.dataset?.folderId};
|
||||
const options = {width: 320, left: window.innerWidth - 630, top: button.offsetTop };
|
||||
if ( this.collection instanceof CompendiumCollection ) options.pack = this.collection.collection;
|
||||
const cls = getDocumentClass(this.collection.documentName);
|
||||
return cls.createDialog(data, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDrop(event) {
|
||||
const data = TextEditor.getDragEventData(event);
|
||||
if ( !data.type ) return;
|
||||
const target = event.target.closest(".directory-item") || null;
|
||||
|
||||
// Call the drop handler
|
||||
switch ( data.type ) {
|
||||
case "Folder":
|
||||
return this._handleDroppedFolder(target, data);
|
||||
case this.collection.documentName:
|
||||
return this._handleDroppedEntry(target, data);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _handleDroppedEntry(target, data, { _skipDeprecated=false }={}) {
|
||||
/**
|
||||
* @deprecated since v11
|
||||
*/
|
||||
if ( (this._handleDroppedDocument !== DocumentDirectory.prototype._handleDroppedDocument) && !_skipDeprecated ) {
|
||||
foundry.utils.logCompatibilityWarning("DocumentDirectory#_handleDroppedDocument is deprecated. "
|
||||
+ "Please use DocumentDirectory#_handleDroppedEntry instead.", {since: 11, until: 13});
|
||||
return this._handleDroppedDocument(target, data);
|
||||
}
|
||||
|
||||
return super._handleDroppedEntry(target, data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _getDroppedEntryFromData(data) {
|
||||
const cls = this.collection.documentClass;
|
||||
return cls.fromDropData(data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _sortRelative(entry, sortData) {
|
||||
return entry.sortRelative(sortData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _createDroppedEntry(document, folderId) {
|
||||
const data = document.toObject();
|
||||
data.folder = folderId || null;
|
||||
return document.constructor.create(data, {fromCompendium: !!document.compendium });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _handleDroppedForeignFolder(folder, closestFolderId, sortData) {
|
||||
const createdFolders = await this._createDroppedFolderContent(folder, this.collection.folders.get(closestFolderId));
|
||||
if ( createdFolders.length ) folder = createdFolders[0];
|
||||
return {
|
||||
sortNeeded: true,
|
||||
folder: folder
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a dropped Folder and its children in this Collection, if they do not already exist
|
||||
* @param {Folder} folder The Folder being dropped
|
||||
* @param {Folder} targetFolder The Folder to which the Folder should be added
|
||||
* @returns {Promise<Array<Folder>>} The created Folders
|
||||
* @protected
|
||||
*/
|
||||
async _createDroppedFolderContent(folder, targetFolder) {
|
||||
|
||||
const {foldersToCreate, documentsToCreate} = await this._organizeDroppedFoldersAndDocuments(folder, targetFolder);
|
||||
|
||||
// Create Folders
|
||||
let createdFolders;
|
||||
try {
|
||||
createdFolders = await Folder.createDocuments(foldersToCreate, {
|
||||
pack: this.collection.collection,
|
||||
keepId: true
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
ui.notifications.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Create Documents
|
||||
await this._createDroppedFolderDocuments(folder, documentsToCreate);
|
||||
|
||||
return createdFolders;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Organize a dropped Folder and its children into a list of folders to create and documents to create
|
||||
* @param {Folder} folder The Folder being dropped
|
||||
* @param {Folder} targetFolder The Folder to which the Folder should be added
|
||||
* @returns {Promise<{foldersToCreate: Array<Folder>, documentsToCreate: Array<Document>}>}
|
||||
* @private
|
||||
*/
|
||||
async _organizeDroppedFoldersAndDocuments(folder, targetFolder) {
|
||||
let foldersToCreate = [];
|
||||
let documentsToCreate = [];
|
||||
let exceededMaxDepth = false;
|
||||
const addFolder = (folder, currentDepth) => {
|
||||
if ( !folder ) return;
|
||||
|
||||
// If the Folder does not already exist, add it to the list of folders to create
|
||||
if ( this.collection.folders.get(folder.id) !== folder ) {
|
||||
const createData = folder.toObject();
|
||||
if ( targetFolder ) {
|
||||
createData.folder = targetFolder.id;
|
||||
targetFolder = undefined;
|
||||
}
|
||||
if ( currentDepth > this.maxFolderDepth ) {
|
||||
exceededMaxDepth = true;
|
||||
return;
|
||||
}
|
||||
createData.pack = this.collection.collection;
|
||||
foldersToCreate.push(createData);
|
||||
}
|
||||
|
||||
// If the Folder has documents, check those as well
|
||||
if ( folder.contents?.length ) {
|
||||
for ( const document of folder.contents ) {
|
||||
const createData = document.toObject ? document.toObject() : foundry.utils.deepClone(document);
|
||||
documentsToCreate.push(createData);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively check child folders
|
||||
for ( const child of folder.children ) {
|
||||
addFolder(child.folder, currentDepth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const currentDepth = (targetFolder?.ancestors.length ?? 0) + 1;
|
||||
addFolder(folder, currentDepth);
|
||||
if ( exceededMaxDepth ) {
|
||||
ui.notifications.error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: this.maxFolderDepth}), {console: false});
|
||||
foldersToCreate.length = documentsToCreate.length = 0;
|
||||
}
|
||||
return {foldersToCreate, documentsToCreate};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a list of documents in a dropped Folder
|
||||
* @param {Folder} folder The Folder being dropped
|
||||
* @param {Array<Document>} documentsToCreate The documents to create
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _createDroppedFolderDocuments(folder, documentsToCreate) {
|
||||
if ( folder.pack ) {
|
||||
const pack = game.packs.get(folder.pack);
|
||||
if ( pack ) {
|
||||
const ids = documentsToCreate.map(d => d._id);
|
||||
documentsToCreate = await pack.getDocuments({_id__in: ids});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.collection.documentClass.createDocuments(documentsToCreate, {
|
||||
pack: this.collection.collection,
|
||||
keepId: true
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
ui.notifications.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the set of ContextMenu options which should be used for Folders in a SidebarDirectory
|
||||
* @returns {object[]} The Array of context options passed to the ContextMenu instance
|
||||
* @protected
|
||||
*/
|
||||
_getFolderContextOptions() {
|
||||
const options = super._getFolderContextOptions();
|
||||
return options.concat([
|
||||
{
|
||||
name: "OWNERSHIP.Configure",
|
||||
icon: '<i class="fas fa-lock"></i>',
|
||||
condition: () => game.user.isGM,
|
||||
callback: async header => {
|
||||
const li = header.closest(".directory-item")[0];
|
||||
const folder = await fromUuid(li.dataset.uuid);
|
||||
new DocumentOwnershipConfig(folder, {
|
||||
top: Math.min(li.offsetTop, window.innerHeight - 350),
|
||||
left: window.innerWidth - 720
|
||||
}).render(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "FOLDER.Export",
|
||||
icon: '<i class="fas fa-atlas"></i>',
|
||||
condition: header => {
|
||||
const folder = fromUuidSync(header.parent().data("uuid"));
|
||||
return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
|
||||
},
|
||||
callback: async header => {
|
||||
const li = header.closest(".directory-item")[0];
|
||||
const folder = await fromUuid(li.dataset.uuid);
|
||||
return folder.exportDialog(null, {
|
||||
top: Math.min(li.offsetTop, window.innerHeight - 350),
|
||||
left: window.innerWidth - 720,
|
||||
width: 400
|
||||
});
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the set of ContextMenu options which should be used for Documents in a SidebarDirectory
|
||||
* @returns {object[]} The Array of context options passed to the ContextMenu instance
|
||||
* @protected
|
||||
*/
|
||||
_getEntryContextOptions() {
|
||||
const options = super._getEntryContextOptions();
|
||||
return [
|
||||
{
|
||||
name: "OWNERSHIP.Configure",
|
||||
icon: '<i class="fas fa-lock"></i>',
|
||||
condition: () => game.user.isGM,
|
||||
callback: header => {
|
||||
const li = header.closest(".directory-item");
|
||||
const document = this.collection.get(li.data("documentId"));
|
||||
new DocumentOwnershipConfig(document, {
|
||||
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
|
||||
left: window.innerWidth - 720
|
||||
}).render(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SIDEBAR.Export",
|
||||
icon: '<i class="fas fa-file-export"></i>',
|
||||
condition: header => {
|
||||
const li = header.closest(".directory-item");
|
||||
const document = this.collection.get(li.data("documentId"));
|
||||
return document.isOwner;
|
||||
},
|
||||
callback: header => {
|
||||
const li = header.closest(".directory-item");
|
||||
const document = this.collection.get(li.data("documentId"));
|
||||
return document.exportToJSON();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SIDEBAR.Import",
|
||||
icon: '<i class="fas fa-file-import"></i>',
|
||||
condition: header => {
|
||||
const li = header.closest(".directory-item");
|
||||
const document = this.collection.get(li.data("documentId"));
|
||||
return document.isOwner;
|
||||
},
|
||||
callback: header => {
|
||||
const li = header.closest(".directory-item");
|
||||
const document = this.collection.get(li.data("documentId"));
|
||||
return document.importFromJSONDialog();
|
||||
}
|
||||
}
|
||||
].concat(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
async _onCreateDocument(event) {
|
||||
foundry.utils.logCompatibilityWarning("DocumentDirectory#_onCreateDocument is deprecated. "
|
||||
+ "Please use DocumentDirectory#_onCreateEntry instead.", {since: 11, until: 13});
|
||||
return this._onCreateEntry(event, { _skipDeprecated: true });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
async _handleDroppedDocument(target, data) {
|
||||
foundry.utils.logCompatibilityWarning("DocumentDirectory#_handleDroppedDocument is deprecated. "
|
||||
+ "Please use DocumentDirectory#_handleDroppedEntry instead.", {since: 11, until: 13});
|
||||
return this._handleDroppedEntry(target, data, { _skipDeprecated: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
*/
|
||||
Object.defineProperty(globalThis, "SidebarDirectory", {
|
||||
get() {
|
||||
foundry.utils.logCompatibilityWarning("SidebarDirectory has been deprecated. Please use DocumentDirectory instead.",
|
||||
{since: 11, until: 13});
|
||||
return DocumentDirectory;
|
||||
}
|
||||
});
|
||||
171
resources/app/client/apps/sidebar/package-configuration.js
Normal file
171
resources/app/client/apps/sidebar/package-configuration.js
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* An application for configuring data across all installed and active packages.
|
||||
*/
|
||||
class PackageConfiguration extends FormApplication {
|
||||
|
||||
static get categoryOrder() {
|
||||
return ["all", "core", "system", "module", "unmapped"];
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the currently active tab.
|
||||
* @type {string}
|
||||
*/
|
||||
get activeCategory() {
|
||||
return this._tabs[0].active;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["package-configuration"],
|
||||
template: "templates/sidebar/apps/package-configuration.html",
|
||||
categoryTemplate: undefined,
|
||||
width: 780,
|
||||
height: 680,
|
||||
resizable: true,
|
||||
scrollY: [".filters", ".categories"],
|
||||
tabs: [{navSelector: ".tabs", contentSelector: "form .scrollable", initial: "all"}],
|
||||
filters: [{inputSelector: 'input[name="filter"]', contentSelector: ".categories"}],
|
||||
submitButton: false
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options={}) {
|
||||
const data = this._prepareCategoryData();
|
||||
data.categoryTemplate = this.options.categoryTemplate;
|
||||
data.submitButton = this.options.submitButton;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare the structure of category data which is rendered in this configuration form.
|
||||
* @abstract
|
||||
* @protected
|
||||
*/
|
||||
_prepareCategoryData() {
|
||||
return {categories: [], total: 0};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Classify what Category an Action belongs to
|
||||
* @param {string} namespace The entry to classify
|
||||
* @returns {{id: string, title: string}} The category the entry belongs to
|
||||
* @protected
|
||||
*/
|
||||
_categorizeEntry(namespace) {
|
||||
if ( namespace === "core" ) return {
|
||||
id: "core",
|
||||
title: game.i18n.localize("PACKAGECONFIG.Core")
|
||||
};
|
||||
else if ( namespace === game.system.id ) return {
|
||||
id: "system",
|
||||
title: game.system.title
|
||||
};
|
||||
else {
|
||||
const module = game.modules.get(namespace);
|
||||
if ( module ) return {
|
||||
id: module.id,
|
||||
title: module.title
|
||||
};
|
||||
return {
|
||||
id: "unmapped",
|
||||
title: game.i18n.localize("PACKAGECONFIG.Unmapped")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reusable logic for how categories are sorted in relation to each other.
|
||||
* @param {object} a
|
||||
* @param {object} b
|
||||
* @protected
|
||||
*/
|
||||
_sortCategories(a, b) {
|
||||
const categories = this.constructor.categoryOrder;
|
||||
let ia = categories.indexOf(a.id);
|
||||
if ( ia === -1 ) ia = categories.length - 2; // Modules second from last
|
||||
let ib = this.constructor.categoryOrder.indexOf(b.id);
|
||||
if ( ib === -1 ) ib = categories.length - 2; // Modules second from last
|
||||
return (ia - ib) || a.title.localeCompare(b.title, game.i18n.lang);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _render(force, {activeCategory, ...options}={}) {
|
||||
await loadTemplates([this.options.categoryTemplate]);
|
||||
await super._render(force, options);
|
||||
if ( activeCategory ) this._tabs[0].activate(activeCategory);
|
||||
const activeTab = this._tabs[0]?.active;
|
||||
if ( activeTab ) this.element[0].querySelector(`.tabs [data-tab="${activeTab}"]`)?.scrollIntoView();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
if ( this.activeCategory === "all" ) {
|
||||
this._tabs[0]._content.querySelectorAll(".tab").forEach(tab => tab.classList.add("active"));
|
||||
}
|
||||
html.find("button.reset-all").click(this._onResetDefaults.bind(this));
|
||||
html.find("input[name=filter]").focus();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onChangeTab(event, tabs, active) {
|
||||
if ( active === "all" ) {
|
||||
tabs._content.querySelectorAll(".tab").forEach(tab => tab.classList.add("active"));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onSearchFilter(event, query, rgx, html) {
|
||||
const visibleCategories = new Set();
|
||||
|
||||
// Hide entries
|
||||
for ( const entry of html.querySelectorAll(".form-group") ) {
|
||||
if ( !query ) {
|
||||
entry.classList.remove("hidden");
|
||||
continue;
|
||||
}
|
||||
const label = entry.querySelector("label")?.textContent;
|
||||
const notes = entry.querySelector(".notes")?.textContent;
|
||||
const match = (label && rgx.test(SearchFilter.cleanQuery(label)))
|
||||
|| (notes && rgx.test(SearchFilter.cleanQuery(notes)));
|
||||
entry.classList.toggle("hidden", !match);
|
||||
if ( match ) visibleCategories.add(entry.parentElement.dataset.category);
|
||||
}
|
||||
|
||||
// Hide categories which have no visible children
|
||||
for ( const category of html.querySelectorAll(".category") ) {
|
||||
category.classList.toggle("hidden", query && !visibleCategories.has(category.dataset.category));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle button click to reset default settings
|
||||
* @param {Event} event The initial button click event
|
||||
* @abstract
|
||||
* @protected
|
||||
*/
|
||||
_onResetDefaults(event) {}
|
||||
}
|
||||
178
resources/app/client/apps/sidebar/sidebar-tab.js
Normal file
178
resources/app/client/apps/sidebar/sidebar-tab.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* An abstract pattern followed by the different tabs of the sidebar
|
||||
* @abstract
|
||||
* @interface
|
||||
*/
|
||||
class SidebarTab extends Application {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* A reference to the pop-out variant of this SidebarTab, if one exists
|
||||
* @type {SidebarTab}
|
||||
* @protected
|
||||
*/
|
||||
this._popout = null;
|
||||
|
||||
/**
|
||||
* Denote whether this is the original version of the sidebar tab, or a pop-out variant
|
||||
* @type {SidebarTab}
|
||||
*/
|
||||
this._original = null;
|
||||
|
||||
// Adjust options
|
||||
if ( this.options.popOut ) this.options.classes.push("sidebar-popout");
|
||||
this.options.classes.push(`${this.tabName}-sidebar`);
|
||||
|
||||
// Register the tab as the sidebar singleton
|
||||
if ( !this.popOut && ui.sidebar ) ui.sidebar.tabs[this.tabName] = this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: null,
|
||||
popOut: false,
|
||||
width: 300,
|
||||
height: "auto",
|
||||
classes: ["tab", "sidebar-tab"],
|
||||
baseApplication: "SidebarTab"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get id() {
|
||||
return `${this.options.id}${this._original ? "-popout" : ""}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The base name of this sidebar tab
|
||||
* @type {string}
|
||||
*/
|
||||
get tabName() {
|
||||
return this.constructor.defaultOptions.id ?? this.id;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
return {
|
||||
cssId: this.id,
|
||||
cssClass: this.options.classes.join(" "),
|
||||
tabName: this.tabName,
|
||||
user: game.user
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _render(force=false, options={}) {
|
||||
await super._render(force, options);
|
||||
if ( this._popout ) await this._popout._render(force, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _renderInner(data) {
|
||||
let html = await super._renderInner(data);
|
||||
if ( ui.sidebar?.activeTab === this.id ) html.addClass("active");
|
||||
if ( this.popOut ) html.removeClass("tab");
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate this SidebarTab, switching focus to it
|
||||
*/
|
||||
activate() {
|
||||
ui.sidebar.activateTab(this.tabName);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async close(options) {
|
||||
if ( this.popOut ) {
|
||||
const base = this._original;
|
||||
if ( base ) base._popout = null;
|
||||
return super.close(options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a second instance of this SidebarTab class which represents a singleton popped-out container
|
||||
* @returns {SidebarTab} The popped out sidebar tab instance
|
||||
*/
|
||||
createPopout() {
|
||||
if ( this._popout ) return this._popout;
|
||||
|
||||
// Retain options from the main tab
|
||||
const options = {...this.options, popOut: true};
|
||||
delete options.id;
|
||||
delete options.classes;
|
||||
|
||||
// Create a popout application
|
||||
const pop = new this.constructor(options);
|
||||
this._popout = pop;
|
||||
pop._original = this;
|
||||
return pop;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render the SidebarTab as a pop-out container
|
||||
*/
|
||||
renderPopout() {
|
||||
const pop = this.createPopout();
|
||||
pop.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle lazy loading for sidebar images to only load them once they become observed
|
||||
* @param {HTMLElement[]} entries The entries which are now observed
|
||||
* @param {IntersectionObserver} observer The intersection observer instance
|
||||
*/
|
||||
_onLazyLoadImage(entries, observer) {
|
||||
for ( let e of entries ) {
|
||||
if ( !e.isIntersecting ) continue;
|
||||
const li = e.target;
|
||||
|
||||
// Background Image
|
||||
if ( li.dataset.backgroundImage ) {
|
||||
li.style["background-image"] = `url("${li.dataset.backgroundImage}")`;
|
||||
delete li.dataset.backgroundImage;
|
||||
}
|
||||
|
||||
// Avatar image
|
||||
const img = li.querySelector("img");
|
||||
if ( img && img.dataset.src ) {
|
||||
img.src = img.dataset.src;
|
||||
delete img.dataset.src;
|
||||
}
|
||||
|
||||
// No longer observe the target
|
||||
observer.unobserve(e.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
272
resources/app/client/apps/sidebar/sidebar.js
Normal file
272
resources/app/client/apps/sidebar/sidebar.js
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Render the Sidebar container, and after rendering insert Sidebar tabs.
|
||||
*/
|
||||
class Sidebar extends Application {
|
||||
|
||||
/**
|
||||
* Singleton application instances for each sidebar tab
|
||||
* @type {Record<string, SidebarTab>}
|
||||
*/
|
||||
tabs = {};
|
||||
|
||||
/**
|
||||
* Track whether the sidebar container is currently collapsed
|
||||
* @type {boolean}
|
||||
*/
|
||||
_collapsed = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "sidebar",
|
||||
template: "templates/sidebar/sidebar.html",
|
||||
popOut: false,
|
||||
width: 300,
|
||||
tabs: [{navSelector: ".tabs", contentSelector: "#sidebar", initial: "chat"}]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the name of the active Sidebar tab
|
||||
* @type {string}
|
||||
*/
|
||||
get activeTab() {
|
||||
return this._tabs[0].active;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Singleton application instances for each popout tab
|
||||
* @type {Record<string, SidebarTab>}
|
||||
*/
|
||||
get popouts() {
|
||||
const popouts = {};
|
||||
for ( let [name, app] of Object.entries(this.tabs) ) {
|
||||
if ( app._popout ) popouts[name] = app._popout;
|
||||
}
|
||||
return popouts;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendering
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options={}) {
|
||||
const isGM = game.user.isGM;
|
||||
|
||||
// Configure tabs
|
||||
const tabs = {
|
||||
chat: {
|
||||
tooltip: ChatMessage.metadata.labelPlural,
|
||||
icon: CONFIG.ChatMessage.sidebarIcon,
|
||||
notification: "<i id=\"chat-notification\" class=\"notification-pip fas fa-exclamation-circle\"></i>"
|
||||
},
|
||||
combat: {
|
||||
tooltip: Combat.metadata.labelPlural,
|
||||
icon: CONFIG.Combat.sidebarIcon
|
||||
},
|
||||
scenes: {
|
||||
tooltip: Scene.metadata.labelPlural,
|
||||
icon: CONFIG.Scene.sidebarIcon
|
||||
},
|
||||
actors: {
|
||||
tooltip: Actor.metadata.labelPlural,
|
||||
icon: CONFIG.Actor.sidebarIcon
|
||||
},
|
||||
items: {
|
||||
tooltip: Item.metadata.labelPlural,
|
||||
icon: CONFIG.Item.sidebarIcon
|
||||
},
|
||||
journal: {
|
||||
tooltip: "SIDEBAR.TabJournal",
|
||||
icon: CONFIG.JournalEntry.sidebarIcon
|
||||
},
|
||||
tables: {
|
||||
tooltip: RollTable.metadata.labelPlural,
|
||||
icon: CONFIG.RollTable.sidebarIcon
|
||||
},
|
||||
cards: {
|
||||
tooltip: Cards.metadata.labelPlural,
|
||||
icon: CONFIG.Cards.sidebarIcon
|
||||
},
|
||||
playlists: {
|
||||
tooltip: Playlist.metadata.labelPlural,
|
||||
icon: CONFIG.Playlist.sidebarIcon
|
||||
},
|
||||
compendium: {
|
||||
tooltip: "SIDEBAR.TabCompendium",
|
||||
icon: "fas fa-atlas"
|
||||
},
|
||||
settings: {
|
||||
tooltip: "SIDEBAR.TabSettings",
|
||||
icon: "fas fa-cogs"
|
||||
}
|
||||
};
|
||||
if ( !isGM ) delete tabs.scenes;
|
||||
|
||||
// Display core or system update notification?
|
||||
if ( isGM && (game.data.coreUpdate.hasUpdate || game.data.systemUpdate.hasUpdate) ) {
|
||||
tabs.settings.notification = `<i class="notification-pip fas fa-exclamation-circle"></i>`;
|
||||
}
|
||||
return {tabs};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, options) {
|
||||
|
||||
// Render the Sidebar container only once
|
||||
if ( !this.rendered ) await super._render(force, options);
|
||||
|
||||
// Render sidebar Applications
|
||||
const renders = [];
|
||||
for ( let [name, app] of Object.entries(this.tabs) ) {
|
||||
renders.push(app._render(true).catch(err => {
|
||||
Hooks.onError("Sidebar#_render", err, {
|
||||
msg: `Failed to render Sidebar tab ${name}`,
|
||||
log: "error",
|
||||
name
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
Promise.all(renders).then(() => this.activateTab(this.activeTab));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Expand the Sidebar container from a collapsed state.
|
||||
* Take no action if the sidebar is already expanded.
|
||||
*/
|
||||
expand() {
|
||||
if ( !this._collapsed ) return;
|
||||
const sidebar = this.element;
|
||||
const tab = sidebar.find(".sidebar-tab.active");
|
||||
const tabs = sidebar.find("#sidebar-tabs");
|
||||
const icon = tabs.find("a.collapse i");
|
||||
|
||||
// Animate the sidebar expansion
|
||||
tab.hide();
|
||||
sidebar.animate({width: this.options.width, height: this.position.height}, 150, () => {
|
||||
sidebar.css({width: "", height: ""}); // Revert to default styling
|
||||
sidebar.removeClass("collapsed");
|
||||
tabs[0].dataset.tooltipDirection = TooltipManager.TOOLTIP_DIRECTIONS.DOWN;
|
||||
tab.fadeIn(250, () => {
|
||||
tab.css({
|
||||
display: "",
|
||||
height: ""
|
||||
});
|
||||
});
|
||||
icon.removeClass("fa-caret-left").addClass("fa-caret-right");
|
||||
this._collapsed = false;
|
||||
Hooks.callAll("collapseSidebar", this, this._collapsed);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Collapse the sidebar to a minimized state.
|
||||
* Take no action if the sidebar is already collapsed.
|
||||
*/
|
||||
collapse() {
|
||||
if ( this._collapsed ) return;
|
||||
const sidebar = this.element;
|
||||
const tab = sidebar.find(".sidebar-tab.active");
|
||||
const tabs = sidebar.find("#sidebar-tabs");
|
||||
const icon = tabs.find("a.collapse i");
|
||||
|
||||
// Animate the sidebar collapse
|
||||
tab.fadeOut(250, () => {
|
||||
sidebar.animate({width: 32, height: (32 + 4) * (Object.values(this.tabs).length + 1)}, 150, () => {
|
||||
sidebar.css("height", ""); // Revert to default styling
|
||||
sidebar.addClass("collapsed");
|
||||
tabs[0].dataset.tooltipDirection = TooltipManager.TOOLTIP_DIRECTIONS.LEFT;
|
||||
tab.css("display", "");
|
||||
icon.removeClass("fa-caret-right").addClass("fa-caret-left");
|
||||
this._collapsed = true;
|
||||
Hooks.callAll("collapseSidebar", this, this._collapsed);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
// Right click pop-out
|
||||
const nav = this._tabs[0]._nav;
|
||||
nav.addEventListener("contextmenu", this._onRightClickTab.bind(this));
|
||||
|
||||
// Toggle Collapse
|
||||
const collapse = nav.querySelector(".collapse");
|
||||
collapse.addEventListener("click", this._onToggleCollapse.bind(this));
|
||||
|
||||
// Left click a tab
|
||||
const tabs = nav.querySelectorAll(".item");
|
||||
tabs.forEach(tab => tab.addEventListener("click", this._onLeftClickTab.bind(this)));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onChangeTab(event, tabs, active) {
|
||||
const app = ui[active];
|
||||
Hooks.callAll("changeSidebarTab", app);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the special case of left-clicking a tab when the sidebar is collapsed.
|
||||
* @param {MouseEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onLeftClickTab(event) {
|
||||
const app = ui[event.currentTarget.dataset.tab];
|
||||
if ( app && this._collapsed ) app.renderPopout(app);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle right-click events on tab controls to trigger pop-out containers for each tab
|
||||
* @param {Event} event The originating contextmenu event
|
||||
* @private
|
||||
*/
|
||||
_onRightClickTab(event) {
|
||||
const li = event.target.closest(".item");
|
||||
if ( !li ) return;
|
||||
event.preventDefault();
|
||||
const tabApp = ui[li.dataset.tab];
|
||||
tabApp.renderPopout(tabApp);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling of the Sidebar container's collapsed or expanded state
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onToggleCollapse(event) {
|
||||
event.preventDefault();
|
||||
if ( this._collapsed ) this.expand();
|
||||
else this.collapse();
|
||||
}
|
||||
}
|
||||
94
resources/app/client/apps/sidebar/tabs/actors-directory.js
Normal file
94
resources/app/client/apps/sidebar/tabs/actors-directory.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* The sidebar directory which organizes and displays world-level Actor documents.
|
||||
*/
|
||||
class ActorDirectory extends DocumentDirectory {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this._dragDrop[0].permissions.dragstart = () => game.user.can("TOKEN_CREATE");
|
||||
this._dragDrop[0].permissions.drop = () => game.user.can("ACTOR_CREATE");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static documentName = "Actor";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_canDragStart(selector) {
|
||||
return game.user.can("TOKEN_CREATE");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDragStart(event) {
|
||||
const li = event.currentTarget.closest(".directory-item");
|
||||
let actor = null;
|
||||
if ( li.dataset.documentId ) {
|
||||
actor = game.actors.get(li.dataset.documentId);
|
||||
if ( !actor || !actor.visible ) return false;
|
||||
}
|
||||
|
||||
// Parent directory drag start handling
|
||||
super._onDragStart(event);
|
||||
|
||||
// Create the drag preview for the Token
|
||||
if ( actor && canvas.ready ) {
|
||||
const img = li.querySelector("img");
|
||||
const pt = actor.prototypeToken;
|
||||
const w = pt.width * canvas.dimensions.size * Math.abs(pt.texture.scaleX) * canvas.stage.scale.x;
|
||||
const h = pt.height * canvas.dimensions.size * Math.abs(pt.texture.scaleY) * canvas.stage.scale.y;
|
||||
const preview = DragDrop.createDragImage(img, w, h);
|
||||
event.dataTransfer.setDragImage(preview, w / 2, h / 2);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_canDragDrop(selector) {
|
||||
return game.user.can("ACTOR_CREATE");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getEntryContextOptions() {
|
||||
const options = super._getEntryContextOptions();
|
||||
return [
|
||||
{
|
||||
name: "SIDEBAR.CharArt",
|
||||
icon: '<i class="fas fa-image"></i>',
|
||||
condition: li => {
|
||||
const actor = game.actors.get(li.data("documentId"));
|
||||
return actor.img !== CONST.DEFAULT_TOKEN;
|
||||
},
|
||||
callback: li => {
|
||||
const actor = game.actors.get(li.data("documentId"));
|
||||
new ImagePopout(actor.img, {
|
||||
title: actor.name,
|
||||
uuid: actor.uuid
|
||||
}).render(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SIDEBAR.TokenArt",
|
||||
icon: '<i class="fas fa-image"></i>',
|
||||
condition: li => {
|
||||
const actor = game.actors.get(li.data("documentId"));
|
||||
if ( actor.prototypeToken.randomImg ) return false;
|
||||
return ![null, undefined, CONST.DEFAULT_TOKEN].includes(actor.prototypeToken.texture.src);
|
||||
},
|
||||
callback: li => {
|
||||
const actor = game.actors.get(li.data("documentId"));
|
||||
new ImagePopout(actor.prototypeToken.texture.src, {
|
||||
title: actor.name,
|
||||
uuid: actor.uuid
|
||||
}).render(true);
|
||||
}
|
||||
}
|
||||
].concat(options);
|
||||
}
|
||||
}
|
||||
21
resources/app/client/apps/sidebar/tabs/cards-directory.js
Normal file
21
resources/app/client/apps/sidebar/tabs/cards-directory.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The sidebar directory which organizes and displays world-level Cards documents.
|
||||
* @extends {DocumentDirectory}
|
||||
*/
|
||||
class CardsDirectory extends DocumentDirectory {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Cards";
|
||||
|
||||
/** @inheritDoc */
|
||||
_getEntryContextOptions() {
|
||||
const options = super._getEntryContextOptions();
|
||||
const duplicate = options.find(o => o.name === "SIDEBAR.Duplicate");
|
||||
duplicate.condition = li => {
|
||||
if ( !game.user.isGM ) return false;
|
||||
const cards = this.constructor.collection.get(li.data("documentId"));
|
||||
return cards.canClone;
|
||||
};
|
||||
return options;
|
||||
}
|
||||
}
|
||||
962
resources/app/client/apps/sidebar/tabs/chat-log.js
Normal file
962
resources/app/client/apps/sidebar/tabs/chat-log.js
Normal file
@@ -0,0 +1,962 @@
|
||||
/**
|
||||
* @typedef {ApplicationOptions} ChatLogOptions
|
||||
* @property {boolean} [stream] Is this chat log being rendered as part of the stream view?
|
||||
*/
|
||||
|
||||
/**
|
||||
* The sidebar directory which organizes and displays world-level ChatMessage documents.
|
||||
* @extends {SidebarTab}
|
||||
* @see {Sidebar}
|
||||
* @param {ChatLogOptions} [options] Application configuration options.
|
||||
*/
|
||||
class ChatLog extends SidebarTab {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
/**
|
||||
* Track any pending text which the user has submitted in the chat log textarea
|
||||
* @type {string}
|
||||
* @private
|
||||
*/
|
||||
this._pendingText = "";
|
||||
|
||||
/**
|
||||
* Track the history of the past 5 sent messages which can be accessed using the arrow keys
|
||||
* @type {object[]}
|
||||
* @private
|
||||
*/
|
||||
this._sentMessages = [];
|
||||
|
||||
/**
|
||||
* Track which remembered message is being currently displayed to cycle properly
|
||||
* @type {number}
|
||||
* @private
|
||||
*/
|
||||
this._sentMessageIndex = -1;
|
||||
|
||||
/**
|
||||
* Track the time when the last message was sent to avoid flooding notifications
|
||||
* @type {number}
|
||||
* @private
|
||||
*/
|
||||
this._lastMessageTime = 0;
|
||||
|
||||
/**
|
||||
* Track the id of the last message displayed in the log
|
||||
* @type {string|null}
|
||||
* @private
|
||||
*/
|
||||
this._lastId = null;
|
||||
|
||||
/**
|
||||
* Track the last received message which included the user as a whisper recipient.
|
||||
* @type {ChatMessage|null}
|
||||
* @private
|
||||
*/
|
||||
this._lastWhisper = null;
|
||||
|
||||
/**
|
||||
* A reference to the chat text entry bound key method
|
||||
* @type {Function|null}
|
||||
* @private
|
||||
*/
|
||||
this._onChatKeyDownBinding = null;
|
||||
|
||||
// Update timestamps every 15 seconds
|
||||
setInterval(this.updateTimestamps.bind(this), 1000 * 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* A flag for whether the chat log is currently scrolled to the bottom
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isAtBottom = true;
|
||||
|
||||
/**
|
||||
* A cache of the Jump to Bottom element
|
||||
*/
|
||||
#jumpToBottomElement;
|
||||
|
||||
/**
|
||||
* A semaphore to queue rendering of Chat Messages.
|
||||
* @type {Semaphore}
|
||||
*/
|
||||
#renderingQueue = new foundry.utils.Semaphore(1);
|
||||
|
||||
/**
|
||||
* Currently rendering the next batch?
|
||||
* @type {boolean}
|
||||
*/
|
||||
#renderingBatch = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns if the chat log is currently scrolled to the bottom
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isAtBottom() {
|
||||
return this.#isAtBottom;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {ChatLogOptions}
|
||||
*/
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "chat",
|
||||
template: "templates/sidebar/chat-log.html",
|
||||
title: game.i18n.localize("CHAT.Title"),
|
||||
stream: false,
|
||||
scrollY: ["#chat-log"]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An enumeration of regular expression patterns used to match chat messages.
|
||||
* @enum {RegExp}
|
||||
*/
|
||||
static MESSAGE_PATTERNS = (() => {
|
||||
const dice = "([^#]+)(?:#(.*))?"; // Dice expression with appended flavor text
|
||||
const any = "([^]*)"; // Any character, including new lines
|
||||
return {
|
||||
roll: new RegExp(`^(\\/r(?:oll)? )${dice}$`, "i"), // Regular rolls: /r or /roll
|
||||
gmroll: new RegExp(`^(\\/gmr(?:oll)? )${dice}$`, "i"), // GM rolls: /gmr or /gmroll
|
||||
blindroll: new RegExp(`^(\\/b(?:lind)?r(?:oll)? )${dice}$`, "i"), // Blind rolls: /br or /blindroll
|
||||
selfroll: new RegExp(`^(\\/s(?:elf)?r(?:oll)? )${dice}$`, "i"), // Self rolls: /sr or /selfroll
|
||||
publicroll: new RegExp(`^(\\/p(?:ublic)?r(?:oll)? )${dice}$`, "i"), // Public rolls: /pr or /publicroll
|
||||
ic: new RegExp(`^(/ic )${any}`, "i"),
|
||||
ooc: new RegExp(`^(/ooc )${any}`, "i"),
|
||||
emote: new RegExp(`^(/(?:em(?:ote)?|me) )${any}`, "i"),
|
||||
whisper: new RegExp(/^(\/w(?:hisper)?\s)(\[(?:[^\]]+)\]|(?:[^\s]+))\s*([^]*)/, "i"),
|
||||
reply: new RegExp(`^(/reply )${any}`, "i"),
|
||||
gm: new RegExp(`^(/gm )${any}`, "i"),
|
||||
players: new RegExp(`^(/players )${any}`, "i"),
|
||||
macro: new RegExp(`^(\\/m(?:acro)? )${any}`, "i"),
|
||||
invalid: /^(\/[^\s]+)/ // Any other message starting with a slash command is invalid
|
||||
};
|
||||
})();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The set of commands that can be processed over multiple lines.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
static MULTILINE_COMMANDS = new Set(["roll", "gmroll", "blindroll", "selfroll", "publicroll"]);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the Messages collection that the chat log displays
|
||||
* @type {Messages}
|
||||
*/
|
||||
get collection() {
|
||||
return game.messages;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Application Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
const context = await super.getData(options);
|
||||
return foundry.utils.mergeObject(context, {
|
||||
rollMode: game.settings.get("core", "rollMode"),
|
||||
rollModes: Object.entries(CONFIG.Dice.rollModes).map(([k, v]) => ({
|
||||
group: "CHAT.RollDefault",
|
||||
value: k,
|
||||
label: v
|
||||
})),
|
||||
isStream: !!this.options.stream
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, options) {
|
||||
if ( this.rendered ) return; // Never re-render the Chat Log itself, only its contents
|
||||
await super._render(force, options);
|
||||
return this.scrollBottom({waitImages: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _renderInner(data) {
|
||||
const html = await super._renderInner(data);
|
||||
await this._renderBatch(html, CONFIG.ChatMessage.batchSize);
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render a batch of additional messages, prepending them to the top of the log
|
||||
* @param {jQuery} html The rendered jQuery HTML object
|
||||
* @param {number} size The batch size to include
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async _renderBatch(html, size) {
|
||||
if ( this.#renderingBatch ) return;
|
||||
this.#renderingBatch = true;
|
||||
return this.#renderingQueue.add(async () => {
|
||||
const messages = this.collection.contents;
|
||||
const log = html.find("#chat-log, #chat-log-popout");
|
||||
|
||||
// Get the index of the last rendered message
|
||||
let lastIdx = messages.findIndex(m => m.id === this._lastId);
|
||||
lastIdx = lastIdx !== -1 ? lastIdx : messages.length;
|
||||
|
||||
// Get the next batch to render
|
||||
let targetIdx = Math.max(lastIdx - size, 0);
|
||||
let m = null;
|
||||
if ( lastIdx !== 0 ) {
|
||||
let html = [];
|
||||
for ( let i=targetIdx; i<lastIdx; i++) {
|
||||
m = messages[i];
|
||||
if (!m.visible) continue;
|
||||
m.logged = true;
|
||||
try {
|
||||
html.push(await m.getHTML());
|
||||
} catch(err) {
|
||||
err.message = `Chat message ${m.id} failed to render: ${err})`;
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend the HTML
|
||||
log.prepend(html);
|
||||
this._lastId = messages[targetIdx].id;
|
||||
this.#renderingBatch = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Chat Sidebar Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Delete a single message from the chat log
|
||||
* @param {string} messageId The ChatMessage document to remove from the log
|
||||
* @param {boolean} [deleteAll] Is this part of a flush operation to delete all messages?
|
||||
*/
|
||||
deleteMessage(messageId, {deleteAll=false}={}) {
|
||||
return this.#renderingQueue.add(async () => {
|
||||
|
||||
// Get the chat message being removed from the log
|
||||
const message = game.messages.get(messageId, {strict: false});
|
||||
if ( message ) message.logged = false;
|
||||
|
||||
// Get the current HTML element for the message
|
||||
let li = this.element.find(`.message[data-message-id="${messageId}"]`);
|
||||
if ( !li.length ) return;
|
||||
|
||||
// Update the last index
|
||||
if ( deleteAll ) {
|
||||
this._lastId = null;
|
||||
} else if ( messageId === this._lastId ) {
|
||||
const next = li[0].nextElementSibling;
|
||||
this._lastId = next ? next.dataset.messageId : null;
|
||||
}
|
||||
|
||||
// Remove the deleted message
|
||||
li.slideUp(100, () => li.remove());
|
||||
|
||||
// Delete from popout tab
|
||||
if ( this._popout ) this._popout.deleteMessage(messageId, {deleteAll});
|
||||
if ( this.popOut ) this.setPosition();
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Trigger a notification that alerts the user visually and audibly that a new chat log message has been posted
|
||||
* @param {ChatMessage} message The message generating a notification
|
||||
*/
|
||||
notify(message) {
|
||||
this._lastMessageTime = Date.now();
|
||||
if ( !this.rendered ) return;
|
||||
|
||||
// Display the chat notification icon and remove it 3 seconds later
|
||||
let icon = $("#chat-notification");
|
||||
if ( icon.is(":hidden") ) icon.fadeIn(100);
|
||||
setTimeout(() => {
|
||||
if ( (Date.now() - this._lastMessageTime > 3000) && icon.is(":visible") ) icon.fadeOut(100);
|
||||
}, 3001);
|
||||
|
||||
// Play a notification sound effect
|
||||
if ( message.sound ) game.audio.play(message.sound, {context: game.audio.interface});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Parse a chat string to identify the chat command (if any) which was used
|
||||
* @param {string} message The message to match
|
||||
* @returns {string[]} The identified command and regex match
|
||||
*/
|
||||
static parse(message) {
|
||||
for ( const [rule, rgx] of Object.entries(this.MESSAGE_PATTERNS) ) {
|
||||
|
||||
// For multi-line matches, the first line must match
|
||||
if ( this.MULTILINE_COMMANDS.has(rule) ) {
|
||||
const lines = message.split("\n");
|
||||
if ( rgx.test(lines[0]) ) return [rule, lines.map(l => l.match(rgx))];
|
||||
}
|
||||
|
||||
// For single-line matches, match directly
|
||||
else {
|
||||
const match = message.match(rgx);
|
||||
if ( match ) return [rule, match];
|
||||
}
|
||||
}
|
||||
return ["none", [message, "", message]];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Post a single chat message to the log
|
||||
* @param {ChatMessage} message A ChatMessage document instance to post to the log
|
||||
* @param {object} [options={}] Additional options for how the message is posted to the log
|
||||
* @param {string} [options.before] An existing message ID to append the message before, by default the new message is
|
||||
* appended to the end of the log.
|
||||
* @param {boolean} [options.notify] Trigger a notification which shows the log as having a new unread message.
|
||||
* @returns {Promise<void>} A Promise which resolves once the message is posted
|
||||
*/
|
||||
async postOne(message, {before, notify=false}={}) {
|
||||
if ( !message.visible ) return;
|
||||
return this.#renderingQueue.add(async () => {
|
||||
message.logged = true;
|
||||
|
||||
// Track internal flags
|
||||
if ( !this._lastId ) this._lastId = message.id; // Ensure that new messages don't result in batched scrolling
|
||||
if ( (message.whisper || []).includes(game.user.id) && !message.isRoll ) {
|
||||
this._lastWhisper = message;
|
||||
}
|
||||
|
||||
// Render the message to the log
|
||||
const html = await message.getHTML();
|
||||
const log = this.element.find("#chat-log");
|
||||
|
||||
// Append the message after some other one
|
||||
const existing = before ? this.element.find(`.message[data-message-id="${before}"]`) : [];
|
||||
if ( existing.length ) existing.before(html);
|
||||
|
||||
// Otherwise, append the message to the bottom of the log
|
||||
else {
|
||||
log.append(html);
|
||||
if ( this.isAtBottom || (message.author._id === game.user._id) ) this.scrollBottom({waitImages: true});
|
||||
}
|
||||
|
||||
// Post notification
|
||||
if ( notify ) this.notify(message);
|
||||
|
||||
// Update popout tab
|
||||
if ( this._popout ) await this._popout.postOne(message, {before, notify: false});
|
||||
if ( this.popOut ) this.setPosition();
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Scroll the chat log to the bottom
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.popout=false] If a popout exists, scroll it to the bottom too.
|
||||
* @param {boolean} [options.waitImages=false] Wait for any images embedded in the chat log to load first
|
||||
* before scrolling?
|
||||
* @param {ScrollIntoViewOptions} [options.scrollOptions] Options to configure scrolling behaviour.
|
||||
*/
|
||||
async scrollBottom({popout=false, waitImages=false, scrollOptions={}}={}) {
|
||||
if ( !this.rendered ) return;
|
||||
if ( waitImages ) await this._waitForImages();
|
||||
const log = this.element[0].querySelector("#chat-log");
|
||||
log.lastElementChild?.scrollIntoView(scrollOptions);
|
||||
if ( popout ) this._popout?.scrollBottom({waitImages, scrollOptions});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the content of a previously posted message after its data has been replaced
|
||||
* @param {ChatMessage} message The ChatMessage instance to update
|
||||
* @param {boolean} notify Trigger a notification which shows the log as having a new unread message
|
||||
*/
|
||||
async updateMessage(message, notify=false) {
|
||||
let li = this.element.find(`.message[data-message-id="${message.id}"]`);
|
||||
if ( li.length ) {
|
||||
const html = await message.getHTML();
|
||||
li.replaceWith(html);
|
||||
}
|
||||
|
||||
// Add a newly visible message to the log
|
||||
else {
|
||||
const messages = game.messages.contents;
|
||||
const messageIndex = messages.findIndex(m => m === message);
|
||||
let nextMessage;
|
||||
for ( let i = messageIndex + 1; i < messages.length; i++ ) {
|
||||
if ( messages[i].visible ) {
|
||||
nextMessage = messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
await this.postOne(message, {before: nextMessage?.id, notify: false});
|
||||
}
|
||||
|
||||
// Post notification of update
|
||||
if ( notify ) this.notify(message);
|
||||
|
||||
// Update popout tab
|
||||
if ( this._popout ) await this._popout.updateMessage(message, false);
|
||||
if ( this.popOut ) this.setPosition();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the displayed timestamps for every displayed message in the chat log.
|
||||
* Timestamps are displayed in a humanized "timesince" format.
|
||||
*/
|
||||
updateTimestamps() {
|
||||
const messages = this.element.find("#chat-log .message");
|
||||
for ( let li of messages ) {
|
||||
const message = game.messages.get(li.dataset.messageId);
|
||||
if ( !message?.timestamp ) return;
|
||||
const stamp = li.querySelector(".message-timestamp");
|
||||
if (stamp) stamp.textContent = foundry.utils.timeSince(message.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
|
||||
// Load new messages on scroll
|
||||
html.find("#chat-log").scroll(this._onScrollLog.bind(this));
|
||||
|
||||
// Chat message entry
|
||||
this._onChatKeyDownBinding = this._onChatKeyDown.bind(this);
|
||||
html.find("#chat-message").keydown(this._onChatKeyDownBinding);
|
||||
|
||||
// Expand dice roll tooltips
|
||||
html.on("click", ".dice-roll", this._onDiceRollClick.bind(this));
|
||||
|
||||
// Modify Roll Type
|
||||
html.find('select[name="rollMode"]').change(this._onChangeRollMode.bind(this));
|
||||
|
||||
// Single Message Delete
|
||||
html.on("click", "a.message-delete", this._onDeleteMessage.bind(this));
|
||||
|
||||
// Flush log
|
||||
html.find("a.chat-flush").click(this._onFlushLog.bind(this));
|
||||
|
||||
// Export log
|
||||
html.find("a.export-log").click(this._onExportLog.bind(this));
|
||||
|
||||
// Jump to Bottom
|
||||
html.find(".jump-to-bottom > a").click(() => this.scrollBottom());
|
||||
|
||||
// Content Link Dragging
|
||||
html[0].addEventListener("drop", ChatLog._onDropTextAreaData);
|
||||
|
||||
// Chat Entry context menu
|
||||
this._contextMenu(html);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle dropping of transferred data onto the chat editor
|
||||
* @param {DragEvent} event The originating drop event which triggered the data transfer
|
||||
* @private
|
||||
*/
|
||||
static async _onDropTextAreaData(event) {
|
||||
event.preventDefault();
|
||||
const textarea = event.target;
|
||||
|
||||
// Drop cross-linked content
|
||||
const eventData = TextEditor.getDragEventData(event);
|
||||
const link = await TextEditor.getContentLink(eventData);
|
||||
if ( link ) textarea.value += link;
|
||||
|
||||
// Record pending text
|
||||
this._pendingText = textarea.value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare the data object of chat message data depending on the type of message being posted
|
||||
* @param {string} message The original string of the message content
|
||||
* @param {object} [options] Additional options
|
||||
* @param {ChatSpeakerData} [options.speaker] The speaker data
|
||||
* @returns {Promise<Object|void>} The prepared chat data object, or void if we were executing a macro instead
|
||||
*/
|
||||
async processMessage(message, {speaker}={}) {
|
||||
message = message.trim();
|
||||
if ( !message ) return;
|
||||
const cls = ChatMessage.implementation;
|
||||
|
||||
// Set up basic chat data
|
||||
const chatData = {
|
||||
user: game.user.id,
|
||||
speaker: speaker ?? cls.getSpeaker()
|
||||
};
|
||||
|
||||
if ( Hooks.call("chatMessage", this, message, chatData) === false ) return;
|
||||
|
||||
// Parse the message to determine the matching handler
|
||||
let [command, match] = this.constructor.parse(message);
|
||||
|
||||
// Special handlers for no command
|
||||
if ( command === "invalid" ) throw new Error(game.i18n.format("CHAT.InvalidCommand", {command: match[1]}));
|
||||
else if ( command === "none" ) command = chatData.speaker.token ? "ic" : "ooc";
|
||||
|
||||
// Process message data based on the identified command type
|
||||
const createOptions = {};
|
||||
switch (command) {
|
||||
case "roll": case "gmroll": case "blindroll": case "selfroll": case "publicroll":
|
||||
await this._processDiceCommand(command, match, chatData, createOptions);
|
||||
break;
|
||||
case "whisper": case "reply": case "gm": case "players":
|
||||
this._processWhisperCommand(command, match, chatData, createOptions);
|
||||
break;
|
||||
case "ic": case "emote": case "ooc":
|
||||
this._processChatCommand(command, match, chatData, createOptions);
|
||||
break;
|
||||
case "macro":
|
||||
this._processMacroCommand(command, match);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the message using provided data and options
|
||||
return cls.create(chatData, createOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Process messages which are posted using a dice-roll command
|
||||
* @param {string} command The chat command type
|
||||
* @param {RegExpMatchArray[]} matches Multi-line matched roll expressions
|
||||
* @param {Object} chatData The initial chat data
|
||||
* @param {Object} createOptions Options used to create the message
|
||||
* @private
|
||||
*/
|
||||
async _processDiceCommand(command, matches, chatData, createOptions) {
|
||||
const actor = ChatMessage.getSpeakerActor(chatData.speaker) || game.user.character;
|
||||
const rollData = actor ? actor.getRollData() : {};
|
||||
const rolls = [];
|
||||
const rollMode = command === "roll" ? game.settings.get("core", "rollMode") : command;
|
||||
for ( const match of matches ) {
|
||||
if ( !match ) continue;
|
||||
const [formula, flavor] = match.slice(2, 4);
|
||||
if ( flavor && !chatData.flavor ) chatData.flavor = flavor;
|
||||
const roll = Roll.create(formula, rollData);
|
||||
await roll.evaluate({allowInteractive: rollMode !== CONST.DICE_ROLL_MODES.BLIND});
|
||||
rolls.push(roll);
|
||||
}
|
||||
chatData.rolls = rolls;
|
||||
chatData.sound = CONFIG.sounds.dice;
|
||||
chatData.content = rolls.reduce((t, r) => t + r.total, 0);
|
||||
createOptions.rollMode = rollMode;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Process messages which are posted using a chat whisper command
|
||||
* @param {string} command The chat command type
|
||||
* @param {RegExpMatchArray} match The matched RegExp expressions
|
||||
* @param {Object} chatData The initial chat data
|
||||
* @param {Object} createOptions Options used to create the message
|
||||
* @private
|
||||
*/
|
||||
_processWhisperCommand(command, match, chatData, createOptions) {
|
||||
delete chatData.speaker;
|
||||
|
||||
// Determine the recipient users
|
||||
let users = [];
|
||||
let message= "";
|
||||
switch ( command ) {
|
||||
case "whisper":
|
||||
message = match[3];
|
||||
const names = match[2].replace(/[\[\]]/g, "").split(",").map(n => n.trim());
|
||||
users = names.reduce((arr, n) => arr.concat(ChatMessage.getWhisperRecipients(n)), []);
|
||||
break;
|
||||
case "reply":
|
||||
message = match[2];
|
||||
const w = this._lastWhisper;
|
||||
if ( w ) {
|
||||
const group = new Set(w.whisper);
|
||||
group.delete(game.user.id);
|
||||
group.add(w.author.id);
|
||||
users = Array.from(group).map(id => game.users.get(id));
|
||||
}
|
||||
break;
|
||||
case "gm":
|
||||
message = match[2];
|
||||
users = ChatMessage.getWhisperRecipients("gm");
|
||||
break;
|
||||
case "players":
|
||||
message = match[2];
|
||||
users = ChatMessage.getWhisperRecipients("players");
|
||||
break;
|
||||
}
|
||||
|
||||
// Add line break elements
|
||||
message = message.replace(/\n/g, "<br>");
|
||||
|
||||
// Ensure we have valid whisper targets
|
||||
if ( !users.length ) throw new Error(game.i18n.localize("ERROR.NoTargetUsersForWhisper"));
|
||||
if ( users.some(u => !u.isGM) && !game.user.can("MESSAGE_WHISPER") ) {
|
||||
throw new Error(game.i18n.localize("ERROR.CantWhisper"));
|
||||
}
|
||||
|
||||
// Update chat data
|
||||
chatData.whisper = users.map(u => u.id);
|
||||
chatData.content = message;
|
||||
chatData.sound = CONFIG.sounds.notification;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Process messages which are posted using a chat whisper command
|
||||
* @param {string} command The chat command type
|
||||
* @param {RegExpMatchArray} match The matched RegExp expressions
|
||||
* @param {Object} chatData The initial chat data
|
||||
* @param {Object} createOptions Options used to create the message
|
||||
* @private
|
||||
*/
|
||||
_processChatCommand(command, match, chatData, createOptions) {
|
||||
if ( ["ic", "emote"].includes(command) && !(chatData.speaker.actor || chatData.speaker.token) ) {
|
||||
throw new Error("You cannot chat in-character without an identified speaker");
|
||||
}
|
||||
chatData.content = match[2].replace(/\n/g, "<br>");
|
||||
|
||||
// Augment chat data
|
||||
if ( command === "ic" ) {
|
||||
chatData.style = CONST.CHAT_MESSAGE_STYLES.IC;
|
||||
createOptions.chatBubble = true;
|
||||
} else if ( command === "emote" ) {
|
||||
chatData.style = CONST.CHAT_MESSAGE_STYLES.EMOTE;
|
||||
chatData.content = `${chatData.speaker.alias} ${chatData.content}`;
|
||||
createOptions.chatBubble = true;
|
||||
}
|
||||
else {
|
||||
chatData.style = CONST.CHAT_MESSAGE_STYLES.OOC;
|
||||
delete chatData.speaker;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Process messages which execute a macro.
|
||||
* @param {string} command The chat command typed.
|
||||
* @param {RegExpMatchArray} match The RegExp matches.
|
||||
* @private
|
||||
*/
|
||||
_processMacroCommand(command, match) {
|
||||
|
||||
// Parse the macro command with the form /macro {macroName} [param1=val1] [param2=val2] ...
|
||||
let [macroName, ...params] = match[2].split(" ");
|
||||
let expandName = true;
|
||||
const scope = {};
|
||||
let k = undefined;
|
||||
for ( const p of params ) {
|
||||
const kv = p.split("=");
|
||||
if ( kv.length === 2 ) {
|
||||
k = kv[0];
|
||||
scope[k] = kv[1];
|
||||
expandName = false;
|
||||
}
|
||||
else if ( expandName ) macroName += ` ${p}`; // Macro names may contain spaces
|
||||
else if ( k ) scope[k] += ` ${p}`; // Expand prior argument value
|
||||
}
|
||||
macroName = macroName.trimEnd(); // Eliminate trailing spaces
|
||||
|
||||
// Get the target macro by number or by name
|
||||
let macro;
|
||||
if ( Number.isNumeric(macroName) ) {
|
||||
const macroID = game.user.hotbar[macroName];
|
||||
macro = game.macros.get(macroID);
|
||||
}
|
||||
if ( !macro ) macro = game.macros.getName(macroName);
|
||||
if ( !macro ) throw new Error(`Requested Macro "${macroName}" was not found as a named macro or hotbar position`);
|
||||
|
||||
// Execute the Macro with provided scope
|
||||
return macro.execute(scope);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a sent message to an array of remembered messages to be re-sent if the user pages up with the up arrow key
|
||||
* @param {string} message The message text being remembered
|
||||
* @private
|
||||
*/
|
||||
_remember(message) {
|
||||
if ( this._sentMessages.length === 5 ) this._sentMessages.splice(4, 1);
|
||||
this._sentMessages.unshift(message);
|
||||
this._sentMessageIndex = -1;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Recall a previously sent message by incrementing up (1) or down (-1) through the sent messages array
|
||||
* @param {number} direction The direction to recall, positive for older, negative for more recent
|
||||
* @return {string} The recalled message, or an empty string
|
||||
* @private
|
||||
*/
|
||||
_recall(direction) {
|
||||
if ( this._sentMessages.length > 0 ) {
|
||||
let idx = this._sentMessageIndex + direction;
|
||||
this._sentMessageIndex = Math.clamp(idx, -1, this._sentMessages.length-1);
|
||||
}
|
||||
return this._sentMessages[this._sentMessageIndex] || "";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_contextMenu(html) {
|
||||
ContextMenu.create(this, html, ".message", this._getEntryContextOptions());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the ChatLog entry context options
|
||||
* @return {object[]} The ChatLog entry context options
|
||||
* @private
|
||||
*/
|
||||
_getEntryContextOptions() {
|
||||
return [
|
||||
{
|
||||
name: "CHAT.PopoutMessage",
|
||||
icon: '<i class="fas fa-external-link-alt fa-rotate-180"></i>',
|
||||
condition: li => {
|
||||
const message = game.messages.get(li.data("messageId"));
|
||||
return message.getFlag("core", "canPopout") === true;
|
||||
},
|
||||
callback: li => {
|
||||
const message = game.messages.get(li.data("messageId"));
|
||||
new ChatPopout(message).render(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "CHAT.RevealMessage",
|
||||
icon: '<i class="fas fa-eye"></i>',
|
||||
condition: li => {
|
||||
const message = game.messages.get(li.data("messageId"));
|
||||
const isLimited = message.whisper.length || message.blind;
|
||||
return isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
|
||||
},
|
||||
callback: li => {
|
||||
const message = game.messages.get(li.data("messageId"));
|
||||
return message.update({whisper: [], blind: false});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "CHAT.ConcealMessage",
|
||||
icon: '<i class="fas fa-eye-slash"></i>',
|
||||
condition: li => {
|
||||
const message = game.messages.get(li.data("messageId"));
|
||||
const isLimited = message.whisper.length || message.blind;
|
||||
return !isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
|
||||
},
|
||||
callback: li => {
|
||||
const message = game.messages.get(li.data("messageId"));
|
||||
return message.update({whisper: ChatMessage.getWhisperRecipients("gm").map(u => u.id), blind: false});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SIDEBAR.Delete",
|
||||
icon: '<i class="fas fa-trash"></i>',
|
||||
condition: li => {
|
||||
const message = game.messages.get(li.data("messageId"));
|
||||
return message.canUserModify(game.user, "delete");
|
||||
},
|
||||
callback: li => {
|
||||
const message = game.messages.get(li.data("messageId"));
|
||||
return message.delete();
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle keydown events in the chat entry textarea
|
||||
* @param {KeyboardEvent} event
|
||||
* @private
|
||||
*/
|
||||
_onChatKeyDown(event) {
|
||||
const code = event.code;
|
||||
const textarea = event.currentTarget;
|
||||
|
||||
if ( event.originalEvent.isComposing ) return; // Ignore IME composition
|
||||
|
||||
// UP/DOWN ARROW -> Recall Previous Messages
|
||||
const isArrow = ["ArrowUp", "ArrowDown"].includes(code);
|
||||
if ( isArrow ) {
|
||||
if ( this._pendingText ) return;
|
||||
event.preventDefault();
|
||||
textarea.value = this._recall(code === "ArrowUp" ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
|
||||
// ENTER -> Send Message
|
||||
const isEnter = ( (code === "Enter") || (code === "NumpadEnter") ) && !event.shiftKey;
|
||||
if ( isEnter ) {
|
||||
event.preventDefault();
|
||||
const message = textarea.value;
|
||||
if ( !message ) return;
|
||||
event.stopPropagation();
|
||||
this._pendingText = "";
|
||||
|
||||
// Prepare chat message data and handle result
|
||||
return this.processMessage(message).then(() => {
|
||||
textarea.value = "";
|
||||
this._remember(message);
|
||||
}).catch(error => {
|
||||
ui.notifications.error(error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
// BACKSPACE -> Remove pending text
|
||||
if ( event.key === "Backspace" ) {
|
||||
this._pendingText = this._pendingText.slice(0, -1);
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, record that there is pending text
|
||||
this._pendingText = textarea.value + (event.key.length === 1 ? event.key : "");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle setting the preferred roll mode
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onChangeRollMode(event) {
|
||||
event.preventDefault();
|
||||
game.settings.set("core", "rollMode", event.target.value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle single message deletion workflow
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onDeleteMessage(event) {
|
||||
event.preventDefault();
|
||||
const li = event.currentTarget.closest(".message");
|
||||
const messageId = li.dataset.messageId;
|
||||
const message = game.messages.get(messageId);
|
||||
return message ? message.delete() : this.deleteMessage(messageId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clicking of dice tooltip buttons
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onDiceRollClick(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Toggle the message flag
|
||||
let roll = event.currentTarget;
|
||||
const message = game.messages.get(roll.closest(".message").dataset.messageId);
|
||||
message._rollExpanded = !message._rollExpanded;
|
||||
|
||||
// Expand or collapse tooltips
|
||||
const tooltips = roll.querySelectorAll(".dice-tooltip");
|
||||
for ( let tip of tooltips ) {
|
||||
if ( message._rollExpanded ) $(tip).slideDown(200);
|
||||
else $(tip).slideUp(200);
|
||||
tip.classList.toggle("expanded", message._rollExpanded);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events to export the chat log
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onExportLog(event) {
|
||||
event.preventDefault();
|
||||
game.messages.export();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events to flush the chat log
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onFlushLog(event) {
|
||||
event.preventDefault();
|
||||
game.messages.flush(this.#jumpToBottomElement);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle scroll events within the chat log container
|
||||
* @param {UIEvent} event The initial scroll event
|
||||
* @private
|
||||
*/
|
||||
_onScrollLog(event) {
|
||||
if ( !this.rendered ) return;
|
||||
const log = event.target;
|
||||
const pct = log.scrollTop / (log.scrollHeight - log.clientHeight);
|
||||
if ( !this.#jumpToBottomElement ) this.#jumpToBottomElement = this.element.find(".jump-to-bottom")[0];
|
||||
this.#isAtBottom = (pct > 0.99) || Number.isNaN(pct);
|
||||
this.#jumpToBottomElement.classList.toggle("hidden", this.#isAtBottom);
|
||||
if ( pct < 0.01 ) return this._renderBatch(this.element, CONFIG.ChatMessage.batchSize);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update roll mode select dropdowns when the setting is changed
|
||||
* @param {string} mode The new roll mode setting
|
||||
*/
|
||||
static _setRollMode(mode) {
|
||||
for ( let select of $(".roll-type-select") ) {
|
||||
for ( let option of select.options ) {
|
||||
option.selected = option.value === mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
535
resources/app/client/apps/sidebar/tabs/combat-tracker.js
Normal file
535
resources/app/client/apps/sidebar/tabs/combat-tracker.js
Normal file
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* The sidebar directory which organizes and displays world-level Combat documents.
|
||||
*/
|
||||
class CombatTracker extends SidebarTab {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
if ( !this.popOut ) game.combats.apps.push(this);
|
||||
|
||||
/**
|
||||
* Record a reference to the currently highlighted Token
|
||||
* @type {Token|null}
|
||||
* @private
|
||||
*/
|
||||
this._highlighted = null;
|
||||
|
||||
/**
|
||||
* Record the currently tracked Combat encounter
|
||||
* @type {Combat|null}
|
||||
*/
|
||||
this.viewed = null;
|
||||
|
||||
// Initialize the starting encounter
|
||||
this.initialize({render: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "combat",
|
||||
template: "templates/sidebar/combat-tracker.html",
|
||||
title: "COMBAT.SidebarTitle",
|
||||
scrollY: [".directory-list"]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return an array of Combat encounters which occur within the current Scene.
|
||||
* @type {Combat[]}
|
||||
*/
|
||||
get combats() {
|
||||
return game.combats.combats;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
createPopout() {
|
||||
const pop = super.createPopout();
|
||||
pop.initialize({combat: this.viewed, render: true});
|
||||
return pop;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the combat tracker to display a specific combat encounter.
|
||||
* If no encounter is provided, the tracker will be initialized with the first encounter in the viewed scene.
|
||||
* @param {object} [options] Additional options to configure behavior.
|
||||
* @param {Combat|null} [options.combat=null] The combat encounter to initialize
|
||||
* @param {boolean} [options.render=true] Whether to re-render the sidebar after initialization
|
||||
*/
|
||||
initialize({combat=null, render=true}={}) {
|
||||
|
||||
// Retrieve a default encounter if none was provided
|
||||
if ( combat === null ) {
|
||||
const combats = this.combats;
|
||||
combat = combats.length ? combats.find(c => c.active) || combats[0] : null;
|
||||
combat?.updateCombatantActors();
|
||||
}
|
||||
|
||||
// Prepare turn order
|
||||
if ( combat && !combat.turns ) combat.turns = combat.setupTurns();
|
||||
|
||||
// Set flags
|
||||
this.viewed = combat;
|
||||
this._highlighted = null;
|
||||
|
||||
// Also initialize the popout
|
||||
if ( this._popout ) {
|
||||
this._popout.viewed = combat;
|
||||
this._popout._highlighted = null;
|
||||
}
|
||||
|
||||
// Render the tracker
|
||||
if ( render ) this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Scroll the combat log container to ensure the current Combatant turn is centered vertically
|
||||
*/
|
||||
scrollToTurn() {
|
||||
const combat = this.viewed;
|
||||
if ( !combat || (combat.turn === null) ) return;
|
||||
let active = this.element.find(".active")[0];
|
||||
if ( !active ) return;
|
||||
let container = active.parentElement;
|
||||
const nViewable = Math.floor(container.offsetHeight / active.offsetHeight);
|
||||
container.scrollTop = (combat.turn * active.offsetHeight) - ((nViewable/2) * active.offsetHeight);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
let context = await super.getData(options);
|
||||
|
||||
// Get the combat encounters possible for the viewed Scene
|
||||
const combat = this.viewed;
|
||||
const hasCombat = combat !== null;
|
||||
const combats = this.combats;
|
||||
const currentIdx = combats.findIndex(c => c === combat);
|
||||
const previousId = currentIdx > 0 ? combats[currentIdx-1].id : null;
|
||||
const nextId = currentIdx < combats.length - 1 ? combats[currentIdx+1].id : null;
|
||||
const settings = game.settings.get("core", Combat.CONFIG_SETTING);
|
||||
|
||||
// Prepare rendering data
|
||||
context = foundry.utils.mergeObject(context, {
|
||||
combats: combats,
|
||||
currentIndex: currentIdx + 1,
|
||||
combatCount: combats.length,
|
||||
hasCombat: hasCombat,
|
||||
combat,
|
||||
turns: [],
|
||||
previousId,
|
||||
nextId,
|
||||
started: this.started,
|
||||
control: false,
|
||||
settings,
|
||||
linked: combat?.scene !== null,
|
||||
labels: {}
|
||||
});
|
||||
context.labels.scope = game.i18n.localize(`COMBAT.${context.linked ? "Linked" : "Unlinked"}`);
|
||||
if ( !hasCombat ) return context;
|
||||
|
||||
// Format information about each combatant in the encounter
|
||||
let hasDecimals = false;
|
||||
const turns = [];
|
||||
for ( let [i, combatant] of combat.turns.entries() ) {
|
||||
if ( !combatant.visible ) continue;
|
||||
|
||||
// Prepare turn data
|
||||
const resource = combatant.permission >= CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER ? combatant.resource : null;
|
||||
const turn = {
|
||||
id: combatant.id,
|
||||
name: combatant.name,
|
||||
img: await this._getCombatantThumbnail(combatant),
|
||||
active: i === combat.turn,
|
||||
owner: combatant.isOwner,
|
||||
defeated: combatant.isDefeated,
|
||||
hidden: combatant.hidden,
|
||||
initiative: combatant.initiative,
|
||||
hasRolled: combatant.initiative !== null,
|
||||
hasResource: resource !== null,
|
||||
resource: resource,
|
||||
canPing: (combatant.sceneId === canvas.scene?.id) && game.user.hasPermission("PING_CANVAS")
|
||||
};
|
||||
if ( (turn.initiative !== null) && !Number.isInteger(turn.initiative) ) hasDecimals = true;
|
||||
turn.css = [
|
||||
turn.active ? "active" : "",
|
||||
turn.hidden ? "hidden" : "",
|
||||
turn.defeated ? "defeated" : ""
|
||||
].join(" ").trim();
|
||||
|
||||
// Actor and Token status effects
|
||||
turn.effects = new Set();
|
||||
for ( const effect of (combatant.actor?.temporaryEffects || []) ) {
|
||||
if ( effect.statuses.has(CONFIG.specialStatusEffects.DEFEATED) ) turn.defeated = true;
|
||||
else if ( effect.img ) turn.effects.add(effect.img);
|
||||
}
|
||||
turns.push(turn);
|
||||
}
|
||||
|
||||
// Format initiative numeric precision
|
||||
const precision = CONFIG.Combat.initiative.decimals;
|
||||
turns.forEach(t => {
|
||||
if ( t.initiative !== null ) t.initiative = t.initiative.toFixed(hasDecimals ? precision : 0);
|
||||
});
|
||||
|
||||
// Confirm user permission to advance
|
||||
const isPlayerTurn = combat.combatant?.players?.includes(game.user);
|
||||
const canControl = combat.turn && combat.turn.between(1, combat.turns.length - 2)
|
||||
? combat.canUserModify(game.user, "update", {turn: 0})
|
||||
: combat.canUserModify(game.user, "update", {round: 0});
|
||||
|
||||
// Merge update data for rendering
|
||||
return foundry.utils.mergeObject(context, {
|
||||
round: combat.round,
|
||||
turn: combat.turn,
|
||||
turns: turns,
|
||||
control: isPlayerTurn && canControl
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve a source image for a combatant.
|
||||
* @param {Combatant} combatant The combatant queried for image.
|
||||
* @returns {Promise<string>} The source image attributed for this combatant.
|
||||
* @protected
|
||||
*/
|
||||
async _getCombatantThumbnail(combatant) {
|
||||
if ( combatant._videoSrc && !combatant.img ) {
|
||||
if ( combatant._thumb ) return combatant._thumb;
|
||||
return combatant._thumb = await game.video.createThumbnail(combatant._videoSrc, {width: 100, height: 100});
|
||||
}
|
||||
return combatant.img ?? CONST.DEFAULT_TOKEN;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
const tracker = html.find("#combat-tracker");
|
||||
const combatants = tracker.find(".combatant");
|
||||
|
||||
// Create new Combat encounter
|
||||
html.find(".combat-create").click(ev => this._onCombatCreate(ev));
|
||||
|
||||
// Display Combat settings
|
||||
html.find(".combat-settings").click(ev => {
|
||||
ev.preventDefault();
|
||||
new CombatTrackerConfig().render(true);
|
||||
});
|
||||
|
||||
// Cycle the current Combat encounter
|
||||
html.find(".combat-cycle").click(ev => this._onCombatCycle(ev));
|
||||
|
||||
// Combat control
|
||||
html.find(".combat-control").click(ev => this._onCombatControl(ev));
|
||||
|
||||
// Combatant control
|
||||
html.find(".combatant-control").click(ev => this._onCombatantControl(ev));
|
||||
|
||||
// Hover on Combatant
|
||||
combatants.hover(this._onCombatantHoverIn.bind(this), this._onCombatantHoverOut.bind(this));
|
||||
|
||||
// Click on Combatant
|
||||
combatants.click(this._onCombatantMouseDown.bind(this));
|
||||
|
||||
// Context on right-click
|
||||
if ( game.user.isGM ) this._contextMenu(html);
|
||||
|
||||
// Intersection Observer for Combatant avatars
|
||||
const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), {root: tracker[0]});
|
||||
combatants.each((i, li) => observer.observe(li));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle new Combat creation request
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
async _onCombatCreate(event) {
|
||||
event.preventDefault();
|
||||
let scene = game.scenes.current;
|
||||
const cls = getDocumentClass("Combat");
|
||||
await cls.create({scene: scene?.id, active: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a Combat cycle request
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
async _onCombatCycle(event) {
|
||||
event.preventDefault();
|
||||
const btn = event.currentTarget;
|
||||
const combat = game.combats.get(btn.dataset.documentId);
|
||||
if ( !combat ) return;
|
||||
await combat.activate({render: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events on Combat control buttons
|
||||
* @private
|
||||
* @param {Event} event The originating mousedown event
|
||||
*/
|
||||
async _onCombatControl(event) {
|
||||
event.preventDefault();
|
||||
const combat = this.viewed;
|
||||
const ctrl = event.currentTarget;
|
||||
if ( ctrl.getAttribute("disabled") ) return;
|
||||
else ctrl.setAttribute("disabled", true);
|
||||
try {
|
||||
const fn = combat[ctrl.dataset.control];
|
||||
if ( fn ) await fn.bind(combat)();
|
||||
} finally {
|
||||
ctrl.removeAttribute("disabled");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a Combatant control toggle
|
||||
* @private
|
||||
* @param {Event} event The originating mousedown event
|
||||
*/
|
||||
async _onCombatantControl(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const btn = event.currentTarget;
|
||||
const li = btn.closest(".combatant");
|
||||
const combat = this.viewed;
|
||||
const c = combat.combatants.get(li.dataset.combatantId);
|
||||
|
||||
// Switch control action
|
||||
switch ( btn.dataset.control ) {
|
||||
|
||||
// Toggle combatant visibility
|
||||
case "toggleHidden":
|
||||
return c.update({hidden: !c.hidden});
|
||||
|
||||
// Toggle combatant defeated flag
|
||||
case "toggleDefeated":
|
||||
return this._onToggleDefeatedStatus(c);
|
||||
|
||||
// Roll combatant initiative
|
||||
case "rollInitiative":
|
||||
return combat.rollInitiative([c.id]);
|
||||
|
||||
// Actively ping the Combatant
|
||||
case "pingCombatant":
|
||||
return this._onPingCombatant(c);
|
||||
|
||||
case "panToCombatant":
|
||||
return this._onPanToCombatant(c);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling the defeated status effect on a combatant Token
|
||||
* @param {Combatant} combatant The combatant data being modified
|
||||
* @returns {Promise} A Promise that resolves after all operations are complete
|
||||
* @private
|
||||
*/
|
||||
async _onToggleDefeatedStatus(combatant) {
|
||||
const isDefeated = !combatant.isDefeated;
|
||||
await combatant.update({defeated: isDefeated});
|
||||
const defeatedId = CONFIG.specialStatusEffects.DEFEATED;
|
||||
await combatant.actor?.toggleStatusEffect(defeatedId, {overlay: true, active: isDefeated});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle pinging a combatant Token
|
||||
* @param {Combatant} combatant The combatant data
|
||||
* @returns {Promise}
|
||||
* @protected
|
||||
*/
|
||||
async _onPingCombatant(combatant) {
|
||||
if ( !canvas.ready || (combatant.sceneId !== canvas.scene.id) ) return;
|
||||
if ( !combatant.token.object.visible ) return ui.notifications.warn(game.i18n.localize("COMBAT.WarnNonVisibleToken"));
|
||||
await canvas.ping(combatant.token.object.center);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle panning to a combatant Token
|
||||
* @param {Combatant} combatant The combatant data
|
||||
* @returns {Promise}
|
||||
* @protected
|
||||
*/
|
||||
async _onPanToCombatant(combatant) {
|
||||
if ( !canvas.ready || (combatant.sceneId !== canvas.scene.id) ) return;
|
||||
if ( !combatant.token.object.visible ) return ui.notifications.warn(game.i18n.localize("COMBAT.WarnNonVisibleToken"));
|
||||
const {x, y} = combatant.token.object.center;
|
||||
await canvas.animatePan({x, y, scale: Math.max(canvas.stage.scale.x, 0.5)});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-down event on a combatant name in the tracker
|
||||
* @param {Event} event The originating mousedown event
|
||||
* @returns {Promise} A Promise that resolves once the pan is complete
|
||||
* @private
|
||||
*/
|
||||
async _onCombatantMouseDown(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const li = event.currentTarget;
|
||||
const combatant = this.viewed.combatants.get(li.dataset.combatantId);
|
||||
const token = combatant.token;
|
||||
if ( !combatant.actor?.testUserPermission(game.user, "OBSERVER") ) return;
|
||||
const now = Date.now();
|
||||
|
||||
// Handle double-left click to open sheet
|
||||
const dt = now - this._clickTime;
|
||||
this._clickTime = now;
|
||||
if ( dt <= 250 ) return combatant.actor?.sheet.render(true);
|
||||
|
||||
// Control and pan to Token object
|
||||
if ( token?.object ) {
|
||||
token.object?.control({releaseOthers: true});
|
||||
return canvas.animatePan(token.object.center);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-hover events on a combatant in the tracker
|
||||
* @private
|
||||
*/
|
||||
_onCombatantHoverIn(event) {
|
||||
event.preventDefault();
|
||||
if ( !canvas.ready ) return;
|
||||
const li = event.currentTarget;
|
||||
const combatant = this.viewed.combatants.get(li.dataset.combatantId);
|
||||
const token = combatant.token?.object;
|
||||
if ( token?.isVisible ) {
|
||||
if ( !token.controlled ) token._onHoverIn(event, {hoverOutOthers: true});
|
||||
this._highlighted = token;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-unhover events for a combatant in the tracker
|
||||
* @private
|
||||
*/
|
||||
_onCombatantHoverOut(event) {
|
||||
event.preventDefault();
|
||||
if ( this._highlighted ) this._highlighted._onHoverOut(event);
|
||||
this._highlighted = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Highlight a hovered combatant in the tracker.
|
||||
* @param {Combatant} combatant The Combatant
|
||||
* @param {boolean} hover Whether they are being hovered in or out.
|
||||
*/
|
||||
hoverCombatant(combatant, hover) {
|
||||
const trackers = [this.element[0]];
|
||||
if ( this._popout ) trackers.push(this._popout.element[0]);
|
||||
for ( const tracker of trackers ) {
|
||||
const li = tracker.querySelector(`.combatant[data-combatant-id="${combatant.id}"]`);
|
||||
if ( !li ) continue;
|
||||
if ( hover ) li.classList.add("hover");
|
||||
else li.classList.remove("hover");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_contextMenu(html) {
|
||||
ContextMenu.create(this, html, ".directory-item", this._getEntryContextOptions());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Combatant entry context options
|
||||
* @returns {object[]} The Combatant entry context options
|
||||
* @private
|
||||
*/
|
||||
_getEntryContextOptions() {
|
||||
return [
|
||||
{
|
||||
name: "COMBAT.CombatantUpdate",
|
||||
icon: '<i class="fas fa-edit"></i>',
|
||||
callback: this._onConfigureCombatant.bind(this)
|
||||
},
|
||||
{
|
||||
name: "COMBAT.CombatantClear",
|
||||
icon: '<i class="fas fa-undo"></i>',
|
||||
condition: li => {
|
||||
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
|
||||
return Number.isNumeric(combatant?.initiative);
|
||||
},
|
||||
callback: li => {
|
||||
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
|
||||
if ( combatant ) return combatant.update({initiative: null});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "COMBAT.CombatantReroll",
|
||||
icon: '<i class="fas fa-dice-d20"></i>',
|
||||
callback: li => {
|
||||
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
|
||||
if ( combatant ) return this.viewed.rollInitiative([combatant.id]);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "COMBAT.CombatantRemove",
|
||||
icon: '<i class="fas fa-trash"></i>',
|
||||
callback: li => {
|
||||
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
|
||||
if ( combatant ) return combatant.delete();
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog which prompts the user to enter a new initiative value for a Combatant
|
||||
* @param {jQuery} li
|
||||
* @private
|
||||
*/
|
||||
_onConfigureCombatant(li) {
|
||||
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
|
||||
new CombatantConfig(combatant, {
|
||||
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
|
||||
left: window.innerWidth - 720,
|
||||
width: 400
|
||||
}).render(true);
|
||||
}
|
||||
}
|
||||
432
resources/app/client/apps/sidebar/tabs/compendium-directory.js
Normal file
432
resources/app/client/apps/sidebar/tabs/compendium-directory.js
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* A compendium of knowledge arcane and mystical!
|
||||
* Renders the sidebar directory of compendium packs
|
||||
* @extends {SidebarTab}
|
||||
* @mixes {DirectoryApplication}
|
||||
*/
|
||||
class CompendiumDirectory extends DirectoryApplicationMixin(SidebarTab) {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "compendium",
|
||||
template: "templates/sidebar/compendium-directory.html",
|
||||
title: "COMPENDIUM.SidebarTitle",
|
||||
contextMenuSelector: ".directory-item.compendium",
|
||||
entryClickSelector: ".compendium"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the currently active compendium types. If empty, all types are shown.
|
||||
* @type {string[]}
|
||||
*/
|
||||
#activeFilters = [];
|
||||
|
||||
get activeFilters() {
|
||||
return this.#activeFilters;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
entryType = "Compendium";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static entryPartial = "templates/sidebar/partials/pack-partial.html";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_entryAlreadyExists(entry) {
|
||||
return this.collection.has(entry.collection);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getEntryDragData(entryId) {
|
||||
const pack = this.collection.get(entryId);
|
||||
return {
|
||||
type: "Compendium",
|
||||
id: pack.collection
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_entryIsSelf(entry, otherEntry) {
|
||||
return entry.metadata.id === otherEntry.metadata.id;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _sortRelative(entry, sortData) {
|
||||
// We build up a single update object for all compendiums to prevent multiple re-renders
|
||||
const packConfig = game.settings.get("core", "compendiumConfiguration");
|
||||
const targetFolderId = sortData.updateData.folder;
|
||||
packConfig[entry.collection] = foundry.utils.mergeObject(packConfig[entry.collection] || {}, {
|
||||
folder: targetFolderId
|
||||
});
|
||||
|
||||
// Update sorting
|
||||
const sorting = SortingHelpers.performIntegerSort(entry, sortData);
|
||||
for ( const s of sorting ) {
|
||||
const pack = s.target;
|
||||
const existingConfig = packConfig[pack.collection] || {};
|
||||
existingConfig.sort = s.update.sort;
|
||||
}
|
||||
await game.settings.set("core", "compendiumConfiguration", packConfig);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find(".filter").click(this._displayFilterCompendiumMenu.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a menu of compendium types to filter by
|
||||
* @param {PointerEvent} event The originating pointer event
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _displayFilterCompendiumMenu(event) {
|
||||
// If there is a current dropdown menu, remove it
|
||||
const dropdown = document.getElementsByClassName("dropdown-menu")[0];
|
||||
if ( dropdown ) {
|
||||
dropdown.remove();
|
||||
return;
|
||||
}
|
||||
const button = event.currentTarget;
|
||||
|
||||
// Display a menu of compendium types to filter by
|
||||
const choices = CONST.COMPENDIUM_DOCUMENT_TYPES.map(t => {
|
||||
const config = CONFIG[t];
|
||||
return {
|
||||
name: game.i18n.localize(config.documentClass.metadata.label),
|
||||
icon: config.sidebarIcon,
|
||||
type: t,
|
||||
callback: (event) => this._onToggleCompendiumFilterType(event, t)
|
||||
};
|
||||
});
|
||||
|
||||
// If there are active filters, add a "Clear Filters" option
|
||||
if ( this.#activeFilters.length ) {
|
||||
choices.unshift({
|
||||
name: game.i18n.localize("COMPENDIUM.ClearFilters"),
|
||||
icon: "fas fa-times",
|
||||
type: null,
|
||||
callback: (event) => this._onToggleCompendiumFilterType(event, null)
|
||||
});
|
||||
}
|
||||
|
||||
// Create a vertical list of buttons contained in a div
|
||||
const menu = document.createElement("div");
|
||||
menu.classList.add("dropdown-menu");
|
||||
const list = document.createElement("div");
|
||||
list.classList.add("dropdown-list", "flexcol");
|
||||
menu.appendChild(list);
|
||||
for ( let c of choices ) {
|
||||
const dropdownItem = document.createElement("a");
|
||||
dropdownItem.classList.add("dropdown-item");
|
||||
if ( this.#activeFilters.includes(c.type) ) dropdownItem.classList.add("active");
|
||||
dropdownItem.innerHTML = `<i class="${c.icon}"></i> ${c.name}`;
|
||||
dropdownItem.addEventListener("click", c.callback);
|
||||
list.appendChild(dropdownItem);
|
||||
}
|
||||
|
||||
// Position the menu
|
||||
const pos = {
|
||||
top: button.offsetTop + 10,
|
||||
left: button.offsetLeft + 10
|
||||
};
|
||||
menu.style.top = `${pos.top}px`;
|
||||
menu.style.left = `${pos.left}px`;
|
||||
button.parentElement.appendChild(menu);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling a compendium type filter
|
||||
* @param {PointerEvent} event The originating pointer event
|
||||
* @param {string|null} type The compendium type to filter by. If null, clear all filters.
|
||||
* @protected
|
||||
*/
|
||||
_onToggleCompendiumFilterType(event, type) {
|
||||
if ( type === null ) this.#activeFilters = [];
|
||||
else this.#activeFilters = this.#activeFilters.includes(type) ?
|
||||
this.#activeFilters.filter(t => t !== type) : this.#activeFilters.concat(type);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The collection of Compendium Packs which are displayed in this Directory
|
||||
* @returns {CompendiumPacks<string, CompendiumCollection>}
|
||||
*/
|
||||
get collection() {
|
||||
return game.packs;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the dropped Entry from the drop data
|
||||
* @param {object} data The data being dropped
|
||||
* @returns {Promise<object>} The dropped Entry
|
||||
* @protected
|
||||
*/
|
||||
async _getDroppedEntryFromData(data) {
|
||||
return game.packs.get(data.id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _createDroppedEntry(document, folder) {
|
||||
throw new Error("The _createDroppedEntry shouldn't be called for CompendiumDirectory");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getEntryName(entry) {
|
||||
return entry.metadata.label;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getEntryId(entry) {
|
||||
return entry.metadata.id;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
let context = await super.getData(options);
|
||||
|
||||
// For each document, assign a default image if one is not already present, and calculate the style string
|
||||
const packageTypeIcons = {
|
||||
"world": World.icon,
|
||||
"system": System.icon,
|
||||
"module": Module.icon
|
||||
};
|
||||
const packContext = {};
|
||||
for ( const pack of this.collection ) {
|
||||
packContext[pack.collection] = {
|
||||
locked: pack.locked,
|
||||
customOwnership: "ownership" in pack.config,
|
||||
collection: pack.collection,
|
||||
name: pack.metadata.packageName,
|
||||
label: pack.metadata.label,
|
||||
icon: CONFIG[pack.metadata.type].sidebarIcon,
|
||||
hidden: this.#activeFilters?.length ? !this.#activeFilters.includes(pack.metadata.type) : false,
|
||||
banner: pack.banner,
|
||||
sourceIcon: packageTypeIcons[pack.metadata.packageType]
|
||||
};
|
||||
}
|
||||
|
||||
// Return data to the sidebar
|
||||
context = foundry.utils.mergeObject(context, {
|
||||
folderIcon: CONFIG.Folder.sidebarIcon,
|
||||
label: game.i18n.localize("PACKAGE.TagCompendium"),
|
||||
labelPlural: game.i18n.localize("SIDEBAR.TabCompendium"),
|
||||
sidebarIcon: "fas fa-atlas",
|
||||
filtersActive: !!this.#activeFilters.length
|
||||
});
|
||||
context.packContext = packContext;
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async render(force=false, options={}) {
|
||||
game.packs.initializeTree();
|
||||
return super.render(force, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getEntryContextOptions() {
|
||||
if ( !game.user.isGM ) return [];
|
||||
return [
|
||||
{
|
||||
name: "OWNERSHIP.Configure",
|
||||
icon: '<i class="fa-solid fa-user-lock"></i>',
|
||||
callback: li => {
|
||||
const pack = game.packs.get(li.data("pack"));
|
||||
return pack.configureOwnershipDialog();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "FOLDER.Clear",
|
||||
icon: '<i class="fas fa-folder"></i>',
|
||||
condition: header => {
|
||||
const li = header.closest(".directory-item");
|
||||
const entry = this.collection.get(li.data("entryId"));
|
||||
return !!entry.folder;
|
||||
},
|
||||
callback: header => {
|
||||
const li = header.closest(".directory-item");
|
||||
const entry = this.collection.get(li.data("entryId"));
|
||||
entry.setFolder(null);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "COMPENDIUM.ToggleLocked",
|
||||
icon: '<i class="fas fa-lock"></i>',
|
||||
callback: li => {
|
||||
let pack = game.packs.get(li.data("pack"));
|
||||
const isUnlock = pack.locked;
|
||||
if ( isUnlock && (pack.metadata.packageType !== "world")) {
|
||||
return Dialog.confirm({
|
||||
title: `${game.i18n.localize("COMPENDIUM.ToggleLocked")}: ${pack.title}`,
|
||||
content: `<p><strong>${game.i18n.localize("Warning")}:</strong> ${game.i18n.localize("COMPENDIUM.ToggleLockedWarning")}</p>`,
|
||||
yes: () => pack.configure({locked: !pack.locked}),
|
||||
options: {
|
||||
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
|
||||
left: window.innerWidth - 720,
|
||||
width: 400
|
||||
}
|
||||
});
|
||||
}
|
||||
else return pack.configure({locked: !pack.locked});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "COMPENDIUM.Duplicate",
|
||||
icon: '<i class="fas fa-copy"></i>',
|
||||
callback: li => {
|
||||
let pack = game.packs.get(li.data("pack"));
|
||||
const html = `<form>
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize("COMPENDIUM.DuplicateTitle")}</label>
|
||||
<input type="text" name="label" value="${game.i18n.format("DOCUMENT.CopyOf", {name: pack.title})}"/>
|
||||
<p class="notes">${game.i18n.localize("COMPENDIUM.DuplicateHint")}</p>
|
||||
</div>
|
||||
</form>`;
|
||||
return Dialog.confirm({
|
||||
title: `${game.i18n.localize("COMPENDIUM.Duplicate")}: ${pack.title}`,
|
||||
content: html,
|
||||
yes: html => {
|
||||
const label = html.querySelector('input[name="label"]').value;
|
||||
return pack.duplicateCompendium({label});
|
||||
},
|
||||
options: {
|
||||
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
|
||||
left: window.innerWidth - 720,
|
||||
width: 400,
|
||||
jQuery: false
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "COMPENDIUM.ImportAll",
|
||||
icon: '<i class="fas fa-download"></i>',
|
||||
condition: li => game.packs.get(li.data("pack"))?.documentName !== "Adventure",
|
||||
callback: li => {
|
||||
let pack = game.packs.get(li.data("pack"));
|
||||
return pack.importDialog({
|
||||
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
|
||||
left: window.innerWidth - 720,
|
||||
width: 400
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "COMPENDIUM.Delete",
|
||||
icon: '<i class="fas fa-trash"></i>',
|
||||
condition: li => {
|
||||
let pack = game.packs.get(li.data("pack"));
|
||||
return pack.metadata.packageType === "world";
|
||||
},
|
||||
callback: li => {
|
||||
let pack = game.packs.get(li.data("pack"));
|
||||
return this._onDeleteCompendium(pack);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onClickEntryName(event) {
|
||||
event.preventDefault();
|
||||
const element = event.currentTarget;
|
||||
const packId = element.closest("[data-pack]").dataset.pack;
|
||||
const pack = game.packs.get(packId);
|
||||
pack.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onCreateEntry(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const li = event.currentTarget.closest(".directory-item");
|
||||
const targetFolderId = li ? li.dataset.folderId : null;
|
||||
const types = CONST.COMPENDIUM_DOCUMENT_TYPES.map(documentName => {
|
||||
return { value: documentName, label: game.i18n.localize(getDocumentClass(documentName).metadata.label) };
|
||||
});
|
||||
game.i18n.sortObjects(types, "label");
|
||||
const folders = this.collection._formatFolderSelectOptions();
|
||||
const html = await renderTemplate("templates/sidebar/compendium-create.html",
|
||||
{types, folders, folder: targetFolderId, hasFolders: folders.length >= 1});
|
||||
return Dialog.prompt({
|
||||
title: game.i18n.localize("COMPENDIUM.Create"),
|
||||
content: html,
|
||||
label: game.i18n.localize("COMPENDIUM.Create"),
|
||||
callback: async html => {
|
||||
const form = html.querySelector("#compendium-create");
|
||||
const fd = new FormDataExtended(form);
|
||||
const metadata = fd.object;
|
||||
let targetFolderId = metadata.folder;
|
||||
if ( metadata.folder ) delete metadata.folder;
|
||||
if ( !metadata.label ) {
|
||||
let defaultName = game.i18n.format("DOCUMENT.New", {type: game.i18n.localize("PACKAGE.TagCompendium")});
|
||||
const count = game.packs.size;
|
||||
if ( count > 0 ) defaultName += ` (${count + 1})`;
|
||||
metadata.label = defaultName;
|
||||
}
|
||||
const pack = await CompendiumCollection.createCompendium(metadata);
|
||||
if ( targetFolderId ) await pack.setFolder(targetFolderId);
|
||||
},
|
||||
rejectClose: false,
|
||||
options: { jQuery: false }
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a Compendium Pack deletion request
|
||||
* @param {object} pack The pack object requested for deletion
|
||||
* @private
|
||||
*/
|
||||
_onDeleteCompendium(pack) {
|
||||
return Dialog.confirm({
|
||||
title: `${game.i18n.localize("COMPENDIUM.Delete")}: ${pack.title}`,
|
||||
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("COMPENDIUM.DeleteWarning")}</p>`,
|
||||
yes: () => pack.deleteCompendium(),
|
||||
defaultYes: false
|
||||
});
|
||||
}
|
||||
}
|
||||
39
resources/app/client/apps/sidebar/tabs/items-directory.js
Normal file
39
resources/app/client/apps/sidebar/tabs/items-directory.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The sidebar directory which organizes and displays world-level Item documents.
|
||||
*/
|
||||
class ItemDirectory extends DocumentDirectory {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Item";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_canDragDrop(selector) {
|
||||
return game.user.can("ITEM_CREATE");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getEntryContextOptions() {
|
||||
const options = super._getEntryContextOptions();
|
||||
return [
|
||||
{
|
||||
name: "ITEM.ViewArt",
|
||||
icon: '<i class="fas fa-image"></i>',
|
||||
condition: li => {
|
||||
const item = game.items.get(li.data("documentId"));
|
||||
return item.img !== CONST.DEFAULT_TOKEN;
|
||||
},
|
||||
callback: li => {
|
||||
const item = game.items.get(li.data("documentId"));
|
||||
new ImagePopout(item.img, {
|
||||
title: item.name,
|
||||
uuid: item.uuid
|
||||
}).render(true);
|
||||
}
|
||||
}
|
||||
].concat(options);
|
||||
}
|
||||
}
|
||||
30
resources/app/client/apps/sidebar/tabs/journal-directory.js
Normal file
30
resources/app/client/apps/sidebar/tabs/journal-directory.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The sidebar directory which organizes and displays world-level JournalEntry documents.
|
||||
* @extends {DocumentDirectory}
|
||||
*/
|
||||
class JournalDirectory extends DocumentDirectory {
|
||||
|
||||
/** @override */
|
||||
static documentName = "JournalEntry";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getEntryContextOptions() {
|
||||
const options = super._getEntryContextOptions();
|
||||
return options.concat([
|
||||
{
|
||||
name: "SIDEBAR.JumpPin",
|
||||
icon: '<i class="fas fa-crosshairs"></i>',
|
||||
condition: li => {
|
||||
const entry = game.journal.get(li.data("document-id"));
|
||||
return !!entry.sceneNote;
|
||||
},
|
||||
callback: li => {
|
||||
const entry = game.journal.get(li.data("document-id"));
|
||||
return entry.panToNote();
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
19
resources/app/client/apps/sidebar/tabs/macros-directory.js
Normal file
19
resources/app/client/apps/sidebar/tabs/macros-directory.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The directory, not displayed in the sidebar, which organizes and displays world-level Macro documents.
|
||||
* @extends {DocumentDirectory}
|
||||
*
|
||||
* @see {@link Macros} The WorldCollection of Macro Documents
|
||||
* @see {@link Macro} The Macro Document
|
||||
* @see {@link MacroConfig} The Macro Configuration Sheet
|
||||
*/
|
||||
class MacroDirectory extends DocumentDirectory {
|
||||
constructor(options={}) {
|
||||
options.popOut = true;
|
||||
super(options);
|
||||
delete ui.sidebar.tabs["macros"];
|
||||
game.macros.apps.push(this);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static documentName = "Macro";
|
||||
}
|
||||
770
resources/app/client/apps/sidebar/tabs/playlists-directory.js
Normal file
770
resources/app/client/apps/sidebar/tabs/playlists-directory.js
Normal file
@@ -0,0 +1,770 @@
|
||||
/**
|
||||
* The sidebar directory which organizes and displays world-level Playlist documents.
|
||||
* @extends {DocumentDirectory}
|
||||
*/
|
||||
class PlaylistDirectory extends DocumentDirectory {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
/**
|
||||
* Track the playlist IDs which are currently expanded in their display
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
this._expanded = this._createExpandedSet();
|
||||
|
||||
/**
|
||||
* Are the global volume controls currently expanded?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
this._volumeExpanded = true;
|
||||
|
||||
/**
|
||||
* Cache the set of Playlist documents that are displayed as playing when the directory is rendered
|
||||
* @type {Playlist[]}
|
||||
*/
|
||||
this._playingPlaylists = [];
|
||||
|
||||
/**
|
||||
* Cache the set of PlaylistSound documents that are displayed as playing when the directory is rendered
|
||||
* @type {PlaylistSound[]}
|
||||
*/
|
||||
this._playingSounds = [];
|
||||
|
||||
// Update timestamps every second
|
||||
setInterval(this._updateTimestamps.bind(this), 1000);
|
||||
|
||||
// Playlist 'currently playing' pinned location.
|
||||
game.settings.register("core", "playlist.playingLocation", {
|
||||
scope: "client",
|
||||
config: false,
|
||||
default: "top",
|
||||
type: String,
|
||||
onChange: () => ui.playlists.render()
|
||||
});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static documentName = "Playlist";
|
||||
|
||||
/** @override */
|
||||
static entryPartial = "templates/sidebar/partials/playlist-partial.html";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.template = "templates/sidebar/playlists-directory.html";
|
||||
options.dragDrop[0].dragSelector = ".folder, .playlist-name, .sound-name";
|
||||
options.renderUpdateKeys = ["name", "playing", "mode", "sounds", "sort", "sorting", "folder"];
|
||||
options.contextMenuSelector = ".document .playlist-header";
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the set of Playlists which should be displayed in an expanded form
|
||||
* @returns {Set<string>}
|
||||
* @private
|
||||
*/
|
||||
_createExpandedSet() {
|
||||
const expanded = new Set();
|
||||
for ( let playlist of this.documents ) {
|
||||
if ( playlist.playing ) expanded.add(playlist.id);
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return an Array of the Playlist documents which are currently playing
|
||||
* @type {Playlist[]}
|
||||
*/
|
||||
get playing() {
|
||||
return this._playingPlaylists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the 'currently playing' element is pinned to the top or bottom of the display.
|
||||
* @type {string}
|
||||
* @private
|
||||
*/
|
||||
get _playingLocation() {
|
||||
return game.settings.get("core", "playlist.playingLocation");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
this._playingPlaylists = [];
|
||||
this._playingSounds = [];
|
||||
this._playingSoundsData = [];
|
||||
this._prepareTreeData(this.collection.tree);
|
||||
const data = await super.getData(options);
|
||||
const currentAtTop = this._playingLocation === "top";
|
||||
return foundry.utils.mergeObject(data, {
|
||||
playingSounds: this._playingSoundsData,
|
||||
showPlaying: this._playingSoundsData.length > 0,
|
||||
playlistModifier: foundry.audio.AudioHelper.volumeToInput(game.settings.get("core", "globalPlaylistVolume")),
|
||||
playlistTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalPlaylistVolume")),
|
||||
ambientModifier: foundry.audio.AudioHelper.volumeToInput(game.settings.get("core", "globalAmbientVolume")),
|
||||
ambientTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalAmbientVolume")),
|
||||
interfaceModifier: foundry.audio.AudioHelper.volumeToInput(game.settings.get("core", "globalInterfaceVolume")),
|
||||
interfaceTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalInterfaceVolume")),
|
||||
volumeExpanded: this._volumeExpanded,
|
||||
currentlyPlaying: {
|
||||
class: `location-${currentAtTop ? "top" : "bottom"}`,
|
||||
location: {top: currentAtTop, bottom: !currentAtTop},
|
||||
pin: {label: `PLAYLIST.PinTo${currentAtTop ? "Bottom" : "Top"}`, caret: currentAtTop ? "down" : "up"}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Converts a volume level to a human-friendly % value
|
||||
* @param {number} volume Value between [0, 1] of the volume level
|
||||
* @returns {string}
|
||||
*/
|
||||
static volumeToTooltip(volume) {
|
||||
return game.i18n.format("PLAYLIST.VOLUME.TOOLTIP", { volume: Math.round(foundry.audio.AudioHelper.volumeToInput(volume) * 100) });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Augment the tree directory structure with playlist-level data objects for rendering
|
||||
* @param {object} node The tree leaf node being prepared
|
||||
* @private
|
||||
*/
|
||||
_prepareTreeData(node) {
|
||||
node.entries = node.entries.map(p => this._preparePlaylistData(p));
|
||||
for ( const child of node.children ) this._prepareTreeData(child);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create an object of rendering data for each Playlist document being displayed
|
||||
* @param {Playlist} playlist The playlist to display
|
||||
* @returns {object} The data for rendering
|
||||
* @private
|
||||
*/
|
||||
_preparePlaylistData(playlist) {
|
||||
if ( playlist.playing ) this._playingPlaylists.push(playlist);
|
||||
|
||||
// Playlist configuration
|
||||
const p = playlist.toObject(false);
|
||||
p.modeTooltip = this._getModeTooltip(p.mode);
|
||||
p.modeIcon = this._getModeIcon(p.mode);
|
||||
p.disabled = p.mode === CONST.PLAYLIST_MODES.DISABLED;
|
||||
p.expanded = this._expanded.has(p._id);
|
||||
p.css = [p.expanded ? "" : "collapsed", playlist.playing ? "playing" : ""].filterJoin(" ");
|
||||
p.controlCSS = (playlist.isOwner && !p.disabled) ? "" : "disabled";
|
||||
p.isOwner = playlist.isOwner;
|
||||
|
||||
// Playlist sounds
|
||||
const sounds = [];
|
||||
for ( const soundId of playlist.playbackOrder ) {
|
||||
const sound = playlist.sounds.get(soundId);
|
||||
if ( !(sound.isOwner || sound.playing) ) continue;
|
||||
|
||||
// All sounds
|
||||
const s = sound.toObject(false);
|
||||
s.playlistId = playlist.id;
|
||||
s.css = s.playing ? "playing" : "";
|
||||
s.controlCSS = sound.isOwner ? "" : "disabled";
|
||||
s.playIcon = this._getPlayIcon(sound);
|
||||
s.playTitle = s.pausedTime ? "PLAYLIST.SoundResume" : "PLAYLIST.SoundPlay";
|
||||
s.isOwner = sound.isOwner;
|
||||
|
||||
// Playing sounds
|
||||
if ( sound.sound && !sound.sound.failed && (sound.playing || s.pausedTime) ) {
|
||||
s.isPaused = !sound.playing && s.pausedTime;
|
||||
s.pauseIcon = this._getPauseIcon(sound);
|
||||
s.lvolume = foundry.audio.AudioHelper.volumeToInput(s.volume);
|
||||
s.volumeTooltip = this.constructor.volumeToTooltip(s.volume);
|
||||
s.currentTime = this._formatTimestamp(sound.playing ? sound.sound.currentTime : s.pausedTime);
|
||||
s.durationTime = this._formatTimestamp(sound.sound.duration);
|
||||
this._playingSounds.push(sound);
|
||||
this._playingSoundsData.push(s);
|
||||
}
|
||||
sounds.push(s);
|
||||
}
|
||||
p.sounds = sounds;
|
||||
return p;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the icon used to represent the "play/stop" icon for the PlaylistSound
|
||||
* @param {PlaylistSound} sound The sound being rendered
|
||||
* @returns {string} The icon that should be used
|
||||
* @private
|
||||
*/
|
||||
_getPlayIcon(sound) {
|
||||
if ( !sound.playing ) return sound.pausedTime ? "fas fa-play-circle" : "fas fa-play";
|
||||
else return "fas fa-square";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the icon used to represent the pause/loading icon for the PlaylistSound
|
||||
* @param {PlaylistSound} sound The sound being rendered
|
||||
* @returns {string} The icon that should be used
|
||||
* @private
|
||||
*/
|
||||
_getPauseIcon(sound) {
|
||||
return (sound.playing && !sound.sound?.loaded) ? "fas fa-spinner fa-spin" : "fas fa-pause";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a constant playback mode, provide the FontAwesome icon used to display it
|
||||
* @param {number} mode
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_getModeIcon(mode) {
|
||||
return {
|
||||
[CONST.PLAYLIST_MODES.DISABLED]: "fas fa-ban",
|
||||
[CONST.PLAYLIST_MODES.SEQUENTIAL]: "far fa-arrow-alt-circle-right",
|
||||
[CONST.PLAYLIST_MODES.SHUFFLE]: "fas fa-random",
|
||||
[CONST.PLAYLIST_MODES.SIMULTANEOUS]: "fas fa-compress-arrows-alt"
|
||||
}[mode];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a constant playback mode, provide the string tooltip used to describe it
|
||||
* @param {number} mode
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_getModeTooltip(mode) {
|
||||
return {
|
||||
[CONST.PLAYLIST_MODES.DISABLED]: game.i18n.localize("PLAYLIST.ModeDisabled"),
|
||||
[CONST.PLAYLIST_MODES.SEQUENTIAL]: game.i18n.localize("PLAYLIST.ModeSequential"),
|
||||
[CONST.PLAYLIST_MODES.SHUFFLE]: game.i18n.localize("PLAYLIST.ModeShuffle"),
|
||||
[CONST.PLAYLIST_MODES.SIMULTANEOUS]: game.i18n.localize("PLAYLIST.ModeSimultaneous")
|
||||
}[mode];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
// Volume sliders
|
||||
html.find(".global-volume-slider").change(this._onGlobalVolume.bind(this));
|
||||
html.find(".sound-volume").change(this._onSoundVolume.bind(this));
|
||||
|
||||
// Collapse/Expand
|
||||
html.find("#global-volume .playlist-header").click(this._onVolumeCollapse.bind(this));
|
||||
|
||||
// Currently playing pinning
|
||||
html.find("#currently-playing .pin").click(this._onPlayingPin.bind(this));
|
||||
|
||||
// Playlist Control Events
|
||||
html.on("click", "a.sound-control", event => {
|
||||
event.preventDefault();
|
||||
const btn = event.currentTarget;
|
||||
const action = btn.dataset.action;
|
||||
if (!action || btn.classList.contains("disabled")) return;
|
||||
|
||||
// Delegate to Playlist and Sound control handlers
|
||||
switch (action) {
|
||||
case "playlist-mode":
|
||||
return this._onPlaylistToggleMode(event);
|
||||
case "playlist-play":
|
||||
case "playlist-stop":
|
||||
return this._onPlaylistPlay(event, action === "playlist-play");
|
||||
case "playlist-forward":
|
||||
case "playlist-backward":
|
||||
return this._onPlaylistSkip(event, action);
|
||||
case "sound-create":
|
||||
return this._onSoundCreate(event);
|
||||
case "sound-pause":
|
||||
case "sound-play":
|
||||
case "sound-stop":
|
||||
return this._onSoundPlay(event, action);
|
||||
case "sound-repeat":
|
||||
return this._onSoundToggleMode(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle global volume change for the playlist sidebar
|
||||
* @param {MouseEvent} event The initial click event
|
||||
* @private
|
||||
*/
|
||||
_onGlobalVolume(event) {
|
||||
event.preventDefault();
|
||||
const slider = event.currentTarget;
|
||||
const volume = foundry.audio.AudioHelper.inputToVolume(slider.value);
|
||||
const tooltip = PlaylistDirectory.volumeToTooltip(volume);
|
||||
slider.setAttribute("data-tooltip", tooltip);
|
||||
game.tooltip.activate(slider, {text: tooltip});
|
||||
return game.settings.set("core", slider.name, volume);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
collapseAll() {
|
||||
super.collapseAll();
|
||||
const el = this.element[0];
|
||||
for ( let p of el.querySelectorAll("li.playlist") ) {
|
||||
this._collapse(p, true);
|
||||
}
|
||||
this._expanded.clear();
|
||||
this._collapse(el.querySelector("#global-volume"), true);
|
||||
this._volumeExpanded = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onClickEntryName(event) {
|
||||
const li = event.currentTarget.closest(".playlist");
|
||||
const playlistId = li.dataset.documentId;
|
||||
const wasExpanded = this._expanded.has(playlistId);
|
||||
this._collapse(li, wasExpanded);
|
||||
if ( wasExpanded ) this._expanded.delete(playlistId);
|
||||
else this._expanded.add(playlistId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle global volume control collapse toggle
|
||||
* @param {MouseEvent} event The initial click event
|
||||
* @private
|
||||
*/
|
||||
_onVolumeCollapse(event) {
|
||||
event.preventDefault();
|
||||
const div = event.currentTarget.parentElement;
|
||||
this._volumeExpanded = !this._volumeExpanded;
|
||||
this._collapse(div, !this._volumeExpanded);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Helper method to render the expansion or collapse of playlists
|
||||
* @private
|
||||
*/
|
||||
_collapse(el, collapse, speed = 250) {
|
||||
const ol = el.querySelector(".playlist-sounds");
|
||||
const icon = el.querySelector("i.collapse");
|
||||
if (collapse) { // Collapse the sounds
|
||||
$(ol).slideUp(speed, () => {
|
||||
el.classList.add("collapsed");
|
||||
icon.classList.replace("fa-angle-down", "fa-angle-up");
|
||||
});
|
||||
}
|
||||
else { // Expand the sounds
|
||||
$(ol).slideDown(speed, () => {
|
||||
el.classList.remove("collapsed");
|
||||
icon.classList.replace("fa-angle-up", "fa-angle-down");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle Playlist playback state changes
|
||||
* @param {MouseEvent} event The initial click event
|
||||
* @param {boolean} playing Is the playlist now playing?
|
||||
* @private
|
||||
*/
|
||||
_onPlaylistPlay(event, playing) {
|
||||
const li = event.currentTarget.closest(".playlist");
|
||||
const playlist = game.playlists.get(li.dataset.documentId);
|
||||
if ( playing ) return playlist.playAll();
|
||||
else return playlist.stopAll();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle advancing the playlist to the next (or previous) sound
|
||||
* @param {MouseEvent} event The initial click event
|
||||
* @param {string} action The control action requested
|
||||
* @private
|
||||
*/
|
||||
_onPlaylistSkip(event, action) {
|
||||
const li = event.currentTarget.closest(".playlist");
|
||||
const playlist = game.playlists.get(li.dataset.documentId);
|
||||
return playlist.playNext(undefined, {direction: action === "playlist-forward" ? 1 : -1});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle cycling the playback mode for a Playlist
|
||||
* @param {MouseEvent} event The initial click event
|
||||
* @private
|
||||
*/
|
||||
_onPlaylistToggleMode(event) {
|
||||
const li = event.currentTarget.closest(".playlist");
|
||||
const playlist = game.playlists.get(li.dataset.documentId);
|
||||
return playlist.cycleMode();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle Playlist track addition request
|
||||
* @param {MouseEvent} event The initial click event
|
||||
* @private
|
||||
*/
|
||||
_onSoundCreate(event) {
|
||||
const li = $(event.currentTarget).parents('.playlist');
|
||||
const playlist = game.playlists.get(li.data("documentId"));
|
||||
const sound = new PlaylistSound({name: game.i18n.localize("SOUND.New")}, {parent: playlist});
|
||||
sound.sheet.render(true, {top: li[0].offsetTop, left: window.innerWidth - 670});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Modify the playback state of a Sound within a Playlist
|
||||
* @param {MouseEvent} event The initial click event
|
||||
* @param {string} action The sound control action performed
|
||||
* @private
|
||||
*/
|
||||
_onSoundPlay(event, action) {
|
||||
const li = event.currentTarget.closest(".sound");
|
||||
const playlist = game.playlists.get(li.dataset.playlistId);
|
||||
const sound = playlist.sounds.get(li.dataset.soundId);
|
||||
switch ( action ) {
|
||||
case "sound-play":
|
||||
return playlist.playSound(sound);
|
||||
case "sound-pause":
|
||||
return sound.update({playing: false, pausedTime: sound.sound.currentTime});
|
||||
case "sound-stop":
|
||||
return playlist.stopSound(sound);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle volume adjustments to sounds within a Playlist
|
||||
* @param {Event} event The initial change event
|
||||
* @private
|
||||
*/
|
||||
_onSoundVolume(event) {
|
||||
event.preventDefault();
|
||||
const slider = event.currentTarget;
|
||||
const li = slider.closest(".sound");
|
||||
const playlist = game.playlists.get(li.dataset.playlistId);
|
||||
const playlistSound = playlist.sounds.get(li.dataset.soundId);
|
||||
|
||||
// Get the desired target volume
|
||||
const volume = foundry.audio.AudioHelper.inputToVolume(slider.value);
|
||||
if ( volume === playlistSound.volume ) return;
|
||||
|
||||
// Immediately apply a local adjustment
|
||||
playlistSound.updateSource({volume});
|
||||
playlistSound.sound?.fade(playlistSound.volume, {duration: PlaylistSound.VOLUME_DEBOUNCE_MS});
|
||||
const tooltip = PlaylistDirectory.volumeToTooltip(volume);
|
||||
slider.setAttribute("data-tooltip", tooltip);
|
||||
game.tooltip.activate(slider, {text: tooltip});
|
||||
|
||||
// Debounce a change to the database
|
||||
if ( playlistSound.isOwner ) playlistSound.debounceVolume(volume);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle changes to the sound playback mode
|
||||
* @param {Event} event The initial click event
|
||||
* @private
|
||||
*/
|
||||
_onSoundToggleMode(event) {
|
||||
event.preventDefault();
|
||||
const li = event.currentTarget.closest(".sound");
|
||||
const playlist = game.playlists.get(li.dataset.playlistId);
|
||||
const sound = playlist.sounds.get(li.dataset.soundId);
|
||||
return sound.update({repeat: !sound.repeat});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_onPlayingPin() {
|
||||
const location = this._playingLocation === "top" ? "bottom" : "top";
|
||||
return game.settings.set("core", "playlist.playingLocation", location);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onSearchFilter(event, query, rgx, html) {
|
||||
const isSearch = !!query;
|
||||
const playlistIds = new Set();
|
||||
const soundIds = new Set();
|
||||
const folderIds = new Set();
|
||||
const nameOnlySearch = (this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME);
|
||||
|
||||
// Match documents and folders
|
||||
if ( isSearch ) {
|
||||
|
||||
let results = [];
|
||||
if ( !nameOnlySearch ) results = this.collection.search({query: query});
|
||||
|
||||
// Match Playlists and Sounds
|
||||
for ( let d of this.documents ) {
|
||||
let matched = false;
|
||||
for ( let s of d.sounds ) {
|
||||
if ( s.playing || rgx.test(SearchFilter.cleanQuery(s.name)) ) {
|
||||
soundIds.add(s._id);
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
if ( matched || d.playing || ( nameOnlySearch && rgx.test(SearchFilter.cleanQuery(d.name) )
|
||||
|| results.some(r => r._id === d._id)) ) {
|
||||
playlistIds.add(d._id);
|
||||
if ( d.folder ) folderIds.add(d.folder._id);
|
||||
}
|
||||
}
|
||||
|
||||
// Include parent Folders
|
||||
const folders = this.folders.sort((a, b) => b.depth - a.depth);
|
||||
for ( let f of folders ) {
|
||||
if ( folderIds.has(f.id) && f.folder ) folderIds.add(f.folder._id);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle each directory item
|
||||
for ( let el of html.querySelectorAll(".directory-item") ) {
|
||||
if ( el.classList.contains("global-volume") ) continue;
|
||||
|
||||
// Playlists
|
||||
if ( el.classList.contains("document") ) {
|
||||
const pid = el.dataset.documentId;
|
||||
let playlistIsMatch = !isSearch || playlistIds.has(pid);
|
||||
el.style.display = playlistIsMatch ? "flex" : "none";
|
||||
|
||||
// Sounds
|
||||
const sounds = el.querySelector(".playlist-sounds");
|
||||
for ( const li of sounds.children ) {
|
||||
let soundIsMatch = !isSearch || soundIds.has(li.dataset.soundId);
|
||||
li.style.display = soundIsMatch ? "flex" : "none";
|
||||
if ( soundIsMatch ) {
|
||||
playlistIsMatch = true;
|
||||
}
|
||||
}
|
||||
const showExpanded = this._expanded.has(pid) || (isSearch && playlistIsMatch);
|
||||
el.classList.toggle("collapsed", !showExpanded);
|
||||
}
|
||||
|
||||
|
||||
// Folders
|
||||
else if ( el.classList.contains("folder") ) {
|
||||
const hidden = isSearch && !folderIds.has(el.dataset.folderId);
|
||||
el.style.display = hidden ? "none" : "flex";
|
||||
const uuid = el.closest("li.folder").dataset.uuid;
|
||||
const expanded = (isSearch && folderIds.has(el.dataset.folderId)) ||
|
||||
(!isSearch && game.folders._expanded[uuid]);
|
||||
el.classList.toggle("collapsed", !expanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the displayed timestamps for all currently playing audio sources.
|
||||
* Runs on an interval every 1000ms.
|
||||
* @private
|
||||
*/
|
||||
_updateTimestamps() {
|
||||
if ( !this._playingSounds.length ) return;
|
||||
const playing = this.element.find("#currently-playing")[0];
|
||||
if ( !playing ) return;
|
||||
for ( let sound of this._playingSounds ) {
|
||||
const li = playing.querySelector(`.sound[data-sound-id="${sound.id}"]`);
|
||||
if ( !li ) continue;
|
||||
|
||||
// Update current and max playback time
|
||||
const current = li.querySelector("span.current");
|
||||
const ct = sound.playing ? sound.sound.currentTime : sound.pausedTime;
|
||||
if ( current ) current.textContent = this._formatTimestamp(ct);
|
||||
const max = li.querySelector("span.duration");
|
||||
if ( max ) max.textContent = this._formatTimestamp(sound.sound.duration);
|
||||
|
||||
// Remove the loading spinner
|
||||
const play = li.querySelector("a.pause");
|
||||
if ( play.classList.contains("fa-spinner") ) {
|
||||
play.classList.remove("fa-spin");
|
||||
play.classList.replace("fa-spinner", "fa-pause");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Format the displayed timestamp given a number of seconds as input
|
||||
* @param {number} seconds The current playback time in seconds
|
||||
* @returns {string} The formatted timestamp
|
||||
* @private
|
||||
*/
|
||||
_formatTimestamp(seconds) {
|
||||
if ( !Number.isFinite(seconds) ) return "∞";
|
||||
seconds = seconds ?? 0;
|
||||
let minutes = Math.floor(seconds / 60);
|
||||
seconds = Math.round(seconds % 60);
|
||||
return `${minutes}:${seconds.paddedString(2)}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_contextMenu(html) {
|
||||
super._contextMenu(html);
|
||||
/**
|
||||
* A hook event that fires when the context menu for a Sound in the PlaylistDirectory is constructed.
|
||||
* @function getPlaylistDirectorySoundContext
|
||||
* @memberof hookEvents
|
||||
* @param {PlaylistDirectory} application The Application instance that the context menu is constructed in
|
||||
* @param {ContextMenuEntry[]} entryOptions The context menu entries
|
||||
*/
|
||||
ContextMenu.create(this, html, ".playlist .sound", this._getSoundContextOptions(), {hookName: "SoundContext"});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getEntryContextOptions() {
|
||||
const options = super._getEntryContextOptions();
|
||||
options.unshift({
|
||||
name: "PLAYLIST.Edit",
|
||||
icon: '<i class="fas fa-edit"></i>',
|
||||
callback: header => {
|
||||
const li = header.closest(".directory-item");
|
||||
const playlist = game.playlists.get(li.data("document-id"));
|
||||
const sheet = playlist.sheet;
|
||||
sheet.render(true, this.popOut ? {} : {
|
||||
top: li[0].offsetTop - 24,
|
||||
left: window.innerWidth - ui.sidebar.position.width - sheet.options.width - 10
|
||||
});
|
||||
}
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get context menu options for individual sound effects
|
||||
* @returns {Object} The context options for each sound
|
||||
* @private
|
||||
*/
|
||||
_getSoundContextOptions() {
|
||||
return [
|
||||
{
|
||||
name: "PLAYLIST.SoundEdit",
|
||||
icon: '<i class="fas fa-edit"></i>',
|
||||
callback: li => {
|
||||
const playlistId = li.parents(".playlist").data("document-id");
|
||||
const playlist = game.playlists.get(playlistId);
|
||||
const sound = playlist.sounds.get(li.data("sound-id"));
|
||||
const sheet = sound.sheet;
|
||||
sheet.render(true, this.popOut ? {} : {
|
||||
top: li[0].offsetTop - 24,
|
||||
left: window.innerWidth - ui.sidebar.position.width - sheet.options.width - 10
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "PLAYLIST.SoundPreload",
|
||||
icon: '<i class="fas fa-download"></i>',
|
||||
callback: li => {
|
||||
const playlistId = li.parents(".playlist").data("document-id");
|
||||
const playlist = game.playlists.get(playlistId);
|
||||
const sound = playlist.sounds.get(li.data("sound-id"));
|
||||
game.audio.preload(sound.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "PLAYLIST.SoundDelete",
|
||||
icon: '<i class="fas fa-trash"></i>',
|
||||
callback: li => {
|
||||
const playlistId = li.parents(".playlist").data("document-id");
|
||||
const playlist = game.playlists.get(playlistId);
|
||||
const sound = playlist.sounds.get(li.data("sound-id"));
|
||||
return sound.deleteDialog({
|
||||
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
|
||||
left: window.innerWidth - 720
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragStart(event) {
|
||||
const target = event.currentTarget;
|
||||
if ( target.classList.contains("sound-name") ) {
|
||||
const sound = target.closest(".sound");
|
||||
const document = game.playlists.get(sound.dataset.playlistId)?.sounds.get(sound.dataset.soundId);
|
||||
event.dataTransfer.setData("text/plain", JSON.stringify(document.toDragData()));
|
||||
}
|
||||
else super._onDragStart(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onDrop(event) {
|
||||
const data = TextEditor.getDragEventData(event);
|
||||
if ( data.type !== "PlaylistSound" ) return super._onDrop(event);
|
||||
|
||||
// Reference the target playlist and sound elements
|
||||
const target = event.target.closest(".sound, .playlist");
|
||||
if ( !target ) return false;
|
||||
const sound = await PlaylistSound.implementation.fromDropData(data);
|
||||
const playlist = sound.parent;
|
||||
const otherPlaylistId = target.dataset.documentId || target.dataset.playlistId;
|
||||
|
||||
// Copying to another playlist.
|
||||
if ( otherPlaylistId !== playlist.id ) {
|
||||
const otherPlaylist = game.playlists.get(otherPlaylistId);
|
||||
return PlaylistSound.implementation.create(sound.toObject(), {parent: otherPlaylist});
|
||||
}
|
||||
|
||||
// If there's nothing to sort relative to, or the sound was dropped on itself, do nothing.
|
||||
const targetId = target.dataset.soundId;
|
||||
if ( !targetId || (targetId === sound.id) ) return false;
|
||||
sound.sortRelative({
|
||||
target: playlist.sounds.get(targetId),
|
||||
siblings: playlist.sounds.filter(s => s.id !== sound.id)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The sidebar directory which organizes and displays world-level RollTable documents.
|
||||
* @extends {DocumentDirectory}
|
||||
*/
|
||||
class RollTableDirectory extends DocumentDirectory {
|
||||
|
||||
/** @override */
|
||||
static documentName = "RollTable";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getEntryContextOptions() {
|
||||
let options = super._getEntryContextOptions();
|
||||
|
||||
// Add the "Roll" option
|
||||
options = [
|
||||
{
|
||||
name: "TABLE.Roll",
|
||||
icon: '<i class="fas fa-dice-d20"></i>',
|
||||
callback: li => {
|
||||
const table = game.tables.get(li.data("documentId"));
|
||||
table.draw({roll: true, displayChat: true});
|
||||
}
|
||||
}
|
||||
].concat(options);
|
||||
return options;
|
||||
}
|
||||
}
|
||||
122
resources/app/client/apps/sidebar/tabs/scenes-directory.js
Normal file
122
resources/app/client/apps/sidebar/tabs/scenes-directory.js
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The sidebar directory which organizes and displays world-level Scene documents.
|
||||
* @extends {DocumentDirectory}
|
||||
*/
|
||||
class SceneDirectory extends DocumentDirectory {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Scene";
|
||||
|
||||
/** @override */
|
||||
static entryPartial = "templates/sidebar/scene-partial.html";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.renderUpdateKeys.push("background");
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, options) {
|
||||
if ( !game.user.isGM ) return;
|
||||
return super._render(force, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getEntryContextOptions() {
|
||||
let options = super._getEntryContextOptions();
|
||||
options = [
|
||||
{
|
||||
name: "SCENES.View",
|
||||
icon: '<i class="fas fa-eye"></i>',
|
||||
condition: li => !canvas.ready || (li.data("documentId") !== canvas.scene.id),
|
||||
callback: li => {
|
||||
const scene = game.scenes.get(li.data("documentId"));
|
||||
scene.view();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SCENES.Activate",
|
||||
icon: '<i class="fas fa-bullseye"></i>',
|
||||
condition: li => game.user.isGM && !game.scenes.get(li.data("documentId")).active,
|
||||
callback: li => {
|
||||
const scene = game.scenes.get(li.data("documentId"));
|
||||
scene.activate();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SCENES.Configure",
|
||||
icon: '<i class="fas fa-cogs"></i>',
|
||||
callback: li => {
|
||||
const scene = game.scenes.get(li.data("documentId"));
|
||||
scene.sheet.render(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SCENES.Notes",
|
||||
icon: '<i class="fas fa-scroll"></i>',
|
||||
condition: li => {
|
||||
const scene = game.scenes.get(li.data("documentId"));
|
||||
return !!scene.journal;
|
||||
},
|
||||
callback: li => {
|
||||
const scene = game.scenes.get(li.data("documentId"));
|
||||
const entry = scene.journal;
|
||||
if ( entry ) {
|
||||
const sheet = entry.sheet;
|
||||
const options = {};
|
||||
if ( scene.journalEntryPage ) options.pageId = scene.journalEntryPage;
|
||||
sheet.render(true, options);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SCENES.ToggleNav",
|
||||
icon: '<i class="fas fa-compass"></i>',
|
||||
condition: li => {
|
||||
const scene = game.scenes.get(li.data("documentId"));
|
||||
return game.user.isGM && ( !scene.active );
|
||||
},
|
||||
callback: li => {
|
||||
const scene = game.scenes.get(li.data("documentId"));
|
||||
scene.update({navigation: !scene.navigation});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "SCENES.GenerateThumb",
|
||||
icon: '<i class="fas fa-image"></i>',
|
||||
condition: li => {
|
||||
const scene = game.scenes.get(li[0].dataset.documentId);
|
||||
return (scene.background.src || scene.tiles.size) && !game.settings.get("core", "noCanvas");
|
||||
},
|
||||
callback: li => {
|
||||
const scene = game.scenes.get(li[0].dataset.documentId);
|
||||
scene.createThumbnail().then(data => {
|
||||
scene.update({thumb: data.thumb}, {diff: false});
|
||||
ui.notifications.info(game.i18n.format("SCENES.GenerateThumbSuccess", {name: scene.name}));
|
||||
}).catch(err => ui.notifications.error(err.message));
|
||||
}
|
||||
}
|
||||
].concat(options);
|
||||
|
||||
// Remove the ownership entry
|
||||
options.findSplice(o => o.name === "OWNERSHIP.Configure");
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getFolderContextOptions() {
|
||||
const options = super._getFolderContextOptions();
|
||||
options.findSplice(o => o.name === "OWNERSHIP.Configure");
|
||||
return options;
|
||||
}
|
||||
}
|
||||
185
resources/app/client/apps/sidebar/tabs/settings.js
Normal file
185
resources/app/client/apps/sidebar/tabs/settings.js
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* The sidebar tab which displays various game settings, help messages, and configuration options.
|
||||
* The Settings sidebar is the furthest-to-right using a triple-cogs icon.
|
||||
* @extends {SidebarTab}
|
||||
*/
|
||||
class Settings extends SidebarTab {
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "settings",
|
||||
template: "templates/sidebar/settings.html",
|
||||
title: "Settings"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
const context = await super.getData(options);
|
||||
|
||||
// Check for core update
|
||||
let coreUpdate;
|
||||
if ( game.user.isGM && game.data.coreUpdate.hasUpdate ) {
|
||||
coreUpdate = game.i18n.format("SETUP.UpdateAvailable", {
|
||||
type: game.i18n.localize("Software"),
|
||||
channel: game.data.coreUpdate.channel,
|
||||
version: game.data.coreUpdate.version
|
||||
});
|
||||
}
|
||||
|
||||
// Check for system update
|
||||
let systemUpdate;
|
||||
if ( game.user.isGM && game.data.systemUpdate.hasUpdate ) {
|
||||
systemUpdate = game.i18n.format("SETUP.UpdateAvailable", {
|
||||
type: game.i18n.localize("System"),
|
||||
channel: game.data.system.title,
|
||||
version: game.data.systemUpdate.version
|
||||
});
|
||||
}
|
||||
|
||||
const issues = CONST.WORLD_DOCUMENT_TYPES.reduce((count, documentName) => {
|
||||
const collection = CONFIG[documentName].collection.instance;
|
||||
return count + collection.invalidDocumentIds.size;
|
||||
}, 0) + Object.values(game.issues.packageCompatibilityIssues).reduce((count, {error}) => {
|
||||
return count + error.length;
|
||||
}, 0) + Object.keys(game.issues.usabilityIssues).length;
|
||||
|
||||
// Return rendering context
|
||||
const isDemo = game.data.demoMode;
|
||||
return foundry.utils.mergeObject(context, {
|
||||
system: game.system,
|
||||
release: game.data.release,
|
||||
versionDisplay: game.release.display,
|
||||
canConfigure: game.user.can("SETTINGS_MODIFY") && !isDemo,
|
||||
canEditWorld: game.user.hasRole("GAMEMASTER") && !isDemo,
|
||||
canManagePlayers: game.user.isGM && !isDemo,
|
||||
canReturnSetup: game.user.hasRole("GAMEMASTER") && !isDemo,
|
||||
modules: game.modules.reduce((n, m) => n + (m.active ? 1 : 0), 0),
|
||||
issues,
|
||||
isDemo,
|
||||
coreUpdate,
|
||||
systemUpdate
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
html.find("button[data-action]").click(this._onSettingsButton.bind(this));
|
||||
html.find(".notification-pip.update").click(this._onUpdateNotificationClick.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Delegate different actions for different settings buttons
|
||||
* @param {MouseEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onSettingsButton(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget;
|
||||
switch (button.dataset.action) {
|
||||
case "configure":
|
||||
game.settings.sheet.render(true);
|
||||
break;
|
||||
case "modules":
|
||||
new ModuleManagement().render(true);
|
||||
break;
|
||||
case "world":
|
||||
new WorldConfig(game.world).render(true);
|
||||
break;
|
||||
case "players":
|
||||
return ui.menu.items.players.onClick();
|
||||
case "setup":
|
||||
return game.shutDown();
|
||||
case "support":
|
||||
new SupportDetails().render(true);
|
||||
break;
|
||||
case "controls":
|
||||
new KeybindingsConfig().render(true);
|
||||
break;
|
||||
case "tours":
|
||||
new ToursManagement().render(true);
|
||||
break;
|
||||
case "docs":
|
||||
new FrameViewer("https://foundryvtt.com/kb", {
|
||||
title: "SIDEBAR.Documentation"
|
||||
}).render(true);
|
||||
break;
|
||||
case "wiki":
|
||||
new FrameViewer("https://foundryvtt.wiki/", {
|
||||
title: "SIDEBAR.Wiki"
|
||||
}).render(true);
|
||||
break;
|
||||
case "invitations":
|
||||
new InvitationLinks().render(true);
|
||||
break;
|
||||
case "logout":
|
||||
return ui.menu.items.logout.onClick();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Executes with the update notification pip is clicked
|
||||
* @param {MouseEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onUpdateNotificationClick(event) {
|
||||
event.preventDefault();
|
||||
const key = event.target.dataset.action === "core-update" ? "CoreUpdateInstructions" : "SystemUpdateInstructions";
|
||||
ui.notifications.notify(game.i18n.localize(`SETUP.${key}`));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A simple window application which shows the built documentation pages within an iframe
|
||||
* @type {Application}
|
||||
*/
|
||||
class FrameViewer extends Application {
|
||||
constructor(url, options) {
|
||||
super(options);
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
const h = window.innerHeight * 0.9;
|
||||
const w = Math.min(window.innerWidth * 0.9, 1200);
|
||||
options.height = h;
|
||||
options.width = w;
|
||||
options.top = (window.innerHeight - h) / 2;
|
||||
options.left = (window.innerWidth - w) / 2;
|
||||
options.id = "documentation";
|
||||
options.template = "templates/apps/documentation.html";
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
return {
|
||||
src: this.url
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async close(options) {
|
||||
this.element.find("#docs").remove();
|
||||
return super.close(options);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user