/** * @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>} 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, documentsToCreate: Array}>} * @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} documentsToCreate The documents to create * @returns {Promise} * @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: '', 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: '', 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: '', 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: '', 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: '', 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; } });