Initial
This commit is contained in:
317
resources/app/client/apps/forms/actor.js
Normal file
317
resources/app/client/apps/forms/actor.js
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single Actor document.
|
||||
* This Application is responsible for rendering an actor's attributes and allowing the actor to be edited.
|
||||
* @extends {DocumentSheet}
|
||||
* @category - Applications
|
||||
* @param {Actor} actor The Actor instance being displayed within the sheet.
|
||||
* @param {DocumentSheetOptions} [options] Additional application configuration options.
|
||||
*/
|
||||
class ActorSheet extends DocumentSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
height: 720,
|
||||
width: 800,
|
||||
template: "templates/sheets/actor-sheet.html",
|
||||
closeOnSubmit: false,
|
||||
submitOnClose: true,
|
||||
submitOnChange: true,
|
||||
resizable: true,
|
||||
baseApplication: "ActorSheet",
|
||||
dragDrop: [{dragSelector: ".item-list .item", dropSelector: null}],
|
||||
secrets: [{parentSelector: ".editor"}],
|
||||
token: null
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get title() {
|
||||
if ( !this.actor.isToken ) return this.actor.name;
|
||||
return `[${game.i18n.localize(TokenDocument.metadata.label)}] ${this.actor.name}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience reference to the Actor document
|
||||
* @type {Actor}
|
||||
*/
|
||||
get actor() {
|
||||
return this.object;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* If this Actor Sheet represents a synthetic Token actor, reference the active Token
|
||||
* @type {Token|null}
|
||||
*/
|
||||
get token() {
|
||||
return this.object.token || this.options.token || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async close(options) {
|
||||
this.options.token = null;
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
const context = super.getData(options);
|
||||
context.actor = this.object;
|
||||
context.items = context.data.items;
|
||||
context.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
||||
context.effects = context.data.effects;
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getHeaderButtons() {
|
||||
let buttons = super._getHeaderButtons();
|
||||
const canConfigure = game.user.isGM || (this.actor.isOwner && game.user.can("TOKEN_CONFIGURE"));
|
||||
if ( this.options.editable && canConfigure ) {
|
||||
const closeIndex = buttons.findIndex(btn => btn.label === "Close");
|
||||
buttons.splice(closeIndex, 0, {
|
||||
label: this.token ? "Token" : "TOKEN.TitlePrototype",
|
||||
class: "configure-token",
|
||||
icon: "fas fa-user-circle",
|
||||
onclick: ev => this._onConfigureToken(ev)
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getSubmitData(updateData = {}) {
|
||||
const data = super._getSubmitData(updateData);
|
||||
// Prevent submitting overridden values
|
||||
const overrides = foundry.utils.flattenObject(this.actor.overrides);
|
||||
for ( let k of Object.keys(overrides) ) delete data[k];
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle requests to configure the Token for the Actor
|
||||
* @param {PointerEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onConfigureToken(event) {
|
||||
event.preventDefault();
|
||||
const renderOptions = {
|
||||
left: Math.max(this.position.left - 560 - 10, 10),
|
||||
top: this.position.top
|
||||
};
|
||||
if ( this.token ) return this.token.sheet.render(true, renderOptions);
|
||||
else new CONFIG.Token.prototypeSheetClass(this.actor.prototypeToken, renderOptions).render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Drag and Drop */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_canDragStart(selector) {
|
||||
return this.isEditable;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_canDragDrop(selector) {
|
||||
return this.isEditable;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragStart(event) {
|
||||
const li = event.currentTarget;
|
||||
if ( "link" in event.target.dataset ) return;
|
||||
|
||||
// Create drag data
|
||||
let dragData;
|
||||
|
||||
// Owned Items
|
||||
if ( li.dataset.itemId ) {
|
||||
const item = this.actor.items.get(li.dataset.itemId);
|
||||
dragData = item.toDragData();
|
||||
}
|
||||
|
||||
// Active Effect
|
||||
if ( li.dataset.effectId ) {
|
||||
const effect = this.actor.effects.get(li.dataset.effectId);
|
||||
dragData = effect.toDragData();
|
||||
}
|
||||
|
||||
if ( !dragData ) return;
|
||||
|
||||
// Set data transfer
|
||||
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onDrop(event) {
|
||||
const data = TextEditor.getDragEventData(event);
|
||||
const actor = this.actor;
|
||||
const allowed = Hooks.call("dropActorSheetData", actor, this, data);
|
||||
if ( allowed === false ) return;
|
||||
|
||||
// Handle different data types
|
||||
switch ( data.type ) {
|
||||
case "ActiveEffect":
|
||||
return this._onDropActiveEffect(event, data);
|
||||
case "Actor":
|
||||
return this._onDropActor(event, data);
|
||||
case "Item":
|
||||
return this._onDropItem(event, data);
|
||||
case "Folder":
|
||||
return this._onDropFolder(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the dropping of ActiveEffect data onto an Actor Sheet
|
||||
* @param {DragEvent} event The concluding DragEvent which contains drop data
|
||||
* @param {object} data The data transfer extracted from the event
|
||||
* @returns {Promise<ActiveEffect|boolean>} The created ActiveEffect object or false if it couldn't be created.
|
||||
* @protected
|
||||
*/
|
||||
async _onDropActiveEffect(event, data) {
|
||||
const effect = await ActiveEffect.implementation.fromDropData(data);
|
||||
if ( !this.actor.isOwner || !effect ) return false;
|
||||
if ( effect.target === this.actor ) return false;
|
||||
return ActiveEffect.create(effect.toObject(), {parent: this.actor});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle dropping of an Actor data onto another Actor sheet
|
||||
* @param {DragEvent} event The concluding DragEvent which contains drop data
|
||||
* @param {object} data The data transfer extracted from the event
|
||||
* @returns {Promise<object|boolean>} A data object which describes the result of the drop, or false if the drop was
|
||||
* not permitted.
|
||||
* @protected
|
||||
*/
|
||||
async _onDropActor(event, data) {
|
||||
if ( !this.actor.isOwner ) return false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle dropping of an item reference or item data onto an Actor Sheet
|
||||
* @param {DragEvent} event The concluding DragEvent which contains drop data
|
||||
* @param {object} data The data transfer extracted from the event
|
||||
* @returns {Promise<Item[]|boolean>} The created or updated Item instances, or false if the drop was not permitted.
|
||||
* @protected
|
||||
*/
|
||||
async _onDropItem(event, data) {
|
||||
if ( !this.actor.isOwner ) return false;
|
||||
const item = await Item.implementation.fromDropData(data);
|
||||
const itemData = item.toObject();
|
||||
|
||||
// Handle item sorting within the same Actor
|
||||
if ( this.actor.uuid === item.parent?.uuid ) return this._onSortItem(event, itemData);
|
||||
|
||||
// Create the owned item
|
||||
return this._onDropItemCreate(itemData, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle dropping of a Folder on an Actor Sheet.
|
||||
* The core sheet currently supports dropping a Folder of Items to create all items as owned items.
|
||||
* @param {DragEvent} event The concluding DragEvent which contains drop data
|
||||
* @param {object} data The data transfer extracted from the event
|
||||
* @returns {Promise<Item[]>}
|
||||
* @protected
|
||||
*/
|
||||
async _onDropFolder(event, data) {
|
||||
if ( !this.actor.isOwner ) return [];
|
||||
const folder = await Folder.implementation.fromDropData(data);
|
||||
if ( folder.type !== "Item" ) return [];
|
||||
const droppedItemData = await Promise.all(folder.contents.map(async item => {
|
||||
if ( !(document instanceof Item) ) item = await fromUuid(item.uuid);
|
||||
return item.toObject();
|
||||
}));
|
||||
return this._onDropItemCreate(droppedItemData, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the final creation of dropped Item data on the Actor.
|
||||
* This method is factored out to allow downstream classes the opportunity to override item creation behavior.
|
||||
* @param {object[]|object} itemData The item data requested for creation
|
||||
* @param {DragEvent} event The concluding DragEvent which provided the drop data
|
||||
* @returns {Promise<Item[]>}
|
||||
* @private
|
||||
*/
|
||||
async _onDropItemCreate(itemData, event) {
|
||||
itemData = itemData instanceof Array ? itemData : [itemData];
|
||||
return this.actor.createEmbeddedDocuments("Item", itemData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a drop event for an existing embedded Item to sort that Item relative to its siblings
|
||||
* @param {Event} event
|
||||
* @param {Object} itemData
|
||||
* @private
|
||||
*/
|
||||
_onSortItem(event, itemData) {
|
||||
|
||||
// Get the drag source and drop target
|
||||
const items = this.actor.items;
|
||||
const source = items.get(itemData._id);
|
||||
const dropTarget = event.target.closest("[data-item-id]");
|
||||
if ( !dropTarget ) return;
|
||||
const target = items.get(dropTarget.dataset.itemId);
|
||||
|
||||
// Don't sort on yourself
|
||||
if ( source.id === target.id ) return;
|
||||
|
||||
// Identify sibling items based on adjacent HTML elements
|
||||
const siblings = [];
|
||||
for ( let el of dropTarget.parentElement.children ) {
|
||||
const siblingId = el.dataset.itemId;
|
||||
if ( siblingId && (siblingId !== source.id) ) siblings.push(items.get(el.dataset.itemId));
|
||||
}
|
||||
|
||||
// Perform the sort
|
||||
const sortUpdates = SortingHelpers.performIntegerSort(source, {target, siblings});
|
||||
const updateData = sortUpdates.map(u => {
|
||||
const update = u.update;
|
||||
update._id = u.target._id;
|
||||
return update;
|
||||
});
|
||||
|
||||
// Perform the update
|
||||
return this.actor.updateEmbeddedDocuments("Item", updateData);
|
||||
}
|
||||
}
|
||||
524
resources/app/client/apps/forms/adventure-exporter.js
Normal file
524
resources/app/client/apps/forms/adventure-exporter.js
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* An interface for packaging Adventure content and loading it to a compendium pack.
|
||||
* // TODO - add a warning if you are building the adventure with any missing content
|
||||
* // TODO - add a warning if you are building an adventure that sources content from a different package' compendium
|
||||
*/
|
||||
class AdventureExporter extends DocumentSheet {
|
||||
constructor(document, options={}) {
|
||||
super(document, options);
|
||||
if ( !document.pack ) {
|
||||
throw new Error("You may not export an Adventure that does not belong to a Compendium pack");
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
template: "templates/adventure/exporter.html",
|
||||
id: "adventure-exporter",
|
||||
classes: ["sheet", "adventure", "adventure-exporter"],
|
||||
width: 560,
|
||||
height: "auto",
|
||||
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "summary"}],
|
||||
dragDrop: [{ dropSelector: "form" }],
|
||||
scrollY: [".tab.contents"],
|
||||
submitOnClose: false,
|
||||
closeOnSubmit: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An alias for the Adventure document
|
||||
* @type {Adventure}
|
||||
*/
|
||||
adventure = this.object;
|
||||
|
||||
/**
|
||||
* @typedef {Object} AdventureContentTreeNode
|
||||
* @property {string} id An alias for folder.id
|
||||
* @property {string} name An alias for folder.name
|
||||
* @property {Folder} folder The Folder at this node level
|
||||
* @property {string} state The modification state of the Folder
|
||||
* @property {AdventureContentTreeNode[]} children An array of child nodes
|
||||
* @property {{id: string, name: string, document: ClientDocument, state: string}[]} documents An array of documents
|
||||
*/
|
||||
/**
|
||||
* @typedef {AdventureContentTreeNode} AdventureContentTreeRoot
|
||||
* @property {null} id The folder ID is null at the root level
|
||||
* @property {string} documentName The Document name contained in this tree
|
||||
* @property {string} collection The Document collection name of this tree
|
||||
* @property {string} name The name displayed at the root level of the tree
|
||||
* @property {string} icon The icon displayed at the root level of the tree
|
||||
* @property {string} collapseIcon The icon which represents the current collapsed state of the tree
|
||||
* @property {string} cssClass CSS classes which describe the display of the tree
|
||||
* @property {number} documentCount The number of documents which are present in the tree
|
||||
*/
|
||||
/**
|
||||
* The prepared document tree which is displayed in the form.
|
||||
* @type {Record<string, AdventureContentTreeRoot>}
|
||||
*/
|
||||
contentTree = {};
|
||||
|
||||
/**
|
||||
* A mapping which allows convenient access to content tree nodes by their folder ID
|
||||
* @type {Record<string, AdventureContentTreeNode>}
|
||||
*/
|
||||
#treeNodes = {};
|
||||
|
||||
/**
|
||||
* Track data for content which has been added to the adventure.
|
||||
* @type {Record<string, Set<ClientDocument>>}
|
||||
*/
|
||||
#addedContent = Object.keys(Adventure.contentFields).reduce((obj, f) => {
|
||||
obj[f] = new Set();
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Track the IDs of content which has been removed from the adventure.
|
||||
* @type {Record<string, Set<string>>}
|
||||
*/
|
||||
#removedContent = Object.keys(Adventure.contentFields).reduce((obj, f) => {
|
||||
obj[f] = new Set();
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Track which sections of the contents are collapsed.
|
||||
* @type {Set<string>}
|
||||
* @private
|
||||
*/
|
||||
#collapsedSections = new Set();
|
||||
|
||||
/** @override */
|
||||
get isEditable() {
|
||||
return game.user.isGM;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Application Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
this.contentTree = this.#organizeContentTree();
|
||||
return {
|
||||
adventure: this.adventure,
|
||||
contentTree: this.contentTree
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async activateEditor(name, options={}, initialContent="") {
|
||||
options.plugins = {
|
||||
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema),
|
||||
keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema)
|
||||
};
|
||||
return super.activateEditor(name, options, initialContent);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getHeaderButtons() {
|
||||
return super._getHeaderButtons().filter(btn => btn.label !== "Import");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Organize content in the adventure into a tree structure which is displayed in the UI.
|
||||
* @returns {Record<string, AdventureContentTreeRoot>}
|
||||
*/
|
||||
#organizeContentTree() {
|
||||
const content = {};
|
||||
let remainingFolders = Array.from(this.adventure.folders).concat(Array.from(this.#addedContent.folders || []));
|
||||
|
||||
// Prepare each content section
|
||||
for ( const [name, cls] of Object.entries(Adventure.contentFields) ) {
|
||||
if ( name === "folders" ) continue;
|
||||
|
||||
// Partition content for the section
|
||||
let documents = Array.from(this.adventure[name]).concat(Array.from(this.#addedContent[name] || []));
|
||||
let folders;
|
||||
[remainingFolders, folders] = remainingFolders.partition(f => f.type === cls.documentName);
|
||||
if ( !(documents.length || folders.length) ) continue;
|
||||
|
||||
// Prepare the root node
|
||||
const collapsed = this.#collapsedSections.has(cls.documentName);
|
||||
const section = content[name] = {
|
||||
documentName: cls.documentName,
|
||||
collection: cls.collectionName,
|
||||
id: null,
|
||||
name: game.i18n.localize(cls.metadata.labelPlural),
|
||||
icon: CONFIG[cls.documentName].sidebarIcon,
|
||||
collapseIcon: collapsed ? "fa-solid fa-angle-up" : "fa-solid fa-angle-down",
|
||||
cssClass: [cls.collectionName, collapsed ? "collapsed" : ""].filterJoin(" "),
|
||||
documentCount: documents.length - this.#removedContent[name].size,
|
||||
folder: null,
|
||||
state: "root",
|
||||
children: [],
|
||||
documents: []
|
||||
};
|
||||
|
||||
// Recursively populate the tree
|
||||
[folders, documents] = this.#populateNode(section, folders, documents);
|
||||
|
||||
// Add leftover documents to the section root
|
||||
for ( const d of documents ) {
|
||||
const state = this.#getDocumentState(d);
|
||||
section.documents.push({document: d, id: d.id, name: d.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`});
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Populate one node of the content tree with folders and documents
|
||||
* @param {AdventureContentTreeNode }node The node being populated
|
||||
* @param {Folder[]} remainingFolders Folders which have yet to be populated to a node
|
||||
* @param {ClientDocument[]} remainingDocuments Documents which have yet to be populated to a node
|
||||
* @returns {Array<Folder[], ClientDocument[]>} Folders and Documents which still have yet to be populated
|
||||
*/
|
||||
#populateNode(node, remainingFolders, remainingDocuments) {
|
||||
|
||||
// Allocate Documents to this node
|
||||
let documents;
|
||||
[remainingDocuments, documents] = remainingDocuments.partition(d => d._source.folder === node.id );
|
||||
for ( const d of documents ) {
|
||||
const state = this.#getDocumentState(d);
|
||||
node.documents.push({document: d, id: d.id, name: d.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`});
|
||||
}
|
||||
|
||||
// Allocate Folders to this node
|
||||
let folders;
|
||||
[remainingFolders, folders] = remainingFolders.partition(f => f._source.folder === node.id);
|
||||
for ( const folder of folders ) {
|
||||
const state = this.#getDocumentState(folder);
|
||||
const child = {folder, id: folder.id, name: folder.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`,
|
||||
children: [], documents: []};
|
||||
[remainingFolders, remainingDocuments] = this.#populateNode(child, remainingFolders, remainingDocuments);
|
||||
node.children.push(child);
|
||||
this.#treeNodes[folder.id] = child;
|
||||
}
|
||||
return [remainingFolders, remainingDocuments];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Flag the current state of each document which is displayed
|
||||
* @param {ClientDocument} document The document being modified
|
||||
* @returns {string} The document state
|
||||
*/
|
||||
#getDocumentState(document) {
|
||||
const cn = document.collectionName;
|
||||
if ( this.#removedContent[cn].has(document.id) ) return "remove";
|
||||
if ( this.#addedContent[cn].has(document) ) return "add";
|
||||
const worldCollection = game.collections.get(document.documentName);
|
||||
if ( !worldCollection.has(document.id) ) return "missing";
|
||||
return "update";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async close(options = {}) {
|
||||
this.adventure.reset(); // Reset any pending changes
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.on("click", "a.control", this.#onClickControl.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, adventureData) {
|
||||
|
||||
// Build the adventure data content
|
||||
for ( const [name, cls] of Object.entries(Adventure.contentFields) ) {
|
||||
const collection = game.collections.get(cls.documentName);
|
||||
adventureData[name] = [];
|
||||
const addDoc = id => {
|
||||
if ( this.#removedContent[name].has(id) ) return;
|
||||
const doc = collection.get(id);
|
||||
if ( !doc ) return;
|
||||
adventureData[name].push(doc.toObject());
|
||||
};
|
||||
for ( const d of this.adventure[name] ) addDoc(d.id);
|
||||
for ( const d of this.#addedContent[name] ) addDoc(d.id);
|
||||
}
|
||||
|
||||
const pack = game.packs.get(this.adventure.pack);
|
||||
const restrictedDocuments = adventureData.actors?.length || adventureData.items?.length
|
||||
|| adventureData.folders?.some(f => CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(f.type));
|
||||
if ( restrictedDocuments && !pack?.metadata.system ) {
|
||||
return ui.notifications.error("ADVENTURE.ExportPackNoSystem", {localize: true, permanent: true});
|
||||
}
|
||||
|
||||
// Create or update the document
|
||||
if ( this.adventure.id ) {
|
||||
const updated = await this.adventure.update(adventureData, {diff: false, recursive: false});
|
||||
pack.indexDocument(updated);
|
||||
ui.notifications.info(game.i18n.format("ADVENTURE.UpdateSuccess", {name: this.adventure.name}));
|
||||
} else {
|
||||
await this.adventure.constructor.createDocuments([adventureData], {
|
||||
pack: this.adventure.pack,
|
||||
keepId: true,
|
||||
keepEmbeddedIds: true
|
||||
});
|
||||
ui.notifications.info(game.i18n.format("ADVENTURE.CreateSuccess", {name: this.adventure.name}));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Save editing progress so that re-renders of the form do not wipe out un-saved changes.
|
||||
*/
|
||||
#saveProgress() {
|
||||
const formData = this._getSubmitData();
|
||||
this.adventure.updateSource(formData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle pointer events on a control button
|
||||
* @param {PointerEvent} event The originating pointer event
|
||||
*/
|
||||
#onClickControl(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget;
|
||||
switch ( button.dataset.action ) {
|
||||
case "clear":
|
||||
return this.#onClearSection(button);
|
||||
case "collapse":
|
||||
return this.#onCollapseSection(button);
|
||||
case "remove":
|
||||
return this.#onRemoveContent(button);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clear all content from a particular document-type section.
|
||||
* @param {HTMLAnchorElement} button The clicked control button
|
||||
*/
|
||||
#onClearSection(button) {
|
||||
const section = button.closest(".document-type");
|
||||
const documentType = section.dataset.documentType;
|
||||
const cls = getDocumentClass(documentType);
|
||||
this.#removeNode(this.contentTree[cls.collectionName]);
|
||||
this.#saveProgress();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle the collapsed or expanded state of a document-type section
|
||||
* @param {HTMLAnchorElement} button The clicked control button
|
||||
*/
|
||||
#onCollapseSection(button) {
|
||||
const section = button.closest(".document-type");
|
||||
const icon = button.firstElementChild;
|
||||
const documentType = section.dataset.documentType;
|
||||
const isCollapsed = this.#collapsedSections.has(documentType);
|
||||
if ( isCollapsed ) {
|
||||
this.#collapsedSections.delete(documentType);
|
||||
section.classList.remove("collapsed");
|
||||
icon.classList.replace("fa-angle-up", "fa-angle-down");
|
||||
} else {
|
||||
this.#collapsedSections.add(documentType);
|
||||
section.classList.add("collapsed");
|
||||
icon.classList.replace("fa-angle-down", "fa-angle-up");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a single piece of content.
|
||||
* @param {HTMLAnchorElement} button The clicked control button
|
||||
*/
|
||||
#onRemoveContent(button) {
|
||||
const h4 = button.closest("h4");
|
||||
const isFolder = h4.classList.contains("folder");
|
||||
const documentName = isFolder ? "Folder" : button.closest(".document-type").dataset.documentType;
|
||||
const document = this.#getDocument(documentName, h4.dataset.documentId);
|
||||
if ( document ) {
|
||||
this.removeContent(document);
|
||||
this.#saveProgress();
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Document instance from the clicked content tag.
|
||||
* @param {string} documentName The document type
|
||||
* @param {string} documentId The document ID
|
||||
* @returns {ClientDocument|null} The Document instance, or null
|
||||
*/
|
||||
#getDocument(documentName, documentId) {
|
||||
const cls = getDocumentClass(documentName);
|
||||
const cn = cls.collectionName;
|
||||
const existing = this.adventure[cn].find(d => d.id === documentId);
|
||||
if ( existing ) return existing;
|
||||
const added = this.#addedContent[cn].find(d => d.id === documentId);
|
||||
return added || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Content Drop Handling */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onDrop(event) {
|
||||
const data = TextEditor.getDragEventData(event);
|
||||
const cls = getDocumentClass(data?.type);
|
||||
if ( !cls || !(cls.collectionName in Adventure.contentFields) ) return;
|
||||
const document = await cls.fromDropData(data);
|
||||
if ( document.pack || document.isEmbedded ) {
|
||||
return ui.notifications.error("ADVENTURE.ExportPrimaryDocumentsOnly", {localize: true});
|
||||
}
|
||||
const pack = game.packs.get(this.adventure.pack);
|
||||
const type = data?.type === "Folder" ? document.type : data?.type;
|
||||
if ( !pack?.metadata.system && CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(type) ) {
|
||||
return ui.notifications.error("ADVENTURE.ExportPackNoSystem", {localize: true});
|
||||
}
|
||||
this.addContent(document);
|
||||
this.#saveProgress();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Content Management Workflows */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Stage a document for addition to the Adventure.
|
||||
* This adds the document locally, the change is not yet submitted to the database.
|
||||
* @param {Folder|ClientDocument} document Some document to be added to the Adventure.
|
||||
*/
|
||||
addContent(document) {
|
||||
if ( document instanceof foundry.documents.BaseFolder ) this.#addFolder(document);
|
||||
if ( document.folder ) this.#addDocument(document.folder);
|
||||
this.#addDocument(document);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a single Document from the Adventure.
|
||||
* @param {ClientDocument} document The Document being removed from the Adventure.
|
||||
*/
|
||||
removeContent(document) {
|
||||
if ( document instanceof foundry.documents.BaseFolder ) {
|
||||
const node = this.#treeNodes[document.id];
|
||||
if ( !node ) return;
|
||||
if ( this.#removedContent.folders.has(node.id) ) return this.#restoreNode(node);
|
||||
return this.#removeNode(node);
|
||||
}
|
||||
else this.#removeDocument(document);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a single document from the content tree
|
||||
* @param {AdventureContentTreeNode} node The node to remove
|
||||
*/
|
||||
#removeNode(node) {
|
||||
for ( const child of node.children ) this.#removeNode(child);
|
||||
for ( const d of node.documents ) this.#removeDocument(d.document);
|
||||
if ( node.folder ) this.#removeDocument(node.folder);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Restore a removed node back to the content tree
|
||||
* @param {AdventureContentTreeNode} node The node to restore
|
||||
*/
|
||||
#restoreNode(node) {
|
||||
for ( const child of node.children ) this.#restoreNode(child);
|
||||
for ( const d of node.documents ) this.#removedContent[d.document.collectionName].delete(d.id);
|
||||
return this.#removedContent.folders.delete(node.id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a single document from the content tree
|
||||
* @param {ClientDocument} document The document to remove
|
||||
*/
|
||||
#removeDocument(document) {
|
||||
const cn = document.collectionName;
|
||||
|
||||
// If the Document was already removed, re-add it
|
||||
if ( this.#removedContent[cn].has(document.id) ) {
|
||||
this.#removedContent[cn].delete(document.id);
|
||||
}
|
||||
|
||||
// If the content was temporarily added, remove it
|
||||
else if ( this.#addedContent[cn].has(document) ) {
|
||||
this.#addedContent[cn].delete(document);
|
||||
}
|
||||
|
||||
// Otherwise, mark the content as removed
|
||||
else this.#removedContent[cn].add(document.id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add an entire folder tree including contained documents and subfolders to the Adventure.
|
||||
* @param {Folder} folder The folder to add
|
||||
* @private
|
||||
*/
|
||||
#addFolder(folder) {
|
||||
this.#addDocument(folder);
|
||||
for ( const doc of folder.contents ) {
|
||||
this.#addDocument(doc);
|
||||
}
|
||||
for ( const sub of folder.getSubfolders() ) {
|
||||
this.#addFolder(sub);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a single document to the Adventure.
|
||||
* @param {ClientDocument} document The Document to add
|
||||
* @private
|
||||
*/
|
||||
#addDocument(document) {
|
||||
const cn = document.collectionName;
|
||||
|
||||
// If the document was previously removed, restore it
|
||||
if ( this.#removedContent[cn].has(document.id) ) {
|
||||
return this.#removedContent[cn].delete(document.id);
|
||||
}
|
||||
|
||||
// Otherwise, add documents which don't yet exist
|
||||
const existing = this.adventure[cn].find(d => d.id === document.id);
|
||||
if ( !existing ) this.#addedContent[cn].add(document);
|
||||
}
|
||||
}
|
||||
183
resources/app/client/apps/forms/adventure-importer.js
Normal file
183
resources/app/client/apps/forms/adventure-importer.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* An interface for importing an adventure from a compendium pack.
|
||||
*/
|
||||
class AdventureImporter extends DocumentSheet {
|
||||
|
||||
/**
|
||||
* An alias for the Adventure document
|
||||
* @type {Adventure}
|
||||
*/
|
||||
adventure = this.object;
|
||||
|
||||
/** @override */
|
||||
get isEditable() {
|
||||
return game.user.isGM;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
template: "templates/adventure/importer.html",
|
||||
id: "adventure-importer",
|
||||
classes: ["sheet", "adventure", "adventure-importer"],
|
||||
width: 800,
|
||||
height: "auto",
|
||||
submitOnClose: false,
|
||||
closeOnSubmit: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
return {
|
||||
adventure: this.adventure,
|
||||
contents: this._getContentList(),
|
||||
imported: !!game.settings.get("core", "adventureImports")?.[this.adventure.uuid]
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find('[value="all"]').on("change", this._onToggleImportAll.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling the import all checkbox.
|
||||
* @param {Event} event The change event.
|
||||
* @protected
|
||||
*/
|
||||
_onToggleImportAll(event) {
|
||||
const target = event.currentTarget;
|
||||
const section = target.closest(".import-controls");
|
||||
const checked = target.checked;
|
||||
section.querySelectorAll("input").forEach(input => {
|
||||
if ( input === target ) return;
|
||||
if ( input.value !== "folders" ) input.disabled = checked;
|
||||
if ( checked ) input.checked = true;
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare a list of content types provided by this adventure.
|
||||
* @returns {{icon: string, label: string, count: number}[]}
|
||||
* @protected
|
||||
*/
|
||||
_getContentList() {
|
||||
return Object.entries(Adventure.contentFields).reduce((arr, [field, cls]) => {
|
||||
const count = this.adventure[field].size;
|
||||
if ( !count ) return arr;
|
||||
arr.push({
|
||||
icon: CONFIG[cls.documentName].sidebarIcon,
|
||||
label: game.i18n.localize(count > 1 ? cls.metadata.labelPlural : cls.metadata.label),
|
||||
count, field
|
||||
});
|
||||
return arr;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getHeaderButtons() {
|
||||
const buttons = super._getHeaderButtons();
|
||||
buttons.findSplice(b => b.class === "import");
|
||||
return buttons;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
|
||||
// Backwards compatibility. If the AdventureImporter subclass defines _prepareImportData or _importContent
|
||||
/** @deprecated since v11 */
|
||||
const prepareImportDefined = foundry.utils.getDefiningClass(this, "_prepareImportData");
|
||||
const importContentDefined = foundry.utils.getDefiningClass(this, "_importContent");
|
||||
if ( (prepareImportDefined !== AdventureImporter) || (importContentDefined !== AdventureImporter) ) {
|
||||
const warning = `The ${this.name} class overrides the AdventureImporter#_prepareImportData or
|
||||
AdventureImporter#_importContent methods. As such a legacy import workflow will be used, but this workflow is
|
||||
deprecated. Your importer should now call the new Adventure#import, Adventure#prepareImport,
|
||||
or Adventure#importContent methods.`;
|
||||
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
|
||||
return this._importLegacy(formData);
|
||||
}
|
||||
|
||||
// Perform the standard Adventure import workflow
|
||||
return this.adventure.import(formData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Mirror Adventure#import but call AdventureImporter#_importContent and AdventureImport#_prepareImportData
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
async _importLegacy(formData) {
|
||||
|
||||
// Prepare the content for import
|
||||
const {toCreate, toUpdate, documentCount} = await this._prepareImportData(formData);
|
||||
|
||||
// Allow modules to preprocess adventure data or to intercept the import process
|
||||
const allowed = Hooks.call("preImportAdventure", this.adventure, formData, toCreate, toUpdate);
|
||||
if ( allowed === false ) {
|
||||
return console.log(`"${this.adventure.name}" Adventure import was prevented by the "preImportAdventure" hook`);
|
||||
}
|
||||
|
||||
// Warn the user if the import operation will overwrite existing World content
|
||||
if ( !foundry.utils.isEmpty(toUpdate) ) {
|
||||
const confirm = await Dialog.confirm({
|
||||
title: game.i18n.localize("ADVENTURE.ImportOverwriteTitle"),
|
||||
content: `<h4><strong>${game.i18n.localize("Warning")}:</strong></h4>
|
||||
<p>${game.i18n.format("ADVENTURE.ImportOverwriteWarning", {name: this.adventure.name})}</p>`
|
||||
});
|
||||
if ( !confirm ) return;
|
||||
}
|
||||
|
||||
// Perform the import
|
||||
const {created, updated} = await this._importContent(toCreate, toUpdate, documentCount);
|
||||
|
||||
// Refresh the sidebar display
|
||||
ui.sidebar.render();
|
||||
|
||||
// Allow modules to react to the import process
|
||||
Hooks.callAll("importAdventure", this.adventure, formData, created, updated);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
async _prepareImportData(formData) {
|
||||
foundry.utils.logCompatibilityWarning("AdventureImporter#_prepareImportData is deprecated. "
|
||||
+ "Please use Adventure#prepareImport instead.", {since: 11, until: 13});
|
||||
return this.adventure.prepareImport(formData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
async _importContent(toCreate, toUpdate, documentCount) {
|
||||
foundry.utils.logCompatibilityWarning("AdventureImporter#_importContent is deprecated. "
|
||||
+ "Please use Adventure#importContent instead.", {since: 11, until: 13});
|
||||
return this.adventure.importContent({ toCreate, toUpdate, documentCount });
|
||||
}
|
||||
}
|
||||
60
resources/app/client/apps/forms/base-sheet.js
Normal file
60
resources/app/client/apps/forms/base-sheet.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* The Application responsible for displaying a basic sheet for any Document sub-types that do not have a sheet
|
||||
* registered.
|
||||
* @extends {DocumentSheet}
|
||||
*/
|
||||
class BaseSheet extends DocumentSheet {
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
template: "templates/sheets/base-sheet.html",
|
||||
classes: ["sheet", "base-sheet"],
|
||||
width: 450,
|
||||
height: "auto",
|
||||
resizable: true,
|
||||
submitOnChange: true,
|
||||
closeOnSubmit: false
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
const context = await super.getData(options);
|
||||
context.hasName = "name" in this.object;
|
||||
context.hasImage = "img" in this.object;
|
||||
context.hasDescription = "description" in this.object;
|
||||
if ( context.hasDescription ) {
|
||||
context.descriptionHTML = await TextEditor.enrichHTML(this.object.description, {
|
||||
secrets: this.object.isOwner,
|
||||
relativeTo: this.object
|
||||
});
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, options) {
|
||||
await super._render(force, options);
|
||||
await this._waitForImages();
|
||||
this.setPosition();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async activateEditor(name, options={}, initialContent="") {
|
||||
options.relativeLinks = true;
|
||||
options.plugins = {
|
||||
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
|
||||
compact: true,
|
||||
destroyOnSave: false,
|
||||
onSave: () => this.saveEditor(name, {remove: false})
|
||||
})
|
||||
};
|
||||
return super.activateEditor(name, options, initialContent);
|
||||
}
|
||||
}
|
||||
73
resources/app/client/apps/forms/card-config.js
Normal file
73
resources/app/client/apps/forms/card-config.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* A DocumentSheet application responsible for displaying and editing a single embedded Card document.
|
||||
* @extends {DocumentSheet}
|
||||
* @param {Card} object The {@link Card} object being configured.
|
||||
* @param {DocumentSheetOptions} [options] Application configuration options.
|
||||
*/
|
||||
class CardConfig extends DocumentSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["sheet", "card-config"],
|
||||
template: "templates/cards/card-config.html",
|
||||
width: 480,
|
||||
height: "auto",
|
||||
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "details"}]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
return foundry.utils.mergeObject(super.getData(options), {
|
||||
data: this.document.toObject(), // Source data, not derived
|
||||
types: CONFIG.Card.typeLabels
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find(".face-control").click(this._onFaceControl.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle card face control actions which modify single cards on the sheet.
|
||||
* @param {PointerEvent} event The originating click event
|
||||
* @returns {Promise} A Promise which resolves once the handler has completed
|
||||
* @protected
|
||||
*/
|
||||
async _onFaceControl(event) {
|
||||
const button = event.currentTarget;
|
||||
const face = button.closest(".face");
|
||||
const faces = this.object.toObject().faces;
|
||||
|
||||
// Save any pending change to the form
|
||||
await this._onSubmit(event, {preventClose: true, preventRender: true});
|
||||
|
||||
// Handle the control action
|
||||
switch ( button.dataset.action ) {
|
||||
case "addFace":
|
||||
faces.push({});
|
||||
return this.object.update({faces});
|
||||
case "deleteFace":
|
||||
return Dialog.confirm({
|
||||
title: game.i18n.localize("CARD.FaceDelete"),
|
||||
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("CARD.FaceDeleteWarning")}</p>`,
|
||||
yes: () => {
|
||||
const i = Number(face.dataset.face);
|
||||
faces.splice(i, 1);
|
||||
return this.object.update({faces});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
231
resources/app/client/apps/forms/cards-config.js
Normal file
231
resources/app/client/apps/forms/cards-config.js
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* A DocumentSheet application responsible for displaying and editing a single Cards stack.
|
||||
*/
|
||||
class CardsConfig extends DocumentSheet {
|
||||
/**
|
||||
* The CardsConfig sheet is constructed by providing a Cards document and sheet-level options.
|
||||
* @param {Cards} object The {@link Cards} object being configured.
|
||||
* @param {DocumentSheetOptions} [options] Application configuration options.
|
||||
*/
|
||||
constructor(object, options) {
|
||||
super(object, options);
|
||||
this.options.classes.push(object.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* The allowed sorting methods which can be used for this sheet
|
||||
* @enum {string}
|
||||
*/
|
||||
static SORT_TYPES = {
|
||||
STANDARD: "standard",
|
||||
SHUFFLED: "shuffled"
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["sheet", "cards-config"],
|
||||
template: "templates/cards/cards-deck.html",
|
||||
width: 620,
|
||||
height: "auto",
|
||||
closeOnSubmit: false,
|
||||
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
|
||||
dragDrop: [{dragSelector: "ol.cards li.card", dropSelector: "ol.cards"}],
|
||||
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "cards"}],
|
||||
scrollY: ["ol.cards"],
|
||||
sort: this.SORT_TYPES.SHUFFLED
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
|
||||
// Sort cards
|
||||
const sortFn = {
|
||||
standard: this.object.sortStandard,
|
||||
shuffled: this.object.sortShuffled
|
||||
}[options?.sort || "standard"];
|
||||
const cards = this.object.cards.contents.sort((a, b) => sortFn.call(this.object, a, b));
|
||||
|
||||
// Return rendering context
|
||||
return foundry.utils.mergeObject(super.getData(options), {
|
||||
cards: cards,
|
||||
types: CONFIG.Cards.typeLabels,
|
||||
inCompendium: !!this.object.pack
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
// Card Actions
|
||||
html.find(".card-control").click(this._onCardControl.bind(this));
|
||||
|
||||
// Intersection Observer
|
||||
const cards = html.find("ol.cards");
|
||||
const entries = cards.find("li.card");
|
||||
const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), {root: cards[0]});
|
||||
entries.each((i, li) => observer.observe(li));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle card control actions which modify single cards on the sheet.
|
||||
* @param {PointerEvent} event The originating click event
|
||||
* @returns {Promise} A Promise which resolves once the handler has completed
|
||||
* @protected
|
||||
*/
|
||||
async _onCardControl(event) {
|
||||
const button = event.currentTarget;
|
||||
const li = button.closest(".card");
|
||||
const card = li ? this.object.cards.get(li.dataset.cardId) : null;
|
||||
const cls = getDocumentClass("Card");
|
||||
|
||||
// Save any pending change to the form
|
||||
await this._onSubmit(event, {preventClose: true, preventRender: true});
|
||||
|
||||
// Handle the control action
|
||||
switch ( button.dataset.action ) {
|
||||
case "create":
|
||||
return cls.createDialog({ faces: [{}], face: 0 }, {parent: this.object, pack: this.object.pack});
|
||||
case "edit":
|
||||
return card.sheet.render(true);
|
||||
case "delete":
|
||||
return card.deleteDialog();
|
||||
case "deal":
|
||||
return this.object.dealDialog();
|
||||
case "draw":
|
||||
return this.object.drawDialog();
|
||||
case "pass":
|
||||
return this.object.passDialog();
|
||||
case "play":
|
||||
return this.object.playDialog(card);
|
||||
case "reset":
|
||||
return this.object.resetDialog();
|
||||
case "shuffle":
|
||||
this.options.sort = this.constructor.SORT_TYPES.SHUFFLED;
|
||||
return this.object.shuffle();
|
||||
case "toggleSort":
|
||||
this.options.sort = {standard: "shuffled", shuffled: "standard"}[this.options.sort];
|
||||
return this.render();
|
||||
case "nextFace":
|
||||
return card.update({face: card.face === null ? 0 : card.face+1});
|
||||
case "prevFace":
|
||||
return card.update({face: card.face === 0 ? null : card.face-1});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle lazy-loading card face images.
|
||||
* See {@link SidebarTab#_onLazyLoadImage}
|
||||
* @param {IntersectionObserverEntry[]} entries The entries which are now in the observer frame
|
||||
* @param {IntersectionObserver} observer The intersection observer instance
|
||||
* @protected
|
||||
*/
|
||||
_onLazyLoadImage(entries, observer) {
|
||||
return ui.cards._onLazyLoadImage.call(this, entries, observer);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_canDragStart(selector) {
|
||||
return this.isEditable;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragStart(event) {
|
||||
const li = event.currentTarget;
|
||||
const card = this.object.cards.get(li.dataset.cardId);
|
||||
if ( !card ) return;
|
||||
|
||||
// Set data transfer
|
||||
event.dataTransfer.setData("text/plain", JSON.stringify(card.toDragData()));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_canDragDrop(selector) {
|
||||
return this.isEditable;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onDrop(event) {
|
||||
const data = TextEditor.getDragEventData(event);
|
||||
if ( data.type !== "Card" ) return;
|
||||
const card = await Card.implementation.fromDropData(data);
|
||||
if ( card.parent.id === this.object.id ) return this._onSortCard(event, card);
|
||||
try {
|
||||
return await card.pass(this.object);
|
||||
} catch(err) {
|
||||
Hooks.onError("CardsConfig#_onDrop", err, {log: "error", notify: "error"});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle sorting a Card relative to other siblings within this document
|
||||
* @param {Event} event The drag drop event
|
||||
* @param {Card} card The card being dragged
|
||||
* @private
|
||||
*/
|
||||
_onSortCard(event, card) {
|
||||
|
||||
// Identify a specific card as the drop target
|
||||
let target = null;
|
||||
const li = event.target.closest("[data-card-id]");
|
||||
if ( li ) target = this.object.cards.get(li.dataset.cardId) ?? null;
|
||||
|
||||
// Don't sort on yourself.
|
||||
if ( card === target ) return;
|
||||
|
||||
// Identify the set of siblings
|
||||
const siblings = this.object.cards.filter(c => c.id !== card.id);
|
||||
|
||||
// Perform an integer-based sort
|
||||
const updateData = SortingHelpers.performIntegerSort(card, {target, siblings}).map(u => {
|
||||
return {_id: u.target.id, sort: u.update.sort};
|
||||
});
|
||||
return this.object.updateEmbeddedDocuments("Card", updateData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A subclass of CardsConfig which provides a sheet representation for Cards documents with the "hand" type.
|
||||
*/
|
||||
class CardsHand extends CardsConfig {
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
template: "templates/cards/cards-hand.html"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A subclass of CardsConfig which provides a sheet representation for Cards documents with the "pile" type.
|
||||
*/
|
||||
class CardsPile extends CardsConfig {
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
template: "templates/cards/cards-pile.html"
|
||||
});
|
||||
}
|
||||
}
|
||||
82
resources/app/client/apps/forms/combat-config.js
Normal file
82
resources/app/client/apps/forms/combat-config.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The Application responsible for configuring the CombatTracker and its contents.
|
||||
* @extends {FormApplication}
|
||||
*/
|
||||
class CombatTrackerConfig extends FormApplication {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "combat-config",
|
||||
title: game.i18n.localize("COMBAT.Settings"),
|
||||
classes: ["sheet", "combat-sheet"],
|
||||
template: "templates/sheets/combat-config.html",
|
||||
width: 420
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
const attributes = TokenDocument.implementation.getTrackedAttributes();
|
||||
attributes.bar.forEach(a => a.push("value"));
|
||||
const combatThemeSetting = game.settings.settings.get("core.combatTheme");
|
||||
return {
|
||||
canConfigure: game.user.can("SETTINGS_MODIFY"),
|
||||
settings: game.settings.get("core", Combat.CONFIG_SETTING),
|
||||
attributeChoices: TokenDocument.implementation.getTrackedAttributeChoices(attributes),
|
||||
combatTheme: combatThemeSetting,
|
||||
selectedTheme: game.settings.get("core", "combatTheme"),
|
||||
user: game.user
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
game.settings.set("core", "combatTheme", formData["core.combatTheme"]);
|
||||
return game.settings.set("core", Combat.CONFIG_SETTING, {
|
||||
resource: formData.resource,
|
||||
skipDefeated: formData.skipDefeated
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find(".audio-preview").click(this.#onAudioPreview.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
#audioPreviewState = 0;
|
||||
|
||||
/**
|
||||
* Handle previewing a sound file for a Combat Tracker setting
|
||||
* @param {Event} event The initial button click event
|
||||
* @private
|
||||
*/
|
||||
#onAudioPreview(event) {
|
||||
const themeName = this.form["core.combatTheme"].value;
|
||||
const theme = CONFIG.Combat.sounds[themeName];
|
||||
if ( !theme || theme === "none" ) return;
|
||||
const announcements = CONST.COMBAT_ANNOUNCEMENTS;
|
||||
const announcement = announcements[this.#audioPreviewState++ % announcements.length];
|
||||
const sounds = theme[announcement];
|
||||
if ( !sounds ) return;
|
||||
const src = sounds[Math.floor(Math.random() * sounds.length)];
|
||||
game.audio.play(src, {context: game.audio.interface});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onChangeInput(event) {
|
||||
if ( event.currentTarget.name === "core.combatTheme" ) this.#audioPreviewState = 0;
|
||||
return super._onChangeInput(event);
|
||||
}
|
||||
}
|
||||
35
resources/app/client/apps/forms/combatant-config.js
Normal file
35
resources/app/client/apps/forms/combatant-config.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The Application responsible for configuring a single Combatant document within a parent Combat.
|
||||
* @extends {DocumentSheet}
|
||||
*/
|
||||
class CombatantConfig extends DocumentSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "combatant-config",
|
||||
title: game.i18n.localize("COMBAT.CombatantConfig"),
|
||||
classes: ["sheet", "combat-sheet"],
|
||||
template: "templates/sheets/combatant-config.html",
|
||||
width: 420
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get title() {
|
||||
return game.i18n.localize(this.object.id ? "COMBAT.CombatantUpdate" : "COMBAT.CombatantCreate");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
if ( this.object.id ) return this.object.update(formData);
|
||||
else {
|
||||
const cls = getDocumentClass("Combatant");
|
||||
return cls.create(formData, {parent: game.combat});
|
||||
}
|
||||
}
|
||||
}
|
||||
70
resources/app/client/apps/forms/default-sheets-config.js
Normal file
70
resources/app/client/apps/forms/default-sheets-config.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* An Application responsible for allowing GMs to configure the default sheets that are used for the Documents in their
|
||||
* world.
|
||||
*/
|
||||
class DefaultSheetsConfig extends PackageConfiguration {
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
title: game.i18n.localize("SETTINGS.DefaultSheetsL"),
|
||||
id: "default-sheets-config",
|
||||
categoryTemplate: "templates/sidebar/apps/default-sheets-config.html",
|
||||
submitButton: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_prepareCategoryData() {
|
||||
let total = 0;
|
||||
const categories = [];
|
||||
for ( const cls of Object.values(foundry.documents) ) {
|
||||
const documentName = cls.documentName;
|
||||
if ( !cls.hasTypeData ) continue;
|
||||
const subTypes = game.documentTypes[documentName].filter(t => t !== CONST.BASE_DOCUMENT_TYPE);
|
||||
if ( !subTypes.length ) continue;
|
||||
const title = game.i18n.localize(cls.metadata.labelPlural);
|
||||
categories.push({
|
||||
title,
|
||||
id: documentName,
|
||||
count: subTypes.length,
|
||||
subTypes: subTypes.map(t => {
|
||||
const typeLabel = CONFIG[documentName].typeLabels?.[t];
|
||||
const name = typeLabel ? game.i18n.localize(typeLabel) : t;
|
||||
const {defaultClasses, defaultClass} = DocumentSheetConfig.getSheetClassesForSubType(documentName, t);
|
||||
return {type: t, name, defaultClasses, defaultClass};
|
||||
})
|
||||
});
|
||||
total += subTypes.length;
|
||||
}
|
||||
return {categories, total};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _updateObject(event, formData) {
|
||||
const current = game.settings.get("core", "sheetClasses");
|
||||
const settings = Object.entries(formData).reduce((obj, [name, sheetId]) => {
|
||||
const [documentName, ...rest] = name.split(".");
|
||||
const subType = rest.join(".");
|
||||
const cfg = CONFIG[documentName].sheetClasses?.[subType]?.[sheetId];
|
||||
// Do not create an entry in the settings object if the class is already the default.
|
||||
if ( cfg?.default && !current[documentName]?.[subType] ) return obj;
|
||||
const entry = obj[documentName] ??= {};
|
||||
entry[subType] = sheetId;
|
||||
return obj;
|
||||
}, {});
|
||||
return game.settings.set("core", "sheetClasses", settings);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onResetDefaults(event) {
|
||||
event.preventDefault();
|
||||
await game.settings.set("core", "sheetClasses", {});
|
||||
return SettingsConfig.reloadConfirm({world: true});
|
||||
}
|
||||
}
|
||||
112
resources/app/client/apps/forms/effect-config.js
Normal file
112
resources/app/client/apps/forms/effect-config.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The Application responsible for configuring a single ActiveEffect document within a parent Actor or Item.
|
||||
* @extends {DocumentSheet}
|
||||
*
|
||||
* @param {ActiveEffect} object The target active effect being configured
|
||||
* @param {DocumentSheetOptions} [options] Additional options which modify this application instance
|
||||
*/
|
||||
class ActiveEffectConfig extends DocumentSheet {
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["sheet", "active-effect-sheet"],
|
||||
template: "templates/sheets/active-effect-config.html",
|
||||
width: 580,
|
||||
height: "auto",
|
||||
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "details"}]
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
const context = await super.getData(options);
|
||||
context.descriptionHTML = await TextEditor.enrichHTML(this.object.description, {secrets: this.object.isOwner});
|
||||
const legacyTransfer = CONFIG.ActiveEffect.legacyTransferral;
|
||||
const labels = {
|
||||
transfer: {
|
||||
name: game.i18n.localize(`EFFECT.Transfer${legacyTransfer ? "Legacy" : ""}`),
|
||||
hint: game.i18n.localize(`EFFECT.TransferHint${legacyTransfer ? "Legacy" : ""}`)
|
||||
}
|
||||
};
|
||||
|
||||
// Status Conditions
|
||||
const statuses = CONFIG.statusEffects.map(s => {
|
||||
return {
|
||||
id: s.id,
|
||||
label: game.i18n.localize(s.name ?? /** @deprecated since v12 */ s.label),
|
||||
selected: context.data.statuses.includes(s.id) ? "selected" : ""
|
||||
};
|
||||
});
|
||||
|
||||
// Return rendering context
|
||||
return foundry.utils.mergeObject(context, {
|
||||
labels,
|
||||
effect: this.object, // Backwards compatibility
|
||||
data: this.object,
|
||||
isActorEffect: this.object.parent.documentName === "Actor",
|
||||
isItemEffect: this.object.parent.documentName === "Item",
|
||||
submitText: "EFFECT.Submit",
|
||||
statuses,
|
||||
modes: Object.entries(CONST.ACTIVE_EFFECT_MODES).reduce((obj, e) => {
|
||||
obj[e[1]] = game.i18n.localize(`EFFECT.MODE_${e[0]}`);
|
||||
return obj;
|
||||
}, {})
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find(".effect-control").click(this._onEffectControl.bind(this));
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide centralized handling of mouse clicks on control buttons.
|
||||
* Delegate responsibility out to action-specific handlers depending on the button action.
|
||||
* @param {MouseEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onEffectControl(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget;
|
||||
switch ( button.dataset.action ) {
|
||||
case "add":
|
||||
return this._addEffectChange();
|
||||
case "delete":
|
||||
button.closest(".effect-change").remove();
|
||||
return this.submit({preventClose: true}).then(() => this.render());
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle adding a new change to the changes array.
|
||||
* @private
|
||||
*/
|
||||
async _addEffectChange() {
|
||||
const idx = this.document.changes.length;
|
||||
return this.submit({preventClose: true, updateData: {
|
||||
[`changes.${idx}`]: {key: "", mode: CONST.ACTIVE_EFFECT_MODES.ADD, value: ""}
|
||||
}});
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getSubmitData(updateData={}) {
|
||||
const fd = new FormDataExtended(this.form, {editors: this.editors});
|
||||
let data = foundry.utils.expandObject(fd.object);
|
||||
if ( updateData ) foundry.utils.mergeObject(data, updateData);
|
||||
data.changes = Array.from(Object.values(data.changes || {}));
|
||||
data.statuses ??= [];
|
||||
return data;
|
||||
}
|
||||
}
|
||||
70
resources/app/client/apps/forms/folder-edit.js
Normal file
70
resources/app/client/apps/forms/folder-edit.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The Application responsible for configuring a single Folder document.
|
||||
* @extends {DocumentSheet}
|
||||
* @param {Folder} object The {@link Folder} object to configure.
|
||||
* @param {DocumentSheetOptions} [options] Application configuration options.
|
||||
*/
|
||||
class FolderConfig extends DocumentSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["sheet", "folder-edit"],
|
||||
template: "templates/sidebar/folder-edit.html",
|
||||
width: 360
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get id() {
|
||||
return this.object.id ? super.id : "folder-create";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get title() {
|
||||
if ( this.object.id ) return `${game.i18n.localize("FOLDER.Update")}: ${this.object.name}`;
|
||||
return game.i18n.localize("FOLDER.Create");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async close(options={}) {
|
||||
if ( !this.options.submitOnClose ) this.options.resolve?.(null);
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
const folder = this.document.toObject();
|
||||
return {
|
||||
folder: folder,
|
||||
name: folder._id ? folder.name : "",
|
||||
newName: Folder.implementation.defaultName({pack: folder.pack}),
|
||||
safeColor: folder.color?.css ?? "#000000",
|
||||
sortingModes: {a: "FOLDER.SortAlphabetical", m: "FOLDER.SortManual"},
|
||||
submitText: game.i18n.localize(folder._id ? "FOLDER.Update" : "FOLDER.Create")
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
let doc = this.object;
|
||||
if ( !formData.name?.trim() ) formData.name = Folder.implementation.defaultName({pack: doc.pack});
|
||||
if ( this.object.id ) await this.object.update(formData);
|
||||
else {
|
||||
this.object.updateSource(formData);
|
||||
doc = await Folder.create(this.object, { pack: this.object.pack });
|
||||
}
|
||||
this.options.resolve?.(doc);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
385
resources/app/client/apps/forms/fonts.js
Normal file
385
resources/app/client/apps/forms/fonts.js
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @typedef {object} NewFontDefinition
|
||||
* @property {string} [family] The font family.
|
||||
* @property {number} [weight=400] The font weight.
|
||||
* @property {string} [style="normal"] The font style.
|
||||
* @property {string} [src=""] The font file.
|
||||
* @property {string} [preview] The text to preview the font.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A class responsible for configuring custom fonts for the world.
|
||||
* @extends {FormApplication}
|
||||
*/
|
||||
class FontConfig extends FormApplication {
|
||||
/**
|
||||
* An application for configuring custom world fonts.
|
||||
* @param {NewFontDefinition} [object] The default settings for new font definition creation.
|
||||
* @param {object} [options] Additional options to configure behaviour.
|
||||
*/
|
||||
constructor(object={}, options={}) {
|
||||
foundry.utils.mergeObject(object, {
|
||||
family: "",
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
src: "",
|
||||
preview: game.i18n.localize("FONTS.FontPreview"),
|
||||
type: FontConfig.FONT_TYPES.FILE
|
||||
});
|
||||
super(object, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether fonts have been modified since opening the application.
|
||||
* @type {boolean}
|
||||
*/
|
||||
#fontsModified = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The currently selected font.
|
||||
* @type {{family: string, index: number}|null}
|
||||
*/
|
||||
#selected = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether the given font is currently selected.
|
||||
* @param {{family: string, index: number}} selection The font selection information.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#isSelected({family, index}) {
|
||||
if ( !this.#selected ) return false;
|
||||
return (family === this.#selected.family) && (index === this.#selected.index);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
title: game.i18n.localize("SETTINGS.FontConfigL"),
|
||||
id: "font-config",
|
||||
template: "templates/sidebar/apps/font-config.html",
|
||||
popOut: true,
|
||||
width: 600,
|
||||
height: "auto",
|
||||
closeOnSubmit: false,
|
||||
submitOnChange: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether a font is distributed to connected clients or found on their OS.
|
||||
* @enum {string}
|
||||
*/
|
||||
static FONT_TYPES = {
|
||||
FILE: "file",
|
||||
SYSTEM: "system"
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
const definitions = game.settings.get("core", this.constructor.SETTING);
|
||||
const fonts = Object.entries(definitions).flatMap(([family, definition]) => {
|
||||
return this._getDataForDefinition(family, definition);
|
||||
});
|
||||
let selected;
|
||||
if ( (this.#selected === null) && fonts.length ) {
|
||||
fonts[0].selected = true;
|
||||
this.#selected = {family: fonts[0].family, index: fonts[0].index};
|
||||
}
|
||||
if ( fonts.length ) selected = definitions[this.#selected.family].fonts[this.#selected.index];
|
||||
return {
|
||||
fonts, selected,
|
||||
font: this.object,
|
||||
family: this.#selected?.family,
|
||||
weights: Object.entries(CONST.FONT_WEIGHTS).map(([k, v]) => ({value: v, label: `${k} ${v}`})),
|
||||
styles: [{value: "normal", label: "Normal"}, {value: "italic", label: "Italic"}]
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Template data for a given font definition.
|
||||
* @param {string} family The font family.
|
||||
* @param {FontFamilyDefinition} definition The font family definition.
|
||||
* @returns {object[]}
|
||||
* @protected
|
||||
*/
|
||||
_getDataForDefinition(family, definition) {
|
||||
const fonts = definition.fonts.length ? definition.fonts : [{}];
|
||||
return fonts.map((f, i) => {
|
||||
const data = {family, index: i};
|
||||
if ( this.#isSelected(data) ) data.selected = true;
|
||||
data.font = this.constructor._formatFont(family, f);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find("[contenteditable]").on("blur", this._onSubmit.bind(this));
|
||||
html.find(".control").on("click", this._onClickControl.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _updateObject(event, formData) {
|
||||
foundry.utils.mergeObject(this.object, formData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async close(options={}) {
|
||||
await super.close(options);
|
||||
if ( this.#fontsModified ) return SettingsConfig.reloadConfirm({world: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle application controls.
|
||||
* @param {MouseEvent} event The click event.
|
||||
* @protected
|
||||
*/
|
||||
_onClickControl(event) {
|
||||
switch ( event.currentTarget.dataset.action ) {
|
||||
case "add": return this._onAddFont();
|
||||
case "delete": return this._onDeleteFont(event);
|
||||
case "select": return this._onSelectFont(event);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onChangeInput(event) {
|
||||
this._updateFontFields();
|
||||
return super._onChangeInput(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update available font fields based on the font type selected.
|
||||
* @protected
|
||||
*/
|
||||
_updateFontFields() {
|
||||
const type = this.form.elements.type.value;
|
||||
const isSystemFont = type === this.constructor.FONT_TYPES.SYSTEM;
|
||||
["weight", "style", "src"].forEach(name => {
|
||||
const input = this.form.elements[name];
|
||||
if ( input ) input.closest(".form-group")?.classList.toggle("hidden", isSystemFont);
|
||||
});
|
||||
this.setPosition();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a new custom font definition.
|
||||
* @protected
|
||||
*/
|
||||
async _onAddFont() {
|
||||
const {family, src, weight, style, type} = this._getSubmitData();
|
||||
const definitions = game.settings.get("core", this.constructor.SETTING);
|
||||
definitions[family] ??= {editor: true, fonts: []};
|
||||
const definition = definitions[family];
|
||||
const count = type === this.constructor.FONT_TYPES.FILE ? definition.fonts.push({urls: [src], weight, style}) : 1;
|
||||
await game.settings.set("core", this.constructor.SETTING, definitions);
|
||||
await this.constructor.loadFont(family, definition);
|
||||
this.#selected = {family, index: count - 1};
|
||||
this.#fontsModified = true;
|
||||
this.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Delete a font.
|
||||
* @param {MouseEvent} event The click event.
|
||||
* @protected
|
||||
*/
|
||||
async _onDeleteFont(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const target = event.currentTarget.closest("[data-family]");
|
||||
const {family, index} = target.dataset;
|
||||
const definitions = game.settings.get("core", this.constructor.SETTING);
|
||||
const definition = definitions[family];
|
||||
if ( !definition ) return;
|
||||
this.#fontsModified = true;
|
||||
definition.fonts.splice(Number(index), 1);
|
||||
if ( !definition.fonts.length ) delete definitions[family];
|
||||
await game.settings.set("core", this.constructor.SETTING, definitions);
|
||||
if ( this.#isSelected({family, index: Number(index)}) ) this.#selected = null;
|
||||
this.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Select a font to preview.
|
||||
* @param {MouseEvent} event The click event.
|
||||
* @protected
|
||||
*/
|
||||
_onSelectFont(event) {
|
||||
const {family, index} = event.currentTarget.dataset;
|
||||
this.#selected = {family, index: Number(index)};
|
||||
this.render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Font Management Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Define the setting key where this world's font information will be stored.
|
||||
* @type {string}
|
||||
*/
|
||||
static SETTING = "fonts";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A list of fonts that were correctly loaded and are available for use.
|
||||
* @type {Set<string>}
|
||||
* @private
|
||||
*/
|
||||
static #available = new Set();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the list of fonts that successfully loaded.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
static getAvailableFonts() {
|
||||
return Array.from(this.#available);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the list of fonts formatted for display with selectOptions.
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
static getAvailableFontChoices() {
|
||||
return this.getAvailableFonts().reduce((obj, f) => {
|
||||
obj[f] = f;
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Load a font definition.
|
||||
* @param {string} family The font family name (case-sensitive).
|
||||
* @param {FontFamilyDefinition} definition The font family definition.
|
||||
* @returns {Promise<boolean>} Returns true if the font was successfully loaded.
|
||||
*/
|
||||
static async loadFont(family, definition) {
|
||||
const font = `1rem "${family}"`;
|
||||
try {
|
||||
for ( const font of definition.fonts ) {
|
||||
const fontFace = this._createFontFace(family, font);
|
||||
await fontFace.load();
|
||||
document.fonts.add(fontFace);
|
||||
}
|
||||
await document.fonts.load(font);
|
||||
} catch(err) {
|
||||
console.warn(`Font family "${family}" failed to load: `, err);
|
||||
return false;
|
||||
}
|
||||
if ( !document.fonts.check(font) ) {
|
||||
console.warn(`Font family "${family}" failed to load.`);
|
||||
return false;
|
||||
}
|
||||
if ( definition.editor ) this.#available.add(family);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Ensure that fonts have loaded and are ready for use.
|
||||
* Enforce a maximum timeout in milliseconds.
|
||||
* Proceed after that point even if fonts are not yet available.
|
||||
* @param {number} [ms=4500] The maximum time to spend loading fonts before proceeding.
|
||||
* @returns {Promise<void>}
|
||||
* @internal
|
||||
*/
|
||||
static async _loadFonts(ms=4500) {
|
||||
const allFonts = this._collectDefinitions();
|
||||
const promises = [];
|
||||
for ( const definitions of allFonts ) {
|
||||
for ( const [family, definition] of Object.entries(definitions) ) {
|
||||
promises.push(this.loadFont(family, definition));
|
||||
}
|
||||
}
|
||||
const timeout = new Promise(resolve => setTimeout(resolve, ms));
|
||||
const ready = Promise.all(promises).then(() => document.fonts.ready);
|
||||
return Promise.race([ready, timeout]).then(() => console.log(`${vtt} | Fonts loaded and ready.`));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Collect all the font definitions and combine them.
|
||||
* @returns {Record<string, FontFamilyDefinition>[]}
|
||||
* @protected
|
||||
*/
|
||||
static _collectDefinitions() {
|
||||
return [CONFIG.fontDefinitions, game.settings.get("core", this.SETTING)];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create FontFace object from a FontDefinition.
|
||||
* @param {string} family The font family name.
|
||||
* @param {FontDefinition} font The font definition.
|
||||
* @returns {FontFace}
|
||||
* @protected
|
||||
*/
|
||||
static _createFontFace(family, font) {
|
||||
const urls = font.urls.map(url => `url("${url}")`).join(", ");
|
||||
return new FontFace(family, urls, font);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Format a font definition for display.
|
||||
* @param {string} family The font family.
|
||||
* @param {FontDefinition} definition The font definition.
|
||||
* @returns {string} The formatted definition.
|
||||
* @private
|
||||
*/
|
||||
static _formatFont(family, definition) {
|
||||
if ( foundry.utils.isEmpty(definition) ) return family;
|
||||
const {weight, style} = definition;
|
||||
const byWeight = Object.fromEntries(Object.entries(CONST.FONT_WEIGHTS).map(([k, v]) => [v, k]));
|
||||
return `
|
||||
${family},
|
||||
<span style="font-weight: ${weight}">${byWeight[weight]} ${weight}</span>,
|
||||
<span style="font-style: ${style}">${style.toLowerCase()}</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
390
resources/app/client/apps/forms/grid-config.js
Normal file
390
resources/app/client/apps/forms/grid-config.js
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* A tool for fine-tuning the grid in a Scene
|
||||
* @param {Scene} scene The scene whose grid is being configured.
|
||||
* @param {SceneConfig} sheet The Scene Configuration sheet that spawned this dialog.
|
||||
* @param {FormApplicationOptions} [options] Application configuration options.
|
||||
*/
|
||||
class GridConfig extends FormApplication {
|
||||
constructor(scene, sheet, ...args) {
|
||||
super(scene, ...args);
|
||||
|
||||
/**
|
||||
* Track the Scene Configuration sheet reference
|
||||
* @type {SceneConfig}
|
||||
*/
|
||||
this.sheet = sheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the bound key handler function
|
||||
* @type {Function}
|
||||
*/
|
||||
#keyHandler;
|
||||
|
||||
/**
|
||||
* A reference to the bound mousewheel handler function
|
||||
* @type {Function}
|
||||
*/
|
||||
#wheelHandler;
|
||||
|
||||
/**
|
||||
* The preview scene
|
||||
* @type {Scene}
|
||||
*/
|
||||
#scene = null;
|
||||
|
||||
/**
|
||||
* The container containing the preview background image and grid
|
||||
* @type {PIXI.Container|null}
|
||||
*/
|
||||
#preview = null;
|
||||
|
||||
/**
|
||||
* The background preview
|
||||
* @type {PIXI.Sprite|null}
|
||||
*/
|
||||
#background = null;
|
||||
|
||||
/**
|
||||
* The grid preview
|
||||
* @type {GridMesh|null}
|
||||
*/
|
||||
#grid = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "grid-config",
|
||||
template: "templates/scene/grid-config.html",
|
||||
title: game.i18n.localize("SCENES.GridConfigTool"),
|
||||
width: 480,
|
||||
height: "auto",
|
||||
closeOnSubmit: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _render(force, options) {
|
||||
const states = Application.RENDER_STATES;
|
||||
if ( force && [states.CLOSED, states.NONE].includes(this._state) ) {
|
||||
if ( !this.object.background.src ) {
|
||||
ui.notifications.warn("WARNING.GridConfigNoBG", {localize: true});
|
||||
}
|
||||
this.#scene = this.object.clone();
|
||||
}
|
||||
await super._render(force, options);
|
||||
await this.#createPreview();
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options={}) {
|
||||
const bg = getTexture(this.#scene.background.src);
|
||||
return {
|
||||
gridTypes: SceneConfig._getGridTypes(),
|
||||
scale: this.#scene.background.src ? this.object.width / bg.width : 1,
|
||||
scene: this.#scene
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_getSubmitData(updateData) {
|
||||
const formData = super._getSubmitData(updateData);
|
||||
const bg = getTexture(this.#scene.background.src);
|
||||
const tex = bg ? bg : {width: this.object.width, height: this.object.height};
|
||||
formData.width = tex.width * formData.scale;
|
||||
formData.height = tex.height * formData.scale;
|
||||
delete formData.scale;
|
||||
return formData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async close(options={}) {
|
||||
document.removeEventListener("keydown", this.#keyHandler);
|
||||
document.removeEventListener("wheel", this.#wheelHandler);
|
||||
this.#keyHandler = this.#wheelHandler = undefined;
|
||||
await this.sheet.maximize();
|
||||
|
||||
const states = Application.RENDER_STATES;
|
||||
if ( options.force || [states.RENDERED, states.ERROR].includes(this._state) ) {
|
||||
this.#scene = null;
|
||||
this.#destroyPreview();
|
||||
}
|
||||
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
this.#keyHandler ||= this.#onKeyDown.bind(this);
|
||||
document.addEventListener("keydown", this.#keyHandler);
|
||||
this.#wheelHandler ||= this.#onWheel.bind(this);
|
||||
document.addEventListener("wheel", this.#wheelHandler, {passive: false});
|
||||
html.find('button[name="reset"]').click(this.#onReset.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle keyboard events.
|
||||
* @param {KeyboardEvent} event The original keydown event
|
||||
*/
|
||||
#onKeyDown(event) {
|
||||
const key = event.code;
|
||||
const up = ["KeyW", "ArrowUp"];
|
||||
const down = ["KeyS", "ArrowDown"];
|
||||
const left = ["KeyA", "ArrowLeft"];
|
||||
const right = ["KeyD", "ArrowRight"];
|
||||
const moveKeys = up.concat(down).concat(left).concat(right);
|
||||
if ( !moveKeys.includes(key) ) return;
|
||||
|
||||
// Increase the Scene scale on shift + up or down
|
||||
if ( event.shiftKey ) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const delta = up.includes(key) ? 1 : (down.includes(key) ? -1 : 0);
|
||||
this.#scaleBackgroundSize(delta);
|
||||
}
|
||||
|
||||
// Resize grid size on ALT
|
||||
else if ( event.altKey ) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const delta = up.includes(key) ? 1 : (down.includes(key) ? -1 : 0);
|
||||
this.#scaleGridSize(delta);
|
||||
}
|
||||
|
||||
// Shift grid position
|
||||
else if ( !game.keyboard.hasFocus ) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if ( up.includes(key) ) this.#shiftBackground({deltaY: -1});
|
||||
else if ( down.includes(key) ) this.#shiftBackground({deltaY: 1});
|
||||
else if ( left.includes(key) ) this.#shiftBackground({deltaX: -1});
|
||||
else if ( right.includes(key) ) this.#shiftBackground({deltaX: 1});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mousewheel events.
|
||||
* @param {WheelEvent} event The original wheel event
|
||||
*/
|
||||
#onWheel(event) {
|
||||
if ( event.deltaY === 0 ) return;
|
||||
const normalizedDelta = -Math.sign(event.deltaY);
|
||||
const activeElement = document.activeElement;
|
||||
const noShiftAndAlt = !(event.shiftKey || event.altKey);
|
||||
const focus = game.keyboard.hasFocus && document.hasFocus;
|
||||
|
||||
// Increase/Decrease the Scene scale
|
||||
if ( event.shiftKey || (!event.altKey && focus && activeElement.name === "scale") ) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
this.#scaleBackgroundSize(normalizedDelta);
|
||||
}
|
||||
|
||||
// Increase/Decrease the Grid scale
|
||||
else if ( event.altKey || (focus && activeElement.name === "grid.size") ) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
this.#scaleGridSize(normalizedDelta);
|
||||
}
|
||||
|
||||
// If no shift or alt key are pressed
|
||||
else if ( noShiftAndAlt && focus ) {
|
||||
// Increase/Decrease the background x offset
|
||||
if ( activeElement.name === "background.offsetX" ) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
this.#shiftBackground({deltaX: normalizedDelta});
|
||||
}
|
||||
// Increase/Decrease the background y offset
|
||||
else if ( activeElement.name === "background.offsetY" ) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
this.#shiftBackground({deltaY: normalizedDelta});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle reset.
|
||||
*/
|
||||
#onReset() {
|
||||
if ( !this.#scene ) return;
|
||||
this.#scene = this.object.clone();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onChangeInput(event) {
|
||||
await super._onChangeInput(event);
|
||||
const previewData = this._getSubmitData();
|
||||
this.#previewChanges(previewData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
const changes = foundry.utils.flattenObject(
|
||||
foundry.utils.diffObject(this.object.toObject(), foundry.utils.expandObject(formData)));
|
||||
if ( ["width", "height", "padding", "background.offsetX", "background.offsetY", "grid.size", "grid.type"].some(k => k in changes) ) {
|
||||
const confirm = await Dialog.confirm({
|
||||
title: game.i18n.localize("SCENES.DimensionChangeTitle"),
|
||||
content: `<p>${game.i18n.localize("SCENES.DimensionChangeWarning")}</p>`
|
||||
});
|
||||
// Update only if the dialog is confirmed
|
||||
if ( confirm ) return this.object.update(formData, {fromSheet: true});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Previewing and Updating Functions */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create preview
|
||||
*/
|
||||
async #createPreview() {
|
||||
if ( !this.#scene ) return;
|
||||
if ( this.#preview ) this.#destroyPreview();
|
||||
this.#preview = canvas.stage.addChild(new PIXI.Container());
|
||||
this.#preview.eventMode = "none";
|
||||
const fill = this.#preview.addChild(new PIXI.Sprite(PIXI.Texture.WHITE));
|
||||
fill.tint = 0x000000;
|
||||
fill.eventMode = "static";
|
||||
fill.hitArea = canvas.app.screen;
|
||||
// Patching updateTransform to render the fill in screen space
|
||||
fill.updateTransform = function() {
|
||||
const screen = canvas.app.screen;
|
||||
this.width = screen.width;
|
||||
this.height = screen.height;
|
||||
this._boundsID++;
|
||||
this.transform.updateTransform(PIXI.Transform.IDENTITY);
|
||||
this.worldAlpha = this.alpha;
|
||||
};
|
||||
this.#background = this.#preview.addChild(new PIXI.Sprite());
|
||||
this.#background.eventMode = "none";
|
||||
if ( this.#scene.background.src ) {
|
||||
try {
|
||||
this.#background.texture = await loadTexture(this.#scene.background.src);
|
||||
} catch(e) {
|
||||
this.#background.texture = PIXI.Texture.WHITE;
|
||||
console.error(e);
|
||||
}
|
||||
} else {
|
||||
this.#background.texture = PIXI.Texture.WHITE;
|
||||
}
|
||||
this.#grid = this.#preview.addChild(new GridMesh().initialize({color: 0xFF0000}));
|
||||
this.#refreshPreview();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Preview changes to the Scene document as if they were true document updates.
|
||||
* @param {object} [change] A change to preview.
|
||||
*/
|
||||
#previewChanges(change) {
|
||||
if ( !this.#scene ) return;
|
||||
if ( change ) this.#scene.updateSource(change);
|
||||
this.#refreshPreview();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Refresh the preview
|
||||
*/
|
||||
#refreshPreview() {
|
||||
if ( !this.#scene || (this.#preview?.destroyed !== false) ) return;
|
||||
|
||||
// Update the background image
|
||||
const d = this.#scene.dimensions;
|
||||
this.#background.position.set(d.sceneX, d.sceneY);
|
||||
this.#background.width = d.sceneWidth;
|
||||
this.#background.height = d.sceneHeight;
|
||||
|
||||
// Update the grid
|
||||
this.#grid.initialize({
|
||||
type: this.#scene.grid.type,
|
||||
width: d.width,
|
||||
height: d.height,
|
||||
size: d.size
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Destroy the preview
|
||||
*/
|
||||
#destroyPreview() {
|
||||
if ( this.#preview?.destroyed === false ) this.#preview.destroy({children: true});
|
||||
this.#preview = null;
|
||||
this.#background = null;
|
||||
this.#grid = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Scale the background size relative to the grid size
|
||||
* @param {number} delta The directional change in background size
|
||||
*/
|
||||
#scaleBackgroundSize(delta) {
|
||||
const scale = (parseFloat(this.form.scale.value) + (delta * 0.001)).toNearest(0.001);
|
||||
this.form.scale.value = Math.clamp(scale, 0.25, 10.0);
|
||||
this.form.scale.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Scale the grid size relative to the background image.
|
||||
* When scaling the grid size in this way, constrain the allowed values between 50px and 300px.
|
||||
* @param {number} delta The grid size in pixels
|
||||
*/
|
||||
#scaleGridSize(delta) {
|
||||
const gridSize = this.form.elements["grid.size"];
|
||||
gridSize.value = Math.clamp(gridSize.valueAsNumber + delta, 50, 300);
|
||||
gridSize.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Shift the background image relative to the grid layer
|
||||
* @param {object} position The position configuration to preview
|
||||
* @param {number} [position.deltaX=0] The number of pixels to shift in the x-direction
|
||||
* @param {number} [position.deltaY=0] The number of pixels to shift in the y-direction
|
||||
*/
|
||||
#shiftBackground({deltaX=0, deltaY=0}) {
|
||||
const ox = this.form["background.offsetX"];
|
||||
ox.value = parseInt(this.form["background.offsetX"].value) + deltaX;
|
||||
this.form["background.offsetY"].value = parseInt(this.form["background.offsetY"].value) + deltaY;
|
||||
ox.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
}
|
||||
}
|
||||
261
resources/app/client/apps/forms/image-popout.js
Normal file
261
resources/app/client/apps/forms/image-popout.js
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* @typedef {FormApplicationOptions} ImagePopoutOptions
|
||||
* @property {string} [caption] Caption text to display below the image.
|
||||
* @property {string|null} [uuid=null] The UUID of some related {@link Document}.
|
||||
* @property {boolean} [showTitle] Force showing or hiding the title.
|
||||
*/
|
||||
|
||||
/**
|
||||
* An Image Popout Application which features a single image in a lightbox style frame.
|
||||
* Furthermore, this application allows for sharing the display of an image with other connected players.
|
||||
* @param {string} src The image URL.
|
||||
* @param {ImagePopoutOptions} [options] Application configuration options.
|
||||
*
|
||||
* @example Creating an Image Popout
|
||||
* ```js
|
||||
* // Construct the Application instance
|
||||
* const ip = new ImagePopout("path/to/image.jpg", {
|
||||
* title: "My Featured Image",
|
||||
* uuid: game.actors.getName("My Hero").uuid
|
||||
* });
|
||||
*
|
||||
* // Display the image popout
|
||||
* ip.render(true);
|
||||
*
|
||||
* // Share the image with other connected players
|
||||
* ip.share();
|
||||
* ```
|
||||
*/
|
||||
class ImagePopout extends FormApplication {
|
||||
/**
|
||||
* A cached reference to the related Document.
|
||||
* @type {ClientDocument}
|
||||
*/
|
||||
#related;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether the application should display video content.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isVideo() {
|
||||
return VideoHelper.hasVideoExtension(this.object);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {ImagePopoutOptions}
|
||||
*/
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
template: "templates/apps/image-popout.html",
|
||||
classes: ["image-popout", "dark"],
|
||||
resizable: true,
|
||||
caption: undefined,
|
||||
uuid: null
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get title() {
|
||||
return this.isTitleVisible() ? super.title : "";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
return {
|
||||
image: this.object,
|
||||
options: this.options,
|
||||
title: this.title,
|
||||
caption: this.options.caption,
|
||||
showTitle: this.isTitleVisible(),
|
||||
isVideo: this.isVideo
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether the title of the image popout should be visible to the user
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isTitleVisible() {
|
||||
return this.options.showTitle ?? this.#related?.testUserPermission(game.user, "LIMITED") ?? true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a reference to the Document referenced by this popout, if one exists
|
||||
* @returns {Promise<ClientDocument>}
|
||||
*/
|
||||
async getRelatedObject() {
|
||||
if ( this.options.uuid && !this.#related ) this.#related = await fromUuid(this.options.uuid);
|
||||
return this.#related;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _render(...args) {
|
||||
await this.getRelatedObject();
|
||||
this.position = await this.constructor.getPosition(this.object);
|
||||
return super._render(...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
// For some reason, unless we do this, videos will not autoplay the first time the popup is opened in a session,
|
||||
// even if the user has made a gesture.
|
||||
if ( this.isVideo ) html.find("video")[0]?.play();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getHeaderButtons() {
|
||||
const buttons = super._getHeaderButtons();
|
||||
if ( game.user.isGM ) {
|
||||
buttons.unshift({
|
||||
label: "JOURNAL.ActionShow",
|
||||
class: "share-image",
|
||||
icon: "fas fa-eye",
|
||||
onclick: () => this.shareImage()
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Helper Methods
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine the correct position and dimensions for the displayed image
|
||||
* @param {string} img The image URL.
|
||||
* @returns {Object} The positioning object which should be used for rendering
|
||||
*/
|
||||
static async getPosition(img) {
|
||||
if ( !img ) return { width: 480, height: 480 };
|
||||
let w;
|
||||
let h;
|
||||
try {
|
||||
[w, h] = this.isVideo ? await this.getVideoSize(img) : await this.getImageSize(img);
|
||||
} catch(err) {
|
||||
return { width: 480, height: 480 };
|
||||
}
|
||||
const position = {};
|
||||
|
||||
// Compare the image aspect ratio to the screen aspect ratio
|
||||
const sr = window.innerWidth / window.innerHeight;
|
||||
const ar = w / h;
|
||||
|
||||
// The image is constrained by the screen width, display at max width
|
||||
if ( ar > sr ) {
|
||||
position.width = Math.min(w * 2, window.innerWidth - 80);
|
||||
position.height = position.width / ar;
|
||||
}
|
||||
|
||||
// The image is constrained by the screen height, display at max height
|
||||
else {
|
||||
position.height = Math.min(h * 2, window.innerHeight - 120);
|
||||
position.width = position.height * ar;
|
||||
}
|
||||
return position;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine the Image dimensions given a certain path
|
||||
* @param {string} path The image source.
|
||||
* @returns {Promise<[number, number]>}
|
||||
*/
|
||||
static getImageSize(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
resolve([this.width, this.height]);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = path;
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine the dimensions of the given video file.
|
||||
* @param {string} src The URL to the video.
|
||||
* @returns {Promise<[number, number]>}
|
||||
*/
|
||||
static getVideoSize(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement("video");
|
||||
video.onloadedmetadata = () => {
|
||||
video.onloadedmetadata = null;
|
||||
resolve([video.videoWidth, video.videoHeight]);
|
||||
};
|
||||
video.onerror = reject;
|
||||
video.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {object} ShareImageConfig
|
||||
* @property {string} image The image URL to share.
|
||||
* @property {string} title The image title.
|
||||
* @property {string} [uuid] The UUID of a Document related to the image, used to determine permission to see
|
||||
* the image title.
|
||||
* @property {boolean} [showTitle] If this is provided, the permissions of the related Document will be ignored and
|
||||
* the title will be shown based on this parameter.
|
||||
* @property {string[]} [users] A list of user IDs to show the image to.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Share the displayed image with other connected Users
|
||||
* @param {ShareImageConfig} [options]
|
||||
*/
|
||||
shareImage(options={}) {
|
||||
options = foundry.utils.mergeObject(this.options, options, { inplace: false });
|
||||
game.socket.emit("shareImage", {
|
||||
image: this.object,
|
||||
title: options.title,
|
||||
caption: options.caption,
|
||||
uuid: options.uuid,
|
||||
showTitle: options.showTitle,
|
||||
users: Array.isArray(options.users) ? options.users : undefined
|
||||
});
|
||||
ui.notifications.info(game.i18n.format("JOURNAL.ActionShowSuccess", {
|
||||
mode: "image",
|
||||
title: options.title,
|
||||
which: "all"
|
||||
}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a received request to display an image.
|
||||
* @param {ShareImageConfig} config The image configuration data.
|
||||
* @returns {ImagePopout}
|
||||
* @internal
|
||||
*/
|
||||
static _handleShareImage({image, title, caption, uuid, showTitle}={}) {
|
||||
const ip = new ImagePopout(image, {title, caption, uuid, showTitle});
|
||||
ip.render(true);
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
58
resources/app/client/apps/forms/item.js
Normal file
58
resources/app/client/apps/forms/item.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single Item document.
|
||||
* @param {Item} item The Item instance being displayed within the sheet.
|
||||
* @param {DocumentSheetOptions} [options] Additional application configuration options.
|
||||
*/
|
||||
class ItemSheet extends DocumentSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
template: "templates/sheets/item-sheet.html",
|
||||
width: 500,
|
||||
closeOnSubmit: false,
|
||||
submitOnClose: true,
|
||||
submitOnChange: true,
|
||||
resizable: true,
|
||||
baseApplication: "ItemSheet",
|
||||
id: "item",
|
||||
secrets: [{parentSelector: ".editor"}]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get title() {
|
||||
return this.item.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience reference to the Item document
|
||||
* @type {Item}
|
||||
*/
|
||||
get item() {
|
||||
return this.object;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Actor instance which owns this item. This may be null if the item is unowned.
|
||||
* @type {Actor}
|
||||
*/
|
||||
get actor() {
|
||||
return this.item.actor;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
const data = super.getData(options);
|
||||
data.item = data.document;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
613
resources/app/client/apps/forms/journal-page-sheet.js
Normal file
613
resources/app/client/apps/forms/journal-page-sheet.js
Normal file
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single JournalEntryPage document.
|
||||
* @extends {DocumentSheet}
|
||||
* @param {JournalEntryPage} object The JournalEntryPage instance which is being edited.
|
||||
* @param {DocumentSheetOptions} [options] Application options.
|
||||
*/
|
||||
class JournalPageSheet extends DocumentSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["sheet", "journal-sheet", "journal-entry-page"],
|
||||
viewClasses: [],
|
||||
width: 600,
|
||||
height: 680,
|
||||
resizable: true,
|
||||
closeOnSubmit: false,
|
||||
submitOnClose: true,
|
||||
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
|
||||
includeTOC: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get template() {
|
||||
return `templates/journal/page-${this.document.type}-${this.isEditable ? "edit" : "view"}.html`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get title() {
|
||||
return this.object.permission ? this.object.name : "";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The table of contents for this JournalTextPageSheet.
|
||||
* @type {Record<string, JournalEntryPageHeading>}
|
||||
*/
|
||||
toc = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
return foundry.utils.mergeObject(super.getData(options), {
|
||||
headingLevels: Object.fromEntries(Array.fromRange(3, 1).map(level => {
|
||||
return [level, game.i18n.format("JOURNALENTRYPAGE.Level", {level})];
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _renderInner(...args) {
|
||||
await loadTemplates({
|
||||
journalEntryPageHeader: "templates/journal/parts/page-header.html",
|
||||
journalEntryPageFooter: "templates/journal/parts/page-footer.html"
|
||||
});
|
||||
const html = await super._renderInner(...args);
|
||||
if ( this.options.includeTOC ) this.toc = JournalEntryPage.implementation.buildTOC(html.get());
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A method called by the journal sheet when the view mode of the page sheet is closed.
|
||||
* @internal
|
||||
*/
|
||||
_closeView() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Text Secrets Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getSecretContent(secret) {
|
||||
return this.object.text.content;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_updateSecret(secret, content) {
|
||||
return this.object.update({"text.content": content});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Text Editor Integration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async activateEditor(name, options={}, initialContent="") {
|
||||
options.fitToSize = true;
|
||||
options.relativeLinks = true;
|
||||
const editor = await super.activateEditor(name, options, initialContent);
|
||||
this.form.querySelector('[role="application"]')?.style.removeProperty("height");
|
||||
return editor;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the parent sheet if it is open when the server autosaves the contents of this editor.
|
||||
* @param {string} html The updated editor contents.
|
||||
*/
|
||||
onAutosave(html) {
|
||||
this.object.parent?.sheet?.render(false);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the UI appropriately when receiving new steps from another client.
|
||||
*/
|
||||
onNewSteps() {
|
||||
this.form.querySelectorAll('[data-action="save-html"]').forEach(el => el.disabled = true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single JournalEntryPage text document.
|
||||
* @extends {JournalPageSheet}
|
||||
*/
|
||||
class JournalTextPageSheet extends JournalPageSheet {
|
||||
/**
|
||||
* Bi-directional HTML <-> Markdown converter.
|
||||
* @type {showdown.Converter}
|
||||
* @protected
|
||||
*/
|
||||
static _converter = (() => {
|
||||
Object.entries(CONST.SHOWDOWN_OPTIONS).forEach(([k, v]) => showdown.setOption(k, v));
|
||||
return new showdown.Converter();
|
||||
})();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Declare the format that we edit text content in for this sheet so we can perform conversions as necessary.
|
||||
* @type {number}
|
||||
*/
|
||||
static get format() {
|
||||
return CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.classes.push("text");
|
||||
options.secrets.push({parentSelector: "section.journal-page-content"});
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
const data = super.getData(options);
|
||||
this._convertFormats(data);
|
||||
data.editor = {
|
||||
engine: "prosemirror",
|
||||
collaborate: true,
|
||||
content: await TextEditor.enrichHTML(data.document.text.content, {
|
||||
relativeTo: this.object,
|
||||
secrets: this.object.isOwner
|
||||
})
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async close(options={}) {
|
||||
Object.values(this.editors).forEach(ed => {
|
||||
if ( ed.instance ) ed.instance.destroy();
|
||||
});
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, options) {
|
||||
if ( !this.#canRender(options.resync) ) return this.maximize().then(() => this.bringToTop());
|
||||
return super._render(force, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Suppress re-rendering the sheet in cases where an active editor has unsaved work.
|
||||
* In such cases we rely upon collaborative editing to save changes and re-render.
|
||||
* @param {boolean} [resync] Was the application instructed to re-sync?
|
||||
* @returns {boolean} Should a render operation be allowed?
|
||||
*/
|
||||
#canRender(resync) {
|
||||
if ( resync || (this._state !== Application.RENDER_STATES.RENDERED) || !this.isEditable ) return true;
|
||||
return !this.isEditorDirty();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine if any editors are dirty.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEditorDirty() {
|
||||
for ( const editor of Object.values(this.editors) ) {
|
||||
if ( editor.active && editor.instance?.isDirty() ) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _updateObject(event, formData) {
|
||||
if ( (this.constructor.format === CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML) && this.isEditorDirty() ) {
|
||||
// Clear any stored markdown so it can be re-converted.
|
||||
formData["text.markdown"] = "";
|
||||
formData["text.format"] = CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
|
||||
}
|
||||
return super._updateObject(event, formData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async saveEditor(name, { preventRender=true, ...options }={}) {
|
||||
return super.saveEditor(name, { ...options, preventRender });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Lazily convert text formats if we detect the document being saved in a different format.
|
||||
* @param {object} renderData Render data.
|
||||
* @protected
|
||||
*/
|
||||
_convertFormats(renderData) {
|
||||
const formats = CONST.JOURNAL_ENTRY_PAGE_FORMATS;
|
||||
const text = this.object.text;
|
||||
if ( (this.constructor.format === formats.MARKDOWN) && text.content?.length && !text.markdown?.length ) {
|
||||
// We've opened an HTML document in a markdown editor, so we need to convert the HTML to markdown for editing.
|
||||
renderData.data.text.markdown = this.constructor._converter.makeMarkdown(text.content.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single JournalEntryPage image document.
|
||||
* @extends {JournalPageSheet}
|
||||
*/
|
||||
class JournalImagePageSheet extends JournalPageSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.classes.push("image");
|
||||
options.height = "auto";
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single JournalEntryPage video document.
|
||||
* @extends {JournalPageSheet}
|
||||
*/
|
||||
class JournalVideoPageSheet extends JournalPageSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.classes.push("video");
|
||||
options.height = "auto";
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
return foundry.utils.mergeObject(super.getData(options), {
|
||||
flexRatio: !this.object.video.width && !this.object.video.height,
|
||||
isYouTube: game.video.isYouTubeURL(this.object.src),
|
||||
timestamp: this._timestampToTimeComponents(this.object.video.timestamp),
|
||||
yt: {
|
||||
id: `youtube-${foundry.utils.randomID()}`,
|
||||
url: game.video.getYouTubeEmbedURL(this.object.src, this._getYouTubeVars())
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
if ( this.isEditable ) return;
|
||||
// The below listeners are only for when the video page is being viewed, not edited.
|
||||
const iframe = html.find("iframe")[0];
|
||||
if ( iframe ) game.video.getYouTubePlayer(iframe.id, {
|
||||
events: {
|
||||
onStateChange: event => {
|
||||
if ( event.data === YT.PlayerState.PLAYING ) event.target.setVolume(this.object.video.volume * 100);
|
||||
}
|
||||
}
|
||||
}).then(player => {
|
||||
if ( this.object.video.timestamp ) player.seekTo(this.object.video.timestamp, true);
|
||||
});
|
||||
const video = html.parent().find("video")[0];
|
||||
if ( video ) {
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
video.volume = this.object.video.volume;
|
||||
if ( this.object.video.timestamp ) video.currentTime = this.object.video.timestamp;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the YouTube player parameters depending on whether the sheet is being viewed or edited.
|
||||
* @returns {object}
|
||||
* @protected
|
||||
*/
|
||||
_getYouTubeVars() {
|
||||
const vars = {playsinline: 1, modestbranding: 1};
|
||||
if ( !this.isEditable ) {
|
||||
vars.controls = this.object.video.controls ? 1 : 0;
|
||||
vars.autoplay = this.object.video.autoplay ? 1 : 0;
|
||||
vars.loop = this.object.video.loop ? 1 : 0;
|
||||
if ( this.object.video.timestamp ) vars.start = this.object.video.timestamp;
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getSubmitData(updateData={}) {
|
||||
const data = super._getSubmitData(updateData);
|
||||
data["video.timestamp"] = this._timeComponentsToTimestamp(foundry.utils.expandObject(data).timestamp);
|
||||
["h", "m", "s"].forEach(c => delete data[`timestamp.${c}`]);
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert time components to a timestamp in seconds.
|
||||
* @param {{[h]: number, [m]: number, [s]: number}} components The time components.
|
||||
* @returns {number} The timestamp, in seconds.
|
||||
* @protected
|
||||
*/
|
||||
_timeComponentsToTimestamp({h=0, m=0, s=0}={}) {
|
||||
return (h * 3600) + (m * 60) + s;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert a timestamp in seconds into separate time components.
|
||||
* @param {number} timestamp The timestamp, in seconds.
|
||||
* @returns {{[h]: number, [m]: number, [s]: number}} The individual time components.
|
||||
* @protected
|
||||
*/
|
||||
_timestampToTimeComponents(timestamp) {
|
||||
if ( !timestamp ) return {};
|
||||
const components = {};
|
||||
const h = Math.floor(timestamp / 3600);
|
||||
if ( h ) components.h = h;
|
||||
const m = Math.floor((timestamp % 3600) / 60);
|
||||
if ( m ) components.m = m;
|
||||
components.s = timestamp - (h * 3600) - (m * 60);
|
||||
return components;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single JournalEntryPage PDF document.
|
||||
* @extends {JournalPageSheet}
|
||||
*/
|
||||
class JournalPDFPageSheet extends JournalPageSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.classes.push("pdf");
|
||||
options.height = "auto";
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintain a cache of PDF sizes to avoid making HEAD requests every render.
|
||||
* @type {Record<string, number>}
|
||||
* @protected
|
||||
*/
|
||||
static _sizes = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find("> button").on("click", this._onLoadPDF.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
return foundry.utils.mergeObject(super.getData(options), {
|
||||
params: this._getViewerParams()
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _renderInner(...args) {
|
||||
const html = await super._renderInner(...args);
|
||||
const pdfLoader = html.closest(".load-pdf")[0];
|
||||
if ( this.isEditable || !pdfLoader ) return html;
|
||||
let size = this.constructor._sizes[this.object.src];
|
||||
if ( size === undefined ) {
|
||||
const res = await fetch(this.object.src, {method: "HEAD"}).catch(() => {});
|
||||
this.constructor._sizes[this.object.src] = size = Number(res?.headers.get("content-length"));
|
||||
}
|
||||
if ( !isNaN(size) ) {
|
||||
const mb = (size / 1024 / 1024).toFixed(2);
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("hint");
|
||||
span.textContent = ` (${mb} MB)`;
|
||||
pdfLoader.querySelector("button").appendChild(span);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a request to load a PDF.
|
||||
* @param {MouseEvent} event The triggering event.
|
||||
* @protected
|
||||
*/
|
||||
_onLoadPDF(event) {
|
||||
const target = event.currentTarget.parentElement;
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = `scripts/pdfjs/web/viewer.html?${this._getViewerParams()}`;
|
||||
target.replaceWith(frame);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve parameters to pass to the PDF viewer.
|
||||
* @returns {URLSearchParams}
|
||||
* @protected
|
||||
*/
|
||||
_getViewerParams() {
|
||||
const params = new URLSearchParams();
|
||||
if ( this.object.src ) {
|
||||
const src = URL.parseSafe(this.object.src) ? this.object.src : foundry.utils.getRoute(this.object.src);
|
||||
params.append("file", src);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A subclass of {@link JournalTextPageSheet} that implements a markdown editor for editing the text content.
|
||||
* @extends {JournalTextPageSheet}
|
||||
*/
|
||||
class MarkdownJournalPageSheet extends JournalTextPageSheet {
|
||||
/**
|
||||
* Store the dirty flag for this editor.
|
||||
* @type {boolean}
|
||||
* @protected
|
||||
*/
|
||||
_isDirty = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get format() {
|
||||
return CONST.JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.dragDrop = [{dropSelector: "textarea"}];
|
||||
options.classes.push("markdown");
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get template() {
|
||||
if ( this.isEditable ) return "templates/journal/page-markdown-edit.html";
|
||||
return super.template;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
const data = await super.getData(options);
|
||||
data.markdownFormat = CONST.JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find("textarea").on("keypress paste", () => this._isDirty = true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
isEditorDirty() {
|
||||
return this._isDirty;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _updateObject(event, formData) {
|
||||
// Do not persist the markdown conversion if the contents have not been edited.
|
||||
if ( !this.isEditorDirty() ) {
|
||||
delete formData["text.markdown"];
|
||||
delete formData["text.format"];
|
||||
}
|
||||
return super._updateObject(event, formData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDrop(event) {
|
||||
event.preventDefault();
|
||||
const eventData = TextEditor.getDragEventData(event);
|
||||
return this._onDropContentLink(eventData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle dropping a content link onto the editor.
|
||||
* @param {object} eventData The parsed event data.
|
||||
* @protected
|
||||
*/
|
||||
async _onDropContentLink(eventData) {
|
||||
const link = await TextEditor.getContentLink(eventData, {relativeTo: this.object});
|
||||
if ( !link ) return;
|
||||
const editor = this.form.elements["text.markdown"];
|
||||
const content = editor.value;
|
||||
editor.value = content.substring(0, editor.selectionStart) + link + content.substring(editor.selectionStart);
|
||||
this._isDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A subclass of {@link JournalTextPageSheet} that implements a TinyMCE editor.
|
||||
* @extends {JournalTextPageSheet}
|
||||
*/
|
||||
class JournalTextTinyMCESheet extends JournalTextPageSheet {
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
const data = await super.getData(options);
|
||||
data.editor.engine = "tinymce";
|
||||
data.editor.collaborate = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async close(options = {}) {
|
||||
return JournalPageSheet.prototype.close.call(this, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _render(force, options) {
|
||||
return JournalPageSheet.prototype._render.call(this, force, options);
|
||||
}
|
||||
}
|
||||
1064
resources/app/client/apps/forms/journal-sheet.js
Normal file
1064
resources/app/client/apps/forms/journal-sheet.js
Normal file
File diff suppressed because it is too large
Load Diff
102
resources/app/client/apps/forms/macro-config.js
Normal file
102
resources/app/client/apps/forms/macro-config.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* A Macro configuration sheet
|
||||
* @extends {DocumentSheet}
|
||||
*
|
||||
* @param {Macro} object The Macro Document which is being configured
|
||||
* @param {DocumentSheetOptions} [options] Application configuration options.
|
||||
*/
|
||||
class MacroConfig extends DocumentSheet {
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["sheet", "macro-sheet"],
|
||||
template: "templates/sheets/macro-config.html",
|
||||
width: 560,
|
||||
height: 480,
|
||||
resizable: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Should this Macro be created in a specific hotbar slot?
|
||||
* @internal
|
||||
*/
|
||||
_hotbarSlot;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getData(options={}) {
|
||||
const data = super.getData();
|
||||
data.macroTypes = game.documentTypes.Macro.map(t => ({
|
||||
value: t,
|
||||
label: game.i18n.localize(CONFIG.Macro.typeLabels[t]),
|
||||
disabled: (t === "script") && !game.user.can("MACRO_SCRIPT")
|
||||
}));
|
||||
data.macroScopes = CONST.MACRO_SCOPES.map(s => ({value: s, label: s}));
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find("button.execute").click(this.#onExecute.bind(this));
|
||||
html.find('select[name="type"]').change(this.#updateCommandDisabled.bind(this));
|
||||
this.#updateCommandDisabled();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_disableFields(form) {
|
||||
super._disableFields(form);
|
||||
if ( this.object.canExecute ) form.querySelector("button.execute").disabled = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the disabled state of the command textarea.
|
||||
*/
|
||||
#updateCommandDisabled() {
|
||||
const type = this.element[0].querySelector('select[name="type"]').value;
|
||||
this.element[0].querySelector('textarea[name="command"]').disabled = (type === "script") && !game.user.can("MACRO_SCRIPT");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Save and execute the macro using the button on the configuration sheet
|
||||
* @param {MouseEvent} event The originating click event
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #onExecute(event) {
|
||||
event.preventDefault();
|
||||
await this._updateObject(event, this._getSubmitData()); // Submit pending changes
|
||||
this.object.execute(); // Execute the macro
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
const updateData = foundry.utils.expandObject(formData);
|
||||
try {
|
||||
if ( this.object.id ) {
|
||||
this.object.updateSource(updateData, { dryRun: true, fallback: false });
|
||||
return await super._updateObject(event, formData);
|
||||
} else {
|
||||
const macro = await Macro.implementation.create(new Macro.implementation(updateData));
|
||||
if ( !macro ) throw new Error("Failed to create Macro");
|
||||
this.object = macro;
|
||||
await game.user.assignHotbarMacro(macro, this._hotbarSlot);
|
||||
}
|
||||
} catch(err) {
|
||||
Hooks.onError("MacroConfig#_updateObject", err, { notify: "error" });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
resources/app/client/apps/forms/measure-template.js
Normal file
41
resources/app/client/apps/forms/measure-template.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The Application responsible for configuring a single MeasuredTemplate document within a parent Scene.
|
||||
* @param {MeasuredTemplate} object The {@link MeasuredTemplate} being configured.
|
||||
* @param {DocumentSheetOptions} [options] Application configuration options.
|
||||
*/
|
||||
class MeasuredTemplateConfig extends DocumentSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "template-config",
|
||||
classes: ["sheet", "template-sheet"],
|
||||
title: "TEMPLATE.MeasuredConfig",
|
||||
template: "templates/scene/template-config.html",
|
||||
width: 400
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData() {
|
||||
return foundry.utils.mergeObject(super.getData(), {
|
||||
templateTypes: CONFIG.MeasuredTemplate.types,
|
||||
gridUnits: this.document.parent.grid.units || game.i18n.localize("GridUnits"),
|
||||
userColor: game.user.color,
|
||||
submitText: `TEMPLATE.Submit${this.options.preview ? "Create" : "Update"}`
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
if ( this.object.id ) {
|
||||
formData.id = this.object.id;
|
||||
return this.object.update(formData);
|
||||
}
|
||||
return this.object.constructor.create(formData);
|
||||
}
|
||||
}
|
||||
139
resources/app/client/apps/forms/ownership.js
Normal file
139
resources/app/client/apps/forms/ownership.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* A generic application for configuring permissions for various Document types.
|
||||
*/
|
||||
class DocumentOwnershipConfig extends DocumentSheet {
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "permission",
|
||||
template: "templates/apps/ownership.html",
|
||||
width: 400
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Are Gamemaster users currently hidden?
|
||||
* @type {boolean}
|
||||
*/
|
||||
static #gmHidden = true;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get title() {
|
||||
return `${game.i18n.localize("OWNERSHIP.Title")}: ${this.document.name}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
const isFolder = this.document instanceof Folder;
|
||||
const isEmbedded = this.document.isEmbedded;
|
||||
const ownership = this.document.ownership;
|
||||
if ( !ownership && !isFolder ) {
|
||||
throw new Error(`The ${this.document.documentName} document does not contain ownership data`);
|
||||
}
|
||||
|
||||
// User permission levels
|
||||
const playerLevels = Object.entries(CONST.DOCUMENT_META_OWNERSHIP_LEVELS).map(([name, level]) => {
|
||||
return {level, label: game.i18n.localize(`OWNERSHIP.${name}`)};
|
||||
});
|
||||
|
||||
if ( !isFolder ) playerLevels.pop();
|
||||
for ( let [name, level] of Object.entries(CONST.DOCUMENT_OWNERSHIP_LEVELS) ) {
|
||||
if ( (level < 0) && !isEmbedded ) continue;
|
||||
playerLevels.push({level, label: game.i18n.localize(`OWNERSHIP.${name}`)});
|
||||
}
|
||||
|
||||
// Default permission levels
|
||||
const defaultLevels = foundry.utils.deepClone(playerLevels);
|
||||
defaultLevels.shift();
|
||||
|
||||
// Player users
|
||||
const users = game.users.map(user => {
|
||||
return {
|
||||
user,
|
||||
level: isFolder ? CONST.DOCUMENT_META_OWNERSHIP_LEVELS.NOCHANGE : ownership[user.id],
|
||||
isAuthor: this.document.author === user,
|
||||
cssClass: user.isGM ? "gm" : "",
|
||||
icon: user.isGM ? "fa-solid fa-crown": "",
|
||||
tooltip: user.isGM ? game.i18n.localize("USER.RoleGamemaster") : ""
|
||||
};
|
||||
}).sort((a, b) => a.user.name.localeCompare(b.user.name, game.i18n.lang));
|
||||
|
||||
// Construct and return the data object
|
||||
return {
|
||||
currentDefault: ownership?.default ?? CONST.DOCUMENT_META_OWNERSHIP_LEVELS.DEFAULT,
|
||||
instructions: game.i18n.localize(isFolder ? "OWNERSHIP.HintFolder" : "OWNERSHIP.HintDocument"),
|
||||
defaultLevels,
|
||||
playerLevels,
|
||||
isFolder,
|
||||
showGM: !DocumentOwnershipConfig.#gmHidden,
|
||||
users
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
// Toggle GM user visibility
|
||||
const toggle = html[0].querySelector("input#show-gm-toggle");
|
||||
toggle.addEventListener("change", () => this.#toggleGamemasters());
|
||||
this.#toggleGamemasters(DocumentOwnershipConfig.#gmHidden);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
event.preventDefault();
|
||||
if ( !game.user.isGM ) throw new Error("You do not have the ability to configure permissions.");
|
||||
// Collect new ownership levels from the form data
|
||||
const metaLevels = CONST.DOCUMENT_META_OWNERSHIP_LEVELS;
|
||||
const isFolder = this.document instanceof Folder;
|
||||
const omit = isFolder ? metaLevels.NOCHANGE : metaLevels.DEFAULT;
|
||||
const ownershipLevels = {};
|
||||
for ( let [user, level] of Object.entries(formData) ) {
|
||||
if ( level === omit ) {
|
||||
delete ownershipLevels[user];
|
||||
continue;
|
||||
}
|
||||
ownershipLevels[user] = level;
|
||||
}
|
||||
|
||||
// Update all documents in a Folder
|
||||
if ( this.document instanceof Folder ) {
|
||||
const cls = getDocumentClass(this.document.type);
|
||||
const updates = this.document.contents.map(d => {
|
||||
const ownership = foundry.utils.deepClone(d.ownership);
|
||||
for ( let [k, v] of Object.entries(ownershipLevels) ) {
|
||||
if ( v === metaLevels.DEFAULT ) delete ownership[k];
|
||||
else ownership[k] = v;
|
||||
}
|
||||
return {_id: d.id, ownership};
|
||||
});
|
||||
return cls.updateDocuments(updates, {diff: false, recursive: false, noHook: true});
|
||||
}
|
||||
|
||||
// Update a single Document
|
||||
return this.document.update({ownership: ownershipLevels}, {diff: false, recursive: false, noHook: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle CSS classes which display or hide gamemaster users
|
||||
* @param {boolean} hidden Should gamemaster users be hidden?
|
||||
*/
|
||||
#toggleGamemasters(hidden) {
|
||||
hidden ??= !DocumentOwnershipConfig.#gmHidden;
|
||||
this.form.classList.toggle("no-gm", hidden);
|
||||
DocumentOwnershipConfig.#gmHidden = hidden;
|
||||
this.setPosition({height: "auto", width: this.options.width});
|
||||
}
|
||||
}
|
||||
85
resources/app/client/apps/forms/playlist-config.js
Normal file
85
resources/app/client/apps/forms/playlist-config.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* The Application responsible for configuring a single Playlist document.
|
||||
* @extends {DocumentSheet}
|
||||
* @param {Playlist} object The {@link Playlist} to configure.
|
||||
* @param {DocumentSheetOptions} [options] Application configuration options.
|
||||
*/
|
||||
class PlaylistConfig extends DocumentSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const options = super.defaultOptions;
|
||||
options.id = "playlist-config";
|
||||
options.template = "templates/playlist/playlist-config.html";
|
||||
options.width = 360;
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get title() {
|
||||
return `${game.i18n.localize("PLAYLIST.Edit")}: ${this.object.name}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
const data = super.getData(options);
|
||||
data.modes = Object.entries(CONST.PLAYLIST_MODES).reduce((obj, e) => {
|
||||
const [name, value] = e;
|
||||
obj[value] = game.i18n.localize(`PLAYLIST.Mode${name.titleCase()}`);
|
||||
return obj;
|
||||
}, {});
|
||||
data.sorting = Object.entries(CONST.PLAYLIST_SORT_MODES).reduce((obj, [name, value]) => {
|
||||
obj[value] = game.i18n.localize(`PLAYLIST.Sort${name.titleCase()}`);
|
||||
return obj;
|
||||
}, {});
|
||||
data.channels = CONST.AUDIO_CHANNELS;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find("file-picker").on("change", this._onBulkImport.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Special actions to take when a bulk-import path is selected in the FilePicker.
|
||||
* @param {Event} event The <file-picker> change event
|
||||
*/
|
||||
async _onBulkImport(event) {
|
||||
|
||||
// Get audio files
|
||||
const fp = event.target;
|
||||
fp.picker.type = "audio";
|
||||
const contents = await fp.picker.browse(fp.value);
|
||||
fp.picker.type = "folder";
|
||||
if ( !contents?.files?.length ) return;
|
||||
|
||||
// Prepare PlaylistSound data
|
||||
const playlist = this.object;
|
||||
const currentSources = new Set(playlist.sounds.map(s => s.path));
|
||||
const toCreate = contents.files.reduce((arr, src) => {
|
||||
if ( !AudioHelper.hasAudioExtension(src) || currentSources.has(src) ) return arr;
|
||||
const soundData = { name: foundry.audio.AudioHelper.getDefaultSoundName(src), path: src };
|
||||
arr.push(soundData);
|
||||
return arr;
|
||||
}, []);
|
||||
|
||||
// Create all PlaylistSound documents
|
||||
if ( toCreate.length ) {
|
||||
ui.playlists._expanded.add(playlist.id);
|
||||
return playlist.createEmbeddedDocuments("PlaylistSound", toCreate);
|
||||
} else {
|
||||
const warning = game.i18n.format("PLAYLIST.BulkImportWarning", {path: filePicker.target});
|
||||
return ui.notifications.warn(warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
resources/app/client/apps/forms/playlist-sound-config.js
Normal file
73
resources/app/client/apps/forms/playlist-sound-config.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The Application responsible for configuring a single PlaylistSound document within a parent Playlist.
|
||||
* @extends {DocumentSheet}
|
||||
*
|
||||
* @param {PlaylistSound} sound The PlaylistSound document being configured
|
||||
* @param {DocumentSheetOptions} [options] Additional application rendering options
|
||||
*/
|
||||
class PlaylistSoundConfig extends DocumentSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "track-config",
|
||||
template: "templates/playlist/sound-config.html",
|
||||
width: 360
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get title() {
|
||||
if ( !this.object.id ) return `${game.i18n.localize("PLAYLIST.SoundCreate")}: ${this.object.parent.name}`;
|
||||
return `${game.i18n.localize("PLAYLIST.SoundEdit")}: ${this.object.name}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
const context = super.getData(options);
|
||||
if ( !this.document.id ) context.data.name = "";
|
||||
context.lvolume = foundry.audio.AudioHelper.volumeToInput(this.document.volume);
|
||||
context.channels = CONST.AUDIO_CHANNELS;
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find('input[name="path"]').change(this._onSourceChange.bind(this));
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Auto-populate the track name using the provided filename, if a name is not already set
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onSourceChange(event) {
|
||||
event.preventDefault();
|
||||
const field = event.target;
|
||||
const form = field.form;
|
||||
if ( !form.name.value ) {
|
||||
form.name.value = foundry.audio.AudioHelper.getDefaultSoundName(field.value);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _updateObject(event, formData) {
|
||||
formData["volume"] = foundry.audio.AudioHelper.inputToVolume(formData["lvolume"]);
|
||||
if (this.object.id) return this.object.update(formData);
|
||||
return this.object.constructor.create(formData, {parent: this.object.parent});
|
||||
}
|
||||
}
|
||||
450
resources/app/client/apps/forms/roll-table-config.js
Normal file
450
resources/app/client/apps/forms/roll-table-config.js
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* The Application responsible for displaying and editing a single RollTable document.
|
||||
* @param {RollTable} table The RollTable document being configured
|
||||
* @param {DocumentSheetOptions} [options] Additional application configuration options
|
||||
*/
|
||||
class RollTableConfig extends DocumentSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["sheet", "roll-table-config"],
|
||||
template: "templates/sheets/roll-table-config.html",
|
||||
width: 720,
|
||||
height: "auto",
|
||||
closeOnSubmit: false,
|
||||
viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
|
||||
scrollY: ["table.table-results tbody"],
|
||||
dragDrop: [{dragSelector: null, dropSelector: null}]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get title() {
|
||||
return `${game.i18n.localize("TABLE.SheetTitle")}: ${this.document.name}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async getData(options={}) {
|
||||
const context = super.getData(options);
|
||||
context.descriptionHTML = await TextEditor.enrichHTML(this.object.description, {secrets: this.object.isOwner});
|
||||
const results = this.document.results.map(result => {
|
||||
result = result.toObject(false);
|
||||
result.isText = result.type === CONST.TABLE_RESULT_TYPES.TEXT;
|
||||
result.isDocument = result.type === CONST.TABLE_RESULT_TYPES.DOCUMENT;
|
||||
result.isCompendium = result.type === CONST.TABLE_RESULT_TYPES.COMPENDIUM;
|
||||
result.img = result.img || CONFIG.RollTable.resultIcon;
|
||||
result.text = TextEditor.decodeHTML(result.text);
|
||||
return result;
|
||||
});
|
||||
results.sort((a, b) => a.range[0] - b.range[0]);
|
||||
|
||||
// Merge data and return;
|
||||
return foundry.utils.mergeObject(context, {
|
||||
results,
|
||||
resultTypes: Object.entries(CONST.TABLE_RESULT_TYPES).reduce((obj, v) => {
|
||||
obj[v[1]] = game.i18n.localize(`TABLE.RESULT_TYPES.${v[0]}.label`);
|
||||
return obj;
|
||||
}, {}),
|
||||
documentTypes: CONST.COMPENDIUM_DOCUMENT_TYPES.map(d =>
|
||||
({value: d, label: game.i18n.localize(getDocumentClass(d).metadata.label)})),
|
||||
compendiumPacks: Array.from(game.packs.keys()).map(k => ({value: k, label: k}))
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
// We need to disable roll button if the document is not editable AND has no formula
|
||||
if ( !this.isEditable && !this.document.formula ) return;
|
||||
|
||||
// Roll the Table
|
||||
const button = html.find("button.roll");
|
||||
button.click(this._onRollTable.bind(this));
|
||||
button[0].disabled = false;
|
||||
|
||||
// The below options require an editable sheet
|
||||
if ( !this.isEditable ) return;
|
||||
|
||||
// Reset the Table
|
||||
html.find("button.reset").click(this._onResetTable.bind(this));
|
||||
|
||||
// Save the sheet on checkbox change
|
||||
html.find('input[type="checkbox"]').change(this._onSubmit.bind(this));
|
||||
|
||||
// Create a new Result
|
||||
html.find("a.create-result").click(this._onCreateResult.bind(this));
|
||||
|
||||
// Delete a Result
|
||||
html.find("a.delete-result").click(this._onDeleteResult.bind(this));
|
||||
|
||||
// Lock or Unlock a Result
|
||||
html.find("a.lock-result").click(this._onLockResult.bind(this));
|
||||
|
||||
// Modify Result Type
|
||||
html.find(".result-type select").change(this._onChangeResultType.bind(this));
|
||||
|
||||
// Re-normalize Table Entries
|
||||
html.find(".normalize-results").click(this._onNormalizeResults.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle creating a TableResult in the RollTable document
|
||||
* @param {MouseEvent} event The originating mouse event
|
||||
* @param {object} [resultData] An optional object of result data to use
|
||||
* @returns {Promise}
|
||||
* @private
|
||||
*/
|
||||
async _onCreateResult(event, resultData={}) {
|
||||
event.preventDefault();
|
||||
|
||||
// Save any pending changes
|
||||
await this._onSubmit(event);
|
||||
|
||||
// Get existing results
|
||||
const results = Array.from(this.document.results.values());
|
||||
let last = results[results.length - 1];
|
||||
|
||||
// Get weight and range data
|
||||
let weight = last ? (last.weight || 1) : 1;
|
||||
let totalWeight = results.reduce((t, r) => t + r.weight, 0) || 1;
|
||||
let minRoll = results.length ? Math.min(...results.map(r => r.range[0])) : 0;
|
||||
let maxRoll = results.length ? Math.max(...results.map(r => r.range[1])) : 0;
|
||||
|
||||
// Determine new starting range
|
||||
const spread = maxRoll - minRoll + 1;
|
||||
const perW = Math.round(spread / totalWeight);
|
||||
const range = [maxRoll + 1, maxRoll + Math.max(1, weight * perW)];
|
||||
|
||||
// Create the new Result
|
||||
resultData = foundry.utils.mergeObject({
|
||||
type: last ? last.type : CONST.TABLE_RESULT_TYPES.TEXT,
|
||||
documentCollection: last ? last.documentCollection : null,
|
||||
weight: weight,
|
||||
range: range,
|
||||
drawn: false
|
||||
}, resultData);
|
||||
return this.document.createEmbeddedDocuments("TableResult", [resultData]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Submit the entire form when a table result type is changed, in case there are other active changes
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onChangeResultType(event) {
|
||||
event.preventDefault();
|
||||
const rt = CONST.TABLE_RESULT_TYPES;
|
||||
const select = event.target;
|
||||
const value = parseInt(select.value);
|
||||
const resultKey = select.name.replace(".type", "");
|
||||
let documentCollection = "";
|
||||
if ( value === rt.DOCUMENT ) documentCollection = "Actor";
|
||||
else if ( value === rt.COMPENDIUM ) documentCollection = game.packs.keys().next().value;
|
||||
const updateData = {[resultKey]: {documentCollection, documentId: null}};
|
||||
return this._onSubmit(event, {updateData});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle deleting a TableResult from the RollTable document
|
||||
* @param {MouseEvent} event The originating click event
|
||||
* @returns {Promise<TableResult>} The deleted TableResult document
|
||||
* @private
|
||||
*/
|
||||
async _onDeleteResult(event) {
|
||||
event.preventDefault();
|
||||
await this._onSubmit(event);
|
||||
const li = event.currentTarget.closest(".table-result");
|
||||
const result = this.object.results.get(li.dataset.resultId);
|
||||
return result.delete();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onDrop(event) {
|
||||
const data = TextEditor.getDragEventData(event);
|
||||
const allowed = Hooks.call("dropRollTableSheetData", this.document, this, data);
|
||||
if ( allowed === false ) return;
|
||||
|
||||
// Get the dropped document
|
||||
if ( !CONST.COMPENDIUM_DOCUMENT_TYPES.includes(data.type) ) return;
|
||||
const cls = getDocumentClass(data.type);
|
||||
const document = await cls.fromDropData(data);
|
||||
if ( !document || document.isEmbedded ) return;
|
||||
|
||||
// Delegate to the onCreate handler
|
||||
const isCompendium = !!document.compendium;
|
||||
return this._onCreateResult(event, {
|
||||
type: isCompendium ? CONST.TABLE_RESULT_TYPES.COMPENDIUM : CONST.TABLE_RESULT_TYPES.DOCUMENT,
|
||||
documentCollection: isCompendium ? document.pack : document.documentName,
|
||||
text: document.name,
|
||||
documentId: document.id,
|
||||
img: document.img || null
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle changing the actor profile image by opening a FilePicker
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onEditImage(event) {
|
||||
const img = event.currentTarget;
|
||||
const isHeader = img.dataset.edit === "img";
|
||||
let current = this.document.img;
|
||||
if ( !isHeader ) {
|
||||
const li = img.closest(".table-result");
|
||||
const result = this.document.results.get(li.dataset.resultId);
|
||||
current = result.img;
|
||||
}
|
||||
const fp = new FilePicker({
|
||||
type: "image",
|
||||
current: current,
|
||||
callback: path => {
|
||||
img.src = path;
|
||||
return this._onSubmit(event);
|
||||
},
|
||||
top: this.position.top + 40,
|
||||
left: this.position.left + 10
|
||||
});
|
||||
return fp.browse();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a button click to re-normalize dice result ranges across all RollTable results
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
async _onNormalizeResults(event) {
|
||||
event.preventDefault();
|
||||
if ( !this.rendered || this._submitting) return false;
|
||||
|
||||
// Save any pending changes
|
||||
await this._onSubmit(event);
|
||||
|
||||
// Normalize the RollTable
|
||||
return this.document.normalize();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling the drawn status of the result in the table
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onLockResult(event) {
|
||||
event.preventDefault();
|
||||
const tableResult = event.currentTarget.closest(".table-result");
|
||||
const result = this.document.results.get(tableResult.dataset.resultId);
|
||||
return result.update({drawn: !result.drawn});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reset the Table to it's original composition with all options unlocked
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
_onResetTable(event) {
|
||||
event.preventDefault();
|
||||
return this.document.resetResults();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle drawing a result from the RollTable
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
async _onRollTable(event) {
|
||||
event.preventDefault();
|
||||
await this.submit({preventClose: true, preventRender: true});
|
||||
event.currentTarget.disabled = true;
|
||||
let tableRoll = await this.document.roll();
|
||||
const draws = this.document.getResultsForRoll(tableRoll.roll.total);
|
||||
if ( draws.length ) {
|
||||
if (game.settings.get("core", "animateRollTable")) await this._animateRoll(draws);
|
||||
await this.document.draw(tableRoll);
|
||||
}
|
||||
event.currentTarget.disabled = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Configure the update object workflow for the Roll Table configuration sheet
|
||||
* Additional logic is needed here to reconstruct the results array from the editable fields on the sheet
|
||||
* @param {Event} event The form submission event
|
||||
* @param {Object} formData The validated FormData translated into an Object for submission
|
||||
* @returns {Promise}
|
||||
* @private
|
||||
*/
|
||||
async _updateObject(event, formData) {
|
||||
// Expand the data to update the results array
|
||||
const expanded = foundry.utils.expandObject(formData);
|
||||
expanded.results = expanded.hasOwnProperty("results") ? Object.values(expanded.results) : [];
|
||||
for (let r of expanded.results) {
|
||||
r.range = [r.rangeL, r.rangeH];
|
||||
switch (r.type) {
|
||||
|
||||
// Document results
|
||||
case CONST.TABLE_RESULT_TYPES.DOCUMENT:
|
||||
const collection = game.collections.get(r.documentCollection);
|
||||
if (!collection) continue;
|
||||
|
||||
// Get the original document, if the name still matches - take no action
|
||||
const original = r.documentId ? collection.get(r.documentId) : null;
|
||||
if (original && (original.name === r.text)) continue;
|
||||
|
||||
// Otherwise, find the document by ID or name (ID preferred)
|
||||
const doc = collection.find(e => (e.id === r.text) || (e.name === r.text)) || null;
|
||||
r.documentId = doc?.id ?? null;
|
||||
r.text = doc?.name ?? null;
|
||||
r.img = doc?.img ?? null;
|
||||
r.img = doc?.thumb || doc?.img || null;
|
||||
break;
|
||||
|
||||
// Compendium results
|
||||
case CONST.TABLE_RESULT_TYPES.COMPENDIUM:
|
||||
const pack = game.packs.get(r.documentCollection);
|
||||
if (pack) {
|
||||
|
||||
// Get the original entry, if the name still matches - take no action
|
||||
const original = pack.index.get(r.documentId) || null;
|
||||
if (original && (original.name === r.text)) continue;
|
||||
|
||||
// Otherwise, find the document by ID or name (ID preferred)
|
||||
const doc = pack.index.find(i => (i._id === r.text) || (i.name === r.text)) || null;
|
||||
r.documentId = doc?._id || null;
|
||||
r.text = doc?.name || null;
|
||||
r.img = doc?.thumb || doc?.img || null;
|
||||
}
|
||||
break;
|
||||
|
||||
// Plain text results
|
||||
default:
|
||||
r.type = CONST.TABLE_RESULT_TYPES.TEXT;
|
||||
r.documentCollection = null;
|
||||
r.documentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the object
|
||||
return this.document.update(expanded, {diff: false, recursive: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a roulette style animation when a Roll Table result is drawn from the sheet
|
||||
* @param {TableResult[]} results An Array of drawn table results to highlight
|
||||
* @returns {Promise} A Promise which resolves once the animation is complete
|
||||
* @protected
|
||||
*/
|
||||
async _animateRoll(results) {
|
||||
|
||||
// Get the list of results and their indices
|
||||
const tableResults = this.element[0].querySelector(".table-results > tbody");
|
||||
const drawnIds = new Set(results.map(r => r.id));
|
||||
const drawnItems = Array.from(tableResults.children).filter(item => drawnIds.has(item.dataset.resultId));
|
||||
|
||||
// Set the animation timing
|
||||
const nResults = this.object.results.size;
|
||||
const maxTime = 2000;
|
||||
let animTime = 50;
|
||||
let animOffset = Math.round(tableResults.offsetHeight / (tableResults.children[0].offsetHeight * 2));
|
||||
const nLoops = Math.min(Math.ceil(maxTime/(animTime * nResults)), 4);
|
||||
if ( nLoops === 1 ) animTime = maxTime / nResults;
|
||||
|
||||
// Animate the roulette
|
||||
await this._animateRoulette(tableResults, drawnIds, nLoops, animTime, animOffset);
|
||||
|
||||
// Flash the results
|
||||
const flashes = drawnItems.map(li => this._flashResult(li));
|
||||
return Promise.all(flashes);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Animate a "roulette" through the table until arriving at the final loop and a drawn result
|
||||
* @param {HTMLOListElement} ol The list element being iterated
|
||||
* @param {Set<string>} drawnIds The result IDs which have already been drawn
|
||||
* @param {number} nLoops The number of times to loop through the animation
|
||||
* @param {number} animTime The desired animation time in milliseconds
|
||||
* @param {number} animOffset The desired pixel offset of the result within the list
|
||||
* @returns {Promise} A Promise that resolves once the animation is complete
|
||||
* @protected
|
||||
*/
|
||||
async _animateRoulette(ol, drawnIds, nLoops, animTime, animOffset) {
|
||||
let loop = 0;
|
||||
let idx = 0;
|
||||
let item = null;
|
||||
return new Promise(resolve => {
|
||||
let animId = setInterval(() => {
|
||||
if (idx === 0) loop++;
|
||||
if (item) item.classList.remove("roulette");
|
||||
|
||||
// Scroll to the next item
|
||||
item = ol.children[idx];
|
||||
ol.scrollTop = (idx - animOffset) * item.offsetHeight;
|
||||
|
||||
// If we are on the final loop
|
||||
if ( (loop === nLoops) && drawnIds.has(item.dataset.resultId) ) {
|
||||
clearInterval(animId);
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// Continue the roulette and cycle the index
|
||||
item.classList.add("roulette");
|
||||
idx = idx < ol.children.length - 1 ? idx + 1 : 0;
|
||||
}, animTime);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a flashing animation on the selected result to emphasize the draw
|
||||
* @param {HTMLElement} item The HTML <li> item of the winning result
|
||||
* @returns {Promise} A Promise that resolves once the animation is complete
|
||||
* @protected
|
||||
*/
|
||||
async _flashResult(item) {
|
||||
return new Promise(resolve => {
|
||||
let count = 0;
|
||||
let animId = setInterval(() => {
|
||||
if (count % 2) item.classList.remove("roulette");
|
||||
else item.classList.add("roulette");
|
||||
if (count === 7) {
|
||||
clearInterval(animId);
|
||||
resolve();
|
||||
}
|
||||
count++;
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
}
|
||||
526
resources/app/client/apps/forms/scene-config.js
Normal file
526
resources/app/client/apps/forms/scene-config.js
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* The Application responsible for configuring a single Scene document.
|
||||
* @extends {DocumentSheet}
|
||||
* @param {Scene} object The Scene Document which is being configured
|
||||
* @param {DocumentSheetOptions} [options] Application configuration options.
|
||||
*/
|
||||
class SceneConfig extends DocumentSheet {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "scene-config",
|
||||
classes: ["sheet", "scene-sheet"],
|
||||
template: "templates/scene/config.html",
|
||||
width: 560,
|
||||
height: "auto",
|
||||
tabs: [
|
||||
{navSelector: '.tabs[data-group="main"]', contentSelector: "form", initial: "basic"},
|
||||
{navSelector: '.tabs[data-group="ambience"]', contentSelector: '.tab[data-tab="ambience"]', initial: "basic"}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Indicates if width / height should change together to maintain aspect ratio
|
||||
* @type {boolean}
|
||||
*/
|
||||
linkedDimensions = true;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get title() {
|
||||
return `${game.i18n.localize("SCENES.ConfigTitle")}: ${this.object.name}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async close(options={}) {
|
||||
this._resetScenePreview();
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
render(force, options={}) {
|
||||
if ( options.renderContext && !["createScene", "updateScene"].includes(options.renderContext) ) return this;
|
||||
return super.render(force, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
const context = super.getData(options);
|
||||
context.data = this.document.toObject(); // Source data, not derived
|
||||
context.playlistSound = this.document.playlistSound?.id || "";
|
||||
context.foregroundElevation = this.document.foregroundElevation;
|
||||
|
||||
// Selectable types
|
||||
context.minGrid = CONST.GRID_MIN_SIZE;
|
||||
context.gridTypes = this.constructor._getGridTypes();
|
||||
context.gridStyles = CONFIG.Canvas.gridStyles;
|
||||
context.weatherTypes = this._getWeatherTypes();
|
||||
context.ownerships = [
|
||||
{value: 0, label: "SCENES.AccessibilityGM"},
|
||||
{value: 2, label: "SCENES.AccessibilityAll"}
|
||||
];
|
||||
|
||||
// Referenced documents
|
||||
context.playlists = this._getDocuments(game.playlists);
|
||||
context.sounds = this._getDocuments(this.object.playlist?.sounds ?? []);
|
||||
context.journals = this._getDocuments(game.journal);
|
||||
context.pages = this.object.journal?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
|
||||
context.isEnvironment = (this._tabs[0].active === "ambience") && (this._tabs[1].active === "environment");
|
||||
context.baseHueSliderDisabled = (this.document.environment.base.intensity === 0);
|
||||
context.darknessHueSliderDisabled = (this.document.environment.dark.intensity === 0);
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an enumeration of the available grid types which can be applied to this Scene
|
||||
* @returns {object}
|
||||
* @internal
|
||||
*/
|
||||
static _getGridTypes() {
|
||||
const labels = {
|
||||
GRIDLESS: "SCENES.GridGridless",
|
||||
SQUARE: "SCENES.GridSquare",
|
||||
HEXODDR: "SCENES.GridHexOddR",
|
||||
HEXEVENR: "SCENES.GridHexEvenR",
|
||||
HEXODDQ: "SCENES.GridHexOddQ",
|
||||
HEXEVENQ: "SCENES.GridHexEvenQ"
|
||||
};
|
||||
return Object.keys(CONST.GRID_TYPES).reduce((obj, t) => {
|
||||
obj[CONST.GRID_TYPES[t]] = labels[t];
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _renderInner(...args) {
|
||||
await loadTemplates([
|
||||
"templates/scene/parts/scene-ambience.html"
|
||||
]);
|
||||
return super._renderInner(...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the available weather effect types which can be applied to this Scene
|
||||
* @returns {object}
|
||||
* @private
|
||||
*/
|
||||
_getWeatherTypes() {
|
||||
const types = {};
|
||||
for ( let [k, v] of Object.entries(CONFIG.weatherEffects) ) {
|
||||
types[k] = game.i18n.localize(v.label);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the alphabetized Documents which can be chosen as a configuration for the Scene
|
||||
* @param {WorldCollection} collection
|
||||
* @returns {object[]}
|
||||
* @private
|
||||
*/
|
||||
_getDocuments(collection) {
|
||||
const documents = collection.map(doc => {
|
||||
return {id: doc.id, name: doc.name};
|
||||
});
|
||||
documents.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
|
||||
return documents;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.find("button.capture-position").click(this._onCapturePosition.bind(this));
|
||||
html.find("button.grid-config").click(this._onGridConfig.bind(this));
|
||||
html.find("button.dimension-link").click(this._onLinkDimensions.bind(this));
|
||||
html.find("select[name='playlist']").change(this._onChangePlaylist.bind(this));
|
||||
html.find('select[name="journal"]').change(this._onChangeJournal.bind(this));
|
||||
html.find('button[type="reset"]').click(this._onResetForm.bind(this));
|
||||
html.find("hue-slider").change(this._onChangeRange.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Capture the current Scene position and zoom level as the initial view in the Scene config
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onCapturePosition(event) {
|
||||
event.preventDefault();
|
||||
if ( !canvas.ready ) return;
|
||||
const btn = event.currentTarget;
|
||||
const form = btn.form;
|
||||
form["initial.x"].value = parseInt(canvas.stage.pivot.x);
|
||||
form["initial.y"].value = parseInt(canvas.stage.pivot.y);
|
||||
form["initial.scale"].value = canvas.stage.scale.x;
|
||||
ui.notifications.info("SCENES.CaptureInitialViewPosition", {localize: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events to open the grid configuration application
|
||||
* @param {Event} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
async _onGridConfig(event) {
|
||||
event.preventDefault();
|
||||
new GridConfig(this.object, this).render(true);
|
||||
return this.minimize();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events to link or unlink the scene dimensions
|
||||
* @param {Event} event
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async _onLinkDimensions(event) {
|
||||
event.preventDefault();
|
||||
this.linkedDimensions = !this.linkedDimensions;
|
||||
this.element.find("button.dimension-link > i").toggleClass("fa-link-simple", this.linkedDimensions);
|
||||
this.element.find("button.dimension-link > i").toggleClass("fa-link-simple-slash", !this.linkedDimensions);
|
||||
this.element.find("button.resize").attr("disabled", !this.linkedDimensions);
|
||||
|
||||
// Update Tooltip
|
||||
const tooltip = game.i18n.localize(this.linkedDimensions ? "SCENES.DimensionLinked" : "SCENES.DimensionUnlinked");
|
||||
this.element.find("button.dimension-link").attr("data-tooltip", tooltip);
|
||||
game.tooltip.activate(this.element.find("button.dimension-link")[0], { text: tooltip });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _onChangeInput(event) {
|
||||
if ( event.target.name === "width" || event.target.name === "height" ) this._onChangeDimensions(event);
|
||||
if ( event.target.name === "environment.darknessLock" ) await this.#onDarknessLockChange(event.target.checked);
|
||||
this._previewScene(event.target.name);
|
||||
return super._onChangeInput(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle darkness lock change and update immediately the database.
|
||||
* @param {boolean} darknessLock If the darkness lock is checked or not.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #onDarknessLockChange(darknessLock) {
|
||||
const darknessLevelForm = this.form["environment.darknessLevel"];
|
||||
darknessLevelForm.disabled = darknessLock;
|
||||
await this.document.update({
|
||||
environment: {
|
||||
darknessLock,
|
||||
darknessLevel: darknessLevelForm.valueAsNumber
|
||||
}}, {render: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onChangeColorPicker(event) {
|
||||
super._onChangeColorPicker(event);
|
||||
this._previewScene(event.target.dataset.edit);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onChangeRange(event) {
|
||||
super._onChangeRange(event);
|
||||
for ( const target of ["base", "dark"] ) {
|
||||
if ( event.target.name === `environment.${target}.intensity` ) {
|
||||
const intensity = this.form[`environment.${target}.intensity`].valueAsNumber;
|
||||
this.form[`environment.${target}.hue`].disabled = (intensity === 0);
|
||||
}
|
||||
}
|
||||
this._previewScene(event.target.name);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onChangeTab(event, tabs, active) {
|
||||
super._onChangeTab(event, tabs, active);
|
||||
const enabled = (this._tabs[0].active === "ambience") && (this._tabs[1].active === "environment");
|
||||
this.element.find('button[type="reset"]').toggleClass("hidden", !enabled);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reset the values of the environment attributes to their default state.
|
||||
* @param {PointerEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
_onResetForm(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Get base and dark ambience defaults and originals
|
||||
const def = Scene.cleanData().environment;
|
||||
const ori = this.document.toObject().environment;
|
||||
const defaults = {base: def.base, dark: def.dark};
|
||||
const original = {base: ori.base, dark: ori.dark};
|
||||
|
||||
// Reset the elements to the default values
|
||||
for ( const target of ["base", "dark"] ) {
|
||||
this.form[`environment.${target}.hue`].disabled = (defaults[target].intensity === 0);
|
||||
this.form[`environment.${target}.intensity`].value = defaults[target].intensity;
|
||||
this.form[`environment.${target}.luminosity`].value = defaults[target].luminosity;
|
||||
this.form[`environment.${target}.saturation`].value = defaults[target].saturation;
|
||||
this.form[`environment.${target}.shadows`].value = defaults[target].shadows;
|
||||
this.form[`environment.${target}.hue`].value = defaults[target].hue;
|
||||
}
|
||||
|
||||
// Update the document with the default environment values
|
||||
this.document.updateSource({environment: defaults});
|
||||
|
||||
// Preview the scene and re-render the config
|
||||
this._previewScene("forceEnvironmentPreview");
|
||||
this.render();
|
||||
|
||||
// Restore original environment values
|
||||
this.document.updateSource({environment: original});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Live update the scene as certain properties are changed.
|
||||
* @param {string} changed The changed property.
|
||||
* @internal
|
||||
*/
|
||||
_previewScene(changed) {
|
||||
if ( !this.object.isView || !canvas.ready || !changed ) return;
|
||||
const force = changed.includes("force");
|
||||
|
||||
// Preview triggered for the grid
|
||||
if ( ["grid.style", "grid.thickness", "grid.color", "grid.alpha"].includes(changed) || force ) {
|
||||
canvas.interface.grid.initializeMesh({
|
||||
style: this.form["grid.style"].value,
|
||||
thickness: Number(this.form["grid.thickness"].value),
|
||||
color: this.form["grid.color"].value,
|
||||
alpha: Number(this.form["grid.alpha"].value)
|
||||
});
|
||||
}
|
||||
|
||||
// To easily track all the environment changes
|
||||
const environmentChange = changed.includes("environment.") || changed.includes("forceEnvironmentPreview") || force;
|
||||
|
||||
// Preview triggered for the ambience manager
|
||||
if ( ["backgroundColor", "fog.colors.explored", "fog.colors.unexplored"].includes(changed)
|
||||
|| environmentChange ) {
|
||||
canvas.environment.initialize(this.#getAmbienceFormData());
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the ambience form data.
|
||||
* @returns {Object}
|
||||
*/
|
||||
#getAmbienceFormData() {
|
||||
const fd = new FormDataExtended(this.form);
|
||||
const formData = foundry.utils.expandObject(fd.object);
|
||||
return {
|
||||
backgroundColor: formData.backgroundColor,
|
||||
fogExploredColor: formData.fog.colors.explored,
|
||||
fogUnexploredColor: formData.fog.colors.unexplored,
|
||||
environment: formData.environment
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reset the previewed darkness level, background color, grid alpha, and grid color back to their true values.
|
||||
* @private
|
||||
*/
|
||||
_resetScenePreview() {
|
||||
if ( !this.object.isView || !canvas.ready ) return;
|
||||
canvas.scene.reset();
|
||||
canvas.environment.initialize();
|
||||
canvas.interface.grid.initializeMesh(canvas.scene.grid);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle updating the select menu of PlaylistSound options when the Playlist is changed
|
||||
* @param {Event} event The initiating select change event
|
||||
* @private
|
||||
*/
|
||||
_onChangePlaylist(event) {
|
||||
event.preventDefault();
|
||||
const playlist = game.playlists.get(event.target.value);
|
||||
const sounds = this._getDocuments(playlist?.sounds || []);
|
||||
const options = ['<option value=""></option>'].concat(sounds.map(s => {
|
||||
return `<option value="${s.id}">${s.name}</option>`;
|
||||
}));
|
||||
const select = this.form.querySelector("select[name=\"playlistSound\"]");
|
||||
select.innerHTML = options.join("");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle updating the select menu of JournalEntryPage options when the JournalEntry is changed.
|
||||
* @param {Event} event The initiating select change event.
|
||||
* @protected
|
||||
*/
|
||||
_onChangeJournal(event) {
|
||||
event.preventDefault();
|
||||
const entry = game.journal.get(event.currentTarget.value);
|
||||
const pages = entry?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
|
||||
const options = pages.map(page => {
|
||||
const selected = (entry.id === this.object.journal?.id) && (page.id === this.object.journalEntryPage);
|
||||
return `<option value="${page.id}"${selected ? " selected" : ""}>${page.name}</option>`;
|
||||
});
|
||||
this.form.elements.journalEntryPage.innerHTML = `<option></option>${options}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle updating the select menu of JournalEntryPage options when the JournalEntry is changed.
|
||||
* @param event
|
||||
* @private
|
||||
*/
|
||||
_onChangeDimensions(event) {
|
||||
event.preventDefault();
|
||||
if ( !this.linkedDimensions ) return;
|
||||
const name = event.currentTarget.name;
|
||||
const value = Number(event.currentTarget.value);
|
||||
const oldValue = name === "width" ? this.object.width : this.object.height;
|
||||
const scale = value / oldValue;
|
||||
const otherInput = this.form.elements[name === "width" ? "height" : "width"];
|
||||
otherInput.value = otherInput.value * scale;
|
||||
|
||||
// If new value is not a round number, display an error and revert
|
||||
if ( !Number.isInteger(parseFloat(otherInput.value)) ) {
|
||||
ui.notifications.error(game.i18n.localize("SCENES.InvalidDimension"));
|
||||
this.form.elements[name].value = oldValue;
|
||||
otherInput.value = name === "width" ? this.object.height : this.object.width;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, formData) {
|
||||
const scene = this.document;
|
||||
|
||||
// FIXME: Ideally, FormDataExtended would know to set these fields to null instead of keeping a blank string
|
||||
// SceneData.texture.src is nullable in the schema, causing an empty string to be initialised to null. We need to
|
||||
// match that logic here to ensure that comparisons to the existing scene image are accurate.
|
||||
if ( formData["background.src"] === "" ) formData["background.src"] = null;
|
||||
if ( formData.foreground === "" ) formData.foreground = null;
|
||||
if ( formData["fog.overlay"] === "" ) formData["fog.overlay"] = null;
|
||||
|
||||
// The same for fog colors
|
||||
if ( formData["fog.colors.unexplored"] === "" ) formData["fog.colors.unexplored"] = null;
|
||||
if ( formData["fog.colors.explored"] === "" ) formData["fog.colors.explored"] = null;
|
||||
|
||||
// Determine what type of change has occurred
|
||||
const hasDefaultDims = (scene.background.src === null) && (scene.width === 4000) && (scene.height === 3000);
|
||||
const hasImage = formData["background.src"] || scene.background.src;
|
||||
const changedBackground =
|
||||
(formData["background.src"] !== undefined) && (formData["background.src"] !== scene.background.src);
|
||||
const clearedDims = (formData.width === null) || (formData.height === null);
|
||||
const needsThumb = changedBackground || !scene.thumb;
|
||||
const needsDims = formData["background.src"] && (clearedDims || hasDefaultDims);
|
||||
const createThumbnail = hasImage && (needsThumb || needsDims);
|
||||
|
||||
// Update thumbnail and image dimensions
|
||||
if ( createThumbnail && game.settings.get("core", "noCanvas") ) {
|
||||
ui.notifications.warn("SCENES.GenerateThumbNoCanvas", {localize: true});
|
||||
formData.thumb = null;
|
||||
} else if ( createThumbnail ) {
|
||||
let td = {};
|
||||
try {
|
||||
td = await scene.createThumbnail({img: formData["background.src"] ?? scene.background.src});
|
||||
} catch(err) {
|
||||
Hooks.onError("SceneConfig#_updateObject", err, {
|
||||
msg: "Thumbnail generation for Scene failed",
|
||||
notify: "error",
|
||||
log: "error",
|
||||
scene: scene.id
|
||||
});
|
||||
}
|
||||
if ( needsThumb ) formData.thumb = td.thumb || null;
|
||||
if ( needsDims ) {
|
||||
formData.width = td.width;
|
||||
formData.height = td.height;
|
||||
}
|
||||
}
|
||||
|
||||
// Warn the user if Scene dimensions are changing
|
||||
const delta = foundry.utils.diffObject(scene._source, foundry.utils.expandObject(formData));
|
||||
const changes = foundry.utils.flattenObject(delta);
|
||||
const textureChange = ["scaleX", "scaleY", "rotation"].map(k => `background.${k}`);
|
||||
if ( ["grid.size", ...textureChange].some(k => k in changes) ) {
|
||||
const confirm = await Dialog.confirm({
|
||||
title: game.i18n.localize("SCENES.DimensionChangeTitle"),
|
||||
content: `<p>${game.i18n.localize("SCENES.DimensionChangeWarning")}</p>`
|
||||
});
|
||||
if ( !confirm ) return;
|
||||
}
|
||||
|
||||
// If the canvas size has changed in a nonuniform way, ask the user if they want to reposition
|
||||
let autoReposition = false;
|
||||
if ( (scene.background?.src || scene.foreground?.src) && (["width", "height", "padding", "background", "grid.size"].some(x => x in changes)) ) {
|
||||
autoReposition = true;
|
||||
|
||||
// If aspect ratio changes, prompt to replace all tokens with new dimensions and warn about distortions
|
||||
let showPrompt = false;
|
||||
if ( "width" in changes && "height" in changes ) {
|
||||
const currentScale = this.object.width / this.object.height;
|
||||
const newScale = formData.width / formData.height;
|
||||
if ( currentScale !== newScale ) {
|
||||
showPrompt = true;
|
||||
}
|
||||
}
|
||||
else if ( "width" in changes || "height" in changes ) {
|
||||
showPrompt = true;
|
||||
}
|
||||
|
||||
if ( showPrompt ) {
|
||||
const confirm = await Dialog.confirm({
|
||||
title: game.i18n.localize("SCENES.DistortedDimensionsTitle"),
|
||||
content: game.i18n.localize("SCENES.DistortedDimensionsWarning"),
|
||||
defaultYes: false
|
||||
});
|
||||
if ( !confirm ) autoReposition = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the update
|
||||
delete formData["environment.darknessLock"];
|
||||
return scene.update(formData, {autoReposition});
|
||||
}
|
||||
}
|
||||
364
resources/app/client/apps/forms/sheet-config.js
Normal file
364
resources/app/client/apps/forms/sheet-config.js
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Document Sheet Configuration Application
|
||||
*/
|
||||
class DocumentSheetConfig extends FormApplication {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["form", "sheet-config"],
|
||||
template: "templates/sheets/sheet-config.html",
|
||||
width: 400
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An array of pending sheet assignments which are submitted before other elements of the framework are ready.
|
||||
* @type {object[]}
|
||||
* @private
|
||||
*/
|
||||
static #pending = [];
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get title() {
|
||||
const name = this.object.name ?? game.i18n.localize(this.object.constructor.metadata.label);
|
||||
return `${name}: Sheet Configuration`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
getData(options={}) {
|
||||
const {sheetClasses, defaultClasses, defaultClass} = this.constructor.getSheetClassesForSubType(
|
||||
this.object.documentName,
|
||||
this.object.type || CONST.BASE_DOCUMENT_TYPE
|
||||
);
|
||||
|
||||
// Return data
|
||||
return {
|
||||
isGM: game.user.isGM,
|
||||
object: this.object.toObject(),
|
||||
options: this.options,
|
||||
sheetClass: this.object.getFlag("core", "sheetClass") ?? "",
|
||||
blankLabel: game.i18n.localize("SHEETS.DefaultSheet"),
|
||||
sheetClasses, defaultClass, defaultClasses
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _updateObject(event, formData) {
|
||||
event.preventDefault();
|
||||
const original = this.getData({});
|
||||
const defaultSheetChanged = formData.defaultClass !== original.defaultClass;
|
||||
const documentSheetChanged = formData.sheetClass !== original.sheetClass;
|
||||
|
||||
// Update world settings
|
||||
if ( game.user.isGM && defaultSheetChanged ) {
|
||||
const setting = game.settings.get("core", "sheetClasses") || {};
|
||||
const type = this.object.type || CONST.BASE_DOCUMENT_TYPE;
|
||||
foundry.utils.mergeObject(setting, {[`${this.object.documentName}.${type}`]: formData.defaultClass});
|
||||
await game.settings.set("core", "sheetClasses", setting);
|
||||
|
||||
// Trigger a sheet change manually if it wouldn't be triggered by the normal ClientDocument#_onUpdate workflow.
|
||||
if ( !documentSheetChanged ) return this.object._onSheetChange({ sheetOpen: true });
|
||||
}
|
||||
|
||||
// Update the document-specific override
|
||||
if ( documentSheetChanged ) return this.object.setFlag("core", "sheetClass", formData.sheetClass);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Marshal information on the available sheet classes for a given document type and sub-type, and format it for
|
||||
* display.
|
||||
* @param {string} documentName The Document type.
|
||||
* @param {string} subType The Document sub-type.
|
||||
* @returns {{sheetClasses: object, defaultClasses: object, defaultClass: string}}
|
||||
*/
|
||||
static getSheetClassesForSubType(documentName, subType) {
|
||||
const config = CONFIG[documentName];
|
||||
const defaultClasses = {};
|
||||
let defaultClass = null;
|
||||
const sheetClasses = Object.values(config.sheetClasses[subType]).reduce((obj, cfg) => {
|
||||
if ( cfg.canConfigure ) obj[cfg.id] = cfg.label;
|
||||
if ( cfg.default && !defaultClass ) defaultClass = cfg.id;
|
||||
if ( cfg.canConfigure && cfg.canBeDefault ) defaultClasses[cfg.id] = cfg.label;
|
||||
return obj;
|
||||
}, {});
|
||||
return {sheetClasses, defaultClasses, defaultClass};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Configuration Methods
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the configured Sheet preferences for Documents which support dynamic Sheet assignment
|
||||
* Create the configuration structure for supported documents
|
||||
* Process any pending sheet registrations
|
||||
* Update the default values from settings data
|
||||
*/
|
||||
static initializeSheets() {
|
||||
for ( let cls of Object.values(foundry.documents) ) {
|
||||
const types = this._getDocumentTypes(cls);
|
||||
CONFIG[cls.documentName].sheetClasses = types.reduce((obj, type) => {
|
||||
obj[type] = {};
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Register any pending sheets
|
||||
this.#pending.forEach(p => {
|
||||
if ( p.action === "register" ) this.#registerSheet(p);
|
||||
else if ( p.action === "unregister" ) this.#unregisterSheet(p);
|
||||
});
|
||||
this.#pending = [];
|
||||
|
||||
// Update default sheet preferences
|
||||
const defaults = game.settings.get("core", "sheetClasses");
|
||||
this.updateDefaultSheets(defaults);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
static _getDocumentTypes(cls, types=[]) {
|
||||
if ( types.length ) return types;
|
||||
return game.documentTypes[cls.documentName];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register a sheet class as a candidate which can be used to display documents of a given type
|
||||
* @param {typeof ClientDocument} documentClass The Document class for which to register a new Sheet option
|
||||
* @param {string} scope Provide a unique namespace scope for this sheet
|
||||
* @param {typeof DocumentSheet} sheetClass A defined Application class used to render the sheet
|
||||
* @param {object} [config] Additional options used for sheet registration
|
||||
* @param {string|Function} [config.label] A human-readable label for the sheet name, which will be localized
|
||||
* @param {string[]} [config.types] An array of document types for which this sheet should be used
|
||||
* @param {boolean} [config.makeDefault=false] Whether to make this sheet the default for provided types
|
||||
* @param {boolean} [config.canBeDefault=true] Whether this sheet is available to be selected as a default sheet for
|
||||
* all Documents of that type.
|
||||
* @param {boolean} [config.canConfigure=true] Whether this sheet appears in the sheet configuration UI for users.
|
||||
*/
|
||||
static registerSheet(documentClass, scope, sheetClass, {
|
||||
label, types, makeDefault=false, canBeDefault=true, canConfigure=true
|
||||
}={}) {
|
||||
const id = `${scope}.${sheetClass.name}`;
|
||||
const config = {documentClass, id, label, sheetClass, types, makeDefault, canBeDefault, canConfigure};
|
||||
if ( game.ready ) this.#registerSheet(config);
|
||||
else {
|
||||
config.action = "register";
|
||||
this.#pending.push(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the sheet registration.
|
||||
* @param {object} config Configuration for how the sheet should be registered
|
||||
* @param {typeof ClientDocument} config.documentClass The Document class being registered
|
||||
* @param {string} config.id The sheet ID being registered
|
||||
* @param {string} config.label The human-readable sheet label
|
||||
* @param {typeof DocumentSheet} config.sheetClass The sheet class definition being registered
|
||||
* @param {object[]} config.types An array of types for which this sheet is added
|
||||
* @param {boolean} config.makeDefault Make this sheet the default for provided types?
|
||||
* @param {boolean} config.canBeDefault Whether this sheet is available to be selected as a default
|
||||
* sheet for all Documents of that type.
|
||||
* @param {boolean} config.canConfigure Whether the sheet appears in the sheet configuration UI for
|
||||
* users.
|
||||
*/
|
||||
static #registerSheet({documentClass, id, label, sheetClass, types, makeDefault, canBeDefault, canConfigure}={}) {
|
||||
types = this._getDocumentTypes(documentClass, types);
|
||||
const classes = CONFIG[documentClass.documentName]?.sheetClasses;
|
||||
const defaults = game.ready ? game.settings.get("core", "sheetClasses") : {};
|
||||
if ( typeof classes !== "object" ) return;
|
||||
for ( const t of types ) {
|
||||
classes[t] ||= {};
|
||||
const existingDefault = defaults[documentClass.documentName]?.[t];
|
||||
const isDefault = existingDefault ? (existingDefault === id) : makeDefault;
|
||||
if ( isDefault ) Object.values(classes[t]).forEach(s => s.default = false);
|
||||
if ( label instanceof Function ) label = label();
|
||||
else if ( label ) label = game.i18n.localize(label);
|
||||
else label = id;
|
||||
classes[t][id] = {
|
||||
id, label, canBeDefault, canConfigure,
|
||||
cls: sheetClass,
|
||||
default: isDefault
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Unregister a sheet class, removing it from the list of available Applications to use for a Document type
|
||||
* @param {typeof ClientDocument} documentClass The Document class for which to register a new Sheet option
|
||||
* @param {string} scope Provide a unique namespace scope for this sheet
|
||||
* @param {typeof DocumentSheet} sheetClass A defined DocumentSheet subclass used to render the sheet
|
||||
* @param {object} [config]
|
||||
* @param {object[]} [config.types] An Array of types for which this sheet should be removed
|
||||
*/
|
||||
static unregisterSheet(documentClass, scope, sheetClass, {types}={}) {
|
||||
const id = `${scope}.${sheetClass.name}`;
|
||||
const config = {documentClass, id, types};
|
||||
if ( game.ready ) this.#unregisterSheet(config);
|
||||
else {
|
||||
config.action = "unregister";
|
||||
this.#pending.push(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the sheet de-registration.
|
||||
* @param {object} config Configuration for how the sheet should be un-registered
|
||||
* @param {typeof ClientDocument} config.documentClass The Document class being unregistered
|
||||
* @param {string} config.id The sheet ID being unregistered
|
||||
* @param {object[]} config.types An array of types for which this sheet is removed
|
||||
*/
|
||||
static #unregisterSheet({documentClass, id, types}={}) {
|
||||
types = this._getDocumentTypes(documentClass, types);
|
||||
const classes = CONFIG[documentClass.documentName]?.sheetClasses;
|
||||
if ( typeof classes !== "object" ) return;
|
||||
for ( let t of types ) {
|
||||
delete classes[t][id];
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the current default Sheets using a new core world setting.
|
||||
* @param {object} setting
|
||||
*/
|
||||
static updateDefaultSheets(setting={}) {
|
||||
if ( !Object.keys(setting).length ) return;
|
||||
for ( let cls of Object.values(foundry.documents) ) {
|
||||
const documentName = cls.documentName;
|
||||
const cfg = CONFIG[documentName];
|
||||
const classes = cfg.sheetClasses;
|
||||
const collection = cfg.collection?.instance ?? [];
|
||||
const defaults = setting[documentName];
|
||||
if ( !defaults ) continue;
|
||||
|
||||
// Update default preference for registered sheets
|
||||
for ( let [type, sheetId] of Object.entries(defaults) ) {
|
||||
const sheets = Object.values(classes[type] || {});
|
||||
let requested = sheets.find(s => s.id === sheetId);
|
||||
if ( requested ) sheets.forEach(s => s.default = s.id === sheetId);
|
||||
}
|
||||
|
||||
// Close and de-register any existing sheets
|
||||
for ( let document of collection ) {
|
||||
for ( const [id, app] of Object.entries(document.apps) ) {
|
||||
app.close();
|
||||
delete document.apps[id];
|
||||
}
|
||||
document._sheet = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize default sheet configurations for all document types.
|
||||
* @private
|
||||
*/
|
||||
static _registerDefaultSheets() {
|
||||
const defaultSheets = {
|
||||
// Documents
|
||||
Actor: ActorSheet,
|
||||
Adventure: AdventureImporter,
|
||||
Folder: FolderConfig,
|
||||
Item: ItemSheet,
|
||||
JournalEntry: JournalSheet,
|
||||
Macro: MacroConfig,
|
||||
Playlist: PlaylistConfig,
|
||||
RollTable: RollTableConfig,
|
||||
Scene: SceneConfig,
|
||||
User: foundry.applications.sheets.UserConfig,
|
||||
// Embedded Documents
|
||||
ActiveEffect: ActiveEffectConfig,
|
||||
AmbientLight: foundry.applications.sheets.AmbientLightConfig,
|
||||
AmbientSound: foundry.applications.sheets.AmbientSoundConfig,
|
||||
Card: CardConfig,
|
||||
Combatant: CombatantConfig,
|
||||
Drawing: DrawingConfig,
|
||||
MeasuredTemplate: MeasuredTemplateConfig,
|
||||
Note: NoteConfig,
|
||||
PlaylistSound: PlaylistSoundConfig,
|
||||
Region: foundry.applications.sheets.RegionConfig,
|
||||
RegionBehavior: foundry.applications.sheets.RegionBehaviorConfig,
|
||||
Tile: TileConfig,
|
||||
Token: TokenConfig,
|
||||
Wall: WallConfig
|
||||
};
|
||||
|
||||
Object.values(foundry.documents).forEach(base => {
|
||||
const type = base.documentName;
|
||||
const cfg = CONFIG[type];
|
||||
cfg.sheetClasses = {};
|
||||
const defaultSheet = defaultSheets[type];
|
||||
if ( !defaultSheet ) return;
|
||||
DocumentSheetConfig.registerSheet(cfg.documentClass, "core", defaultSheet, {
|
||||
makeDefault: true,
|
||||
label: () => game.i18n.format("SHEETS.DefaultDocumentSheet", {document: game.i18n.localize(`DOCUMENT.${type}`)})
|
||||
});
|
||||
});
|
||||
DocumentSheetConfig.registerSheet(Cards, "core", CardsConfig, {
|
||||
label: "CARDS.CardsDeck",
|
||||
types: ["deck"],
|
||||
makeDefault: true
|
||||
});
|
||||
DocumentSheetConfig.registerSheet(Cards, "core", CardsHand, {
|
||||
label: "CARDS.CardsHand",
|
||||
types: ["hand"],
|
||||
makeDefault: true
|
||||
});
|
||||
DocumentSheetConfig.registerSheet(Cards, "core", CardsPile, {
|
||||
label: "CARDS.CardsPile",
|
||||
types: ["pile"],
|
||||
makeDefault: true
|
||||
});
|
||||
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalTextTinyMCESheet, {
|
||||
types: ["text"],
|
||||
label: () => game.i18n.localize("EDITOR.TinyMCE")
|
||||
});
|
||||
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalImagePageSheet, {
|
||||
types: ["image"],
|
||||
makeDefault: true,
|
||||
label: () =>
|
||||
game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {page: game.i18n.localize("JOURNALENTRYPAGE.TypeImage")})
|
||||
});
|
||||
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalVideoPageSheet, {
|
||||
types: ["video"],
|
||||
makeDefault: true,
|
||||
label: () =>
|
||||
game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {page: game.i18n.localize("JOURNALENTRYPAGE.TypeVideo")})
|
||||
});
|
||||
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalPDFPageSheet, {
|
||||
types: ["pdf"],
|
||||
makeDefault: true,
|
||||
label: () =>
|
||||
game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {page: game.i18n.localize("JOURNALENTRYPAGE.TypePDF")})
|
||||
});
|
||||
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalTextPageSheet, {
|
||||
types: ["text"],
|
||||
makeDefault: true,
|
||||
label: () => {
|
||||
return game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {
|
||||
page: game.i18n.localize("JOURNALENTRYPAGE.TypeText")
|
||||
});
|
||||
}
|
||||
});
|
||||
DocumentSheetConfig.registerSheet(JournalEntryPage, "core", MarkdownJournalPageSheet, {
|
||||
types: ["text"],
|
||||
label: () => game.i18n.localize("EDITOR.Markdown")
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user