Files
Foundry-VTT-Docker/resources/app/client/apps/sidebar/document-directory.js

561 lines
18 KiB
JavaScript
Raw Normal View History

2025-01-04 00:34:03 +01:00
/**
* @typedef {ApplicationOptions} DocumentDirectoryOptions
* @property {string[]} [renderUpdateKeys] A list of data property keys that will trigger a rerender of the tab if
* they are updated on a Document that this tab is responsible for.
* @property {string} [contextMenuSelector] The CSS selector that activates the context menu for displayed Documents.
* @property {string} [entryClickSelector] The CSS selector for the clickable area of an entry in the tab.
*/
/**
* A shared pattern for the sidebar directory which Actors, Items, and Scenes all use
* @extends {SidebarTab}
* @abstract
* @interface
*
* @param {DocumentDirectoryOptions} [options] Application configuration options.
*/
class DocumentDirectory extends DirectoryApplicationMixin(SidebarTab) {
constructor(options={}) {
super(options);
/**
* References to the set of Documents which are displayed in the Sidebar
* @type {ClientDocument[]}
*/
this.documents = null;
/**
* Reference the set of Folders which exist in this Sidebar
* @type {Folder[]}
*/
this.folders = null;
// If a collection was provided, use it instead of the default
this.#collection = options.collection ?? this.constructor.collection;
// Initialize sidebar content
this.initialize();
// Record the directory as an application of the collection if it is not a popout
if ( !this.options.popOut ) this.collection.apps.push(this);
}
/* -------------------------------------------- */
/**
* A reference to the named Document type that this Sidebar Directory instance displays
* @type {string}
*/
static documentName = "Document";
/** @override */
static entryPartial = "templates/sidebar/partials/document-partial.html";
/** @override */
get entryType() {
return this.constructor.documentName;
}
/* -------------------------------------------- */
/**
* @override
* @returns {DocumentDirectoryOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/sidebar/document-directory.html",
renderUpdateKeys: ["name", "img", "thumb", "ownership", "sort", "sorting", "folder"]
});
}
/* -------------------------------------------- */
/** @inheritDoc */
get title() {
const cls = getDocumentClass(this.constructor.documentName);
return `${game.i18n.localize(cls.metadata.labelPlural)} Directory`;
}
/* -------------------------------------------- */
/** @inheritDoc */
get id() {
const cls = getDocumentClass(this.constructor.documentName);
const pack = cls.metadata.collection;
return `${pack}${this._original ? "-popout" : ""}`;
}
/* -------------------------------------------- */
/** @inheritDoc */
get tabName() {
const cls = getDocumentClass(this.constructor.documentName);
return cls.metadata.collection;
}
/* -------------------------------------------- */
/**
* The WorldCollection instance which this Sidebar Directory displays.
* @type {WorldCollection}
*/
static get collection() {
return game.collections.get(this.documentName);
}
/* -------------------------------------------- */
/**
* The collection of Documents which are displayed in this Sidebar Directory
* @type {DocumentCollection}
*/
get collection() {
return this.#collection;
}
/* -------------------------------------------- */
/**
* A per-instance reference to a collection of documents which are displayed in this Sidebar Directory. If set, supersedes the World Collection
* @private
*/
#collection;
/* -------------------------------------------- */
/* Initialization Helpers */
/* -------------------------------------------- */
/**
* Initialize the content of the directory by categorizing folders and documents into a hierarchical tree structure.
*/
initialize() {
// Assign Folders
this.folders = this.collection.folders.contents;
// Assign Documents
this.documents = this.collection.filter(e => e.visible);
// Build Tree
this.collection.initializeTree();
}
/* -------------------------------------------- */
/* Application Rendering
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, context={}) {
// Only re-render the sidebar directory for certain types of updates
const {renderContext, renderData} = context;
if ( (renderContext === `update${this.entryType}`) && !renderData?.some(d => {
return this.options.renderUpdateKeys.some(k => foundry.utils.hasProperty(d, k));
}) ) return;
// Re-build the tree and render
this.initialize();
return super._render(force, context);
}
/* -------------------------------------------- */
/** @override */
get canCreateEntry() {
const cls = getDocumentClass(this.constructor.documentName);
return cls.canUserCreate(game.user);
}
/* -------------------------------------------- */
/** @override */
get canCreateFolder() {
return this.canCreateEntry;
}
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const context = await super.getData(options);
const cfg = CONFIG[this.collection.documentName];
const cls = cfg.documentClass;
return foundry.utils.mergeObject(context, {
documentCls: cls.documentName.toLowerCase(),
tabName: cls.metadata.collection,
sidebarIcon: cfg.sidebarIcon,
folderIcon: CONFIG.Folder.sidebarIcon,
label: game.i18n.localize(cls.metadata.label),
labelPlural: game.i18n.localize(cls.metadata.labelPlural),
unavailable: game.user.isGM ? cfg.collection?.instance?.invalidDocumentIds?.size : 0
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".show-issues").on("click", () => new SupportDetails().render(true, {tab: "documents"}));
}
/* -------------------------------------------- */
/** @override */
async _onClickEntryName(event) {
event.preventDefault();
const element = event.currentTarget;
const documentId = element.parentElement.dataset.documentId;
const document = this.collection.get(documentId) ?? await this.collection.getDocument(documentId);
document.sheet.render(true);
}
/* -------------------------------------------- */
/** @override */
async _onCreateEntry(event, { _skipDeprecated=false }={}) {
/**
* @deprecated since v11
*/
if ( (this._onCreateDocument !== DocumentDirectory.prototype._onCreateDocument) && !_skipDeprecated ) {
foundry.utils.logCompatibilityWarning("DocumentDirectory#_onCreateDocument is deprecated. "
+ "Please use DocumentDirectory#_onCreateEntry instead.", {since: 11, until: 13});
return this._onCreateDocument(event);
}
event.preventDefault();
event.stopPropagation();
const button = event.currentTarget;
const li = button.closest(".directory-item");
const data = {folder: li?.dataset?.folderId};
const options = {width: 320, left: window.innerWidth - 630, top: button.offsetTop };
if ( this.collection instanceof CompendiumCollection ) options.pack = this.collection.collection;
const cls = getDocumentClass(this.collection.documentName);
return cls.createDialog(data, options);
}
/* -------------------------------------------- */
/** @override */
_onDrop(event) {
const data = TextEditor.getDragEventData(event);
if ( !data.type ) return;
const target = event.target.closest(".directory-item") || null;
// Call the drop handler
switch ( data.type ) {
case "Folder":
return this._handleDroppedFolder(target, data);
case this.collection.documentName:
return this._handleDroppedEntry(target, data);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _handleDroppedEntry(target, data, { _skipDeprecated=false }={}) {
/**
* @deprecated since v11
*/
if ( (this._handleDroppedDocument !== DocumentDirectory.prototype._handleDroppedDocument) && !_skipDeprecated ) {
foundry.utils.logCompatibilityWarning("DocumentDirectory#_handleDroppedDocument is deprecated. "
+ "Please use DocumentDirectory#_handleDroppedEntry instead.", {since: 11, until: 13});
return this._handleDroppedDocument(target, data);
}
return super._handleDroppedEntry(target, data);
}
/* -------------------------------------------- */
/** @override */
async _getDroppedEntryFromData(data) {
const cls = this.collection.documentClass;
return cls.fromDropData(data);
}
/* -------------------------------------------- */
/** @override */
async _sortRelative(entry, sortData) {
return entry.sortRelative(sortData);
}
/* -------------------------------------------- */
/** @override */
async _createDroppedEntry(document, folderId) {
const data = document.toObject();
data.folder = folderId || null;
return document.constructor.create(data, {fromCompendium: !!document.compendium });
}
/* -------------------------------------------- */
/** @override */
async _handleDroppedForeignFolder(folder, closestFolderId, sortData) {
const createdFolders = await this._createDroppedFolderContent(folder, this.collection.folders.get(closestFolderId));
if ( createdFolders.length ) folder = createdFolders[0];
return {
sortNeeded: true,
folder: folder
};
}
/* -------------------------------------------- */
/**
* Create a dropped Folder and its children in this Collection, if they do not already exist
* @param {Folder} folder The Folder being dropped
* @param {Folder} targetFolder The Folder to which the Folder should be added
* @returns {Promise<Array<Folder>>} The created Folders
* @protected
*/
async _createDroppedFolderContent(folder, targetFolder) {
const {foldersToCreate, documentsToCreate} = await this._organizeDroppedFoldersAndDocuments(folder, targetFolder);
// Create Folders
let createdFolders;
try {
createdFolders = await Folder.createDocuments(foldersToCreate, {
pack: this.collection.collection,
keepId: true
});
}
catch (err) {
ui.notifications.error(err.message);
throw err;
}
// Create Documents
await this._createDroppedFolderDocuments(folder, documentsToCreate);
return createdFolders;
}
/* -------------------------------------------- */
/**
* Organize a dropped Folder and its children into a list of folders to create and documents to create
* @param {Folder} folder The Folder being dropped
* @param {Folder} targetFolder The Folder to which the Folder should be added
* @returns {Promise<{foldersToCreate: Array<Folder>, documentsToCreate: Array<Document>}>}
* @private
*/
async _organizeDroppedFoldersAndDocuments(folder, targetFolder) {
let foldersToCreate = [];
let documentsToCreate = [];
let exceededMaxDepth = false;
const addFolder = (folder, currentDepth) => {
if ( !folder ) return;
// If the Folder does not already exist, add it to the list of folders to create
if ( this.collection.folders.get(folder.id) !== folder ) {
const createData = folder.toObject();
if ( targetFolder ) {
createData.folder = targetFolder.id;
targetFolder = undefined;
}
if ( currentDepth > this.maxFolderDepth ) {
exceededMaxDepth = true;
return;
}
createData.pack = this.collection.collection;
foldersToCreate.push(createData);
}
// If the Folder has documents, check those as well
if ( folder.contents?.length ) {
for ( const document of folder.contents ) {
const createData = document.toObject ? document.toObject() : foundry.utils.deepClone(document);
documentsToCreate.push(createData);
}
}
// Recursively check child folders
for ( const child of folder.children ) {
addFolder(child.folder, currentDepth + 1);
}
};
const currentDepth = (targetFolder?.ancestors.length ?? 0) + 1;
addFolder(folder, currentDepth);
if ( exceededMaxDepth ) {
ui.notifications.error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: this.maxFolderDepth}), {console: false});
foldersToCreate.length = documentsToCreate.length = 0;
}
return {foldersToCreate, documentsToCreate};
}
/* -------------------------------------------- */
/**
* Create a list of documents in a dropped Folder
* @param {Folder} folder The Folder being dropped
* @param {Array<Document>} documentsToCreate The documents to create
* @returns {Promise<void>}
* @protected
*/
async _createDroppedFolderDocuments(folder, documentsToCreate) {
if ( folder.pack ) {
const pack = game.packs.get(folder.pack);
if ( pack ) {
const ids = documentsToCreate.map(d => d._id);
documentsToCreate = await pack.getDocuments({_id__in: ids});
}
}
try {
await this.collection.documentClass.createDocuments(documentsToCreate, {
pack: this.collection.collection,
keepId: true
});
}
catch (err) {
ui.notifications.error(err.message);
throw err;
}
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be used for Folders in a SidebarDirectory
* @returns {object[]} The Array of context options passed to the ContextMenu instance
* @protected
*/
_getFolderContextOptions() {
const options = super._getFolderContextOptions();
return options.concat([
{
name: "OWNERSHIP.Configure",
icon: '<i class="fas fa-lock"></i>',
condition: () => game.user.isGM,
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
new DocumentOwnershipConfig(folder, {
top: Math.min(li.offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720
}).render(true);
}
},
{
name: "FOLDER.Export",
icon: '<i class="fas fa-atlas"></i>',
condition: header => {
const folder = fromUuidSync(header.parent().data("uuid"));
return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
},
callback: async header => {
const li = header.closest(".directory-item")[0];
const folder = await fromUuid(li.dataset.uuid);
return folder.exportDialog(null, {
top: Math.min(li.offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
});
}
}
]);
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be used for Documents in a SidebarDirectory
* @returns {object[]} The Array of context options passed to the ContextMenu instance
* @protected
*/
_getEntryContextOptions() {
const options = super._getEntryContextOptions();
return [
{
name: "OWNERSHIP.Configure",
icon: '<i class="fas fa-lock"></i>',
condition: () => game.user.isGM,
callback: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
new DocumentOwnershipConfig(document, {
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720
}).render(true);
}
},
{
name: "SIDEBAR.Export",
icon: '<i class="fas fa-file-export"></i>',
condition: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
return document.isOwner;
},
callback: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
return document.exportToJSON();
}
},
{
name: "SIDEBAR.Import",
icon: '<i class="fas fa-file-import"></i>',
condition: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
return document.isOwner;
},
callback: header => {
const li = header.closest(".directory-item");
const document = this.collection.get(li.data("documentId"));
return document.importFromJSONDialog();
}
}
].concat(options);
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async _onCreateDocument(event) {
foundry.utils.logCompatibilityWarning("DocumentDirectory#_onCreateDocument is deprecated. "
+ "Please use DocumentDirectory#_onCreateEntry instead.", {since: 11, until: 13});
return this._onCreateEntry(event, { _skipDeprecated: true });
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async _handleDroppedDocument(target, data) {
foundry.utils.logCompatibilityWarning("DocumentDirectory#_handleDroppedDocument is deprecated. "
+ "Please use DocumentDirectory#_handleDroppedEntry instead.", {since: 11, until: 13});
return this._handleDroppedEntry(target, data, { _skipDeprecated: true });
}
}
/**
* @deprecated since v11
*/
Object.defineProperty(globalThis, "SidebarDirectory", {
get() {
foundry.utils.logCompatibilityWarning("SidebarDirectory has been deprecated. Please use DocumentDirectory instead.",
{since: 11, until: 13});
return DocumentDirectory;
}
});