/** * @typedef {Object} DirectoryMixinEntry * @property {string} id The unique id of the entry * @property {Folder|string} folder The folder id or folder object to which this entry belongs * @property {string} [img] An image path to display for the entry * @property {string} [sort] A numeric sort value which orders this entry relative to others * @interface */ /** * Augment an Application instance with functionality that supports rendering as a directory of foldered entries. * @param {typeof Application} Base The base Application class definition * @returns {typeof DirectoryApplication} The decorated DirectoryApplication class definition */ function DirectoryApplicationMixin(Base) { return class DirectoryApplication extends Base { /** * The path to the template partial which renders a single Entry within this directory * @type {string} */ static entryPartial = "templates/sidebar/partials/entry-partial.html"; /** * The path to the template partial which renders a single Folder within this directory * @type {string} */ static folderPartial = "templates/sidebar/folder-partial.html"; /* -------------------------------------------- */ /** * @inheritdoc * @returns {DocumentDirectoryOptions} */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { renderUpdateKeys: ["name", "sort", "sorting", "folder"], height: "auto", scrollY: ["ol.directory-list"], dragDrop: [{dragSelector: ".directory-item", dropSelector: ".directory-list"}], filters: [{inputSelector: 'input[name="search"]', contentSelector: ".directory-list"}], contextMenuSelector: ".directory-item.document", entryClickSelector: ".entry-name" }); } /* -------------------------------------------- */ /** * The type of Entry that is contained in this DirectoryTab. * @type {string} */ get entryType() { throw new Error("You must implement the entryType getter for this DirectoryTab"); } /* -------------------------------------------- */ /** * The maximum depth of folder nesting which is allowed in this DirectoryTab * @returns {number} */ get maxFolderDepth() { return this.collection.maxFolderDepth; } /* -------------------------------------------- */ /** * Can the current User create new Entries in this DirectoryTab? * @returns {boolean} */ get canCreateEntry() { return game.user.isGM; } /* -------------------------------------------- */ /** * Can the current User create new Folders in this DirectoryTab? * @returns {boolean} */ get canCreateFolder() { return this.canCreateEntry; } /* -------------------------------------------- */ /** @inheritdoc */ _onSearchFilter(event, query, rgx, html) { const isSearch = !!query; let entryIds = new Set(); const folderIds = new Set(); const autoExpandFolderIds = new Set(); // Match entries and folders if ( isSearch ) { // Include folders and their parents, auto-expanding parent folders const includeFolder = (folder, autoExpand = true) => { if ( !folder ) return; if ( typeof folder === "string" ) folder = this.collection.folders.get(folder); if ( !folder ) return; const folderId = folder._id; if ( folderIds.has(folderId) ) { // If this folder is not already auto-expanding, but it should be, add it to the set if ( autoExpand && !autoExpandFolderIds.has(folderId) ) autoExpandFolderIds.add(folderId); return; } folderIds.add(folderId); if ( autoExpand ) autoExpandFolderIds.add(folderId); if ( folder.folder ) includeFolder(folder.folder); }; // First match folders this._matchSearchFolders(rgx, includeFolder); // Next match entries this._matchSearchEntries(rgx, entryIds, folderIds, includeFolder); } // Toggle each directory item for ( let el of html.querySelectorAll(".directory-item") ) { if ( el.classList.contains("hidden") ) continue; if ( el.classList.contains("folder") ) { let match = isSearch && folderIds.has(el.dataset.folderId); el.style.display = (!isSearch || match) ? "flex" : "none"; if ( autoExpandFolderIds.has(el.dataset.folderId) ) { if ( isSearch && match ) el.classList.remove("collapsed"); } else el.classList.toggle("collapsed", !game.folders._expanded[el.dataset.uuid]); } else el.style.display = (!isSearch || entryIds.has(el.dataset.entryId)) ? "flex" : "none"; } } /* -------------------------------------------- */ /** * Identify folders in the collection which match a provided search query. * This method is factored out to be extended by subclasses, for example to support compendium indices. * @param {RegExp} query The search query * @param {Function} includeFolder A callback function to include the folder of any matched entry * @protected */ _matchSearchFolders(query, includeFolder) { for ( const folder of this.collection.folders ) { if ( query.test(SearchFilter.cleanQuery(folder.name)) ) { includeFolder(folder, false); } } } /* -------------------------------------------- */ /** * Identify entries in the collection which match a provided search query. * This method is factored out to be extended by subclasses, for example to support compendium indices. * @param {RegExp} query The search query * @param {Set} entryIds The set of matched Entry IDs * @param {Set} folderIds The set of matched Folder IDs * @param {Function} includeFolder A callback function to include the folder of any matched entry * @protected */ _matchSearchEntries(query, entryIds, folderIds, includeFolder) { const nameOnlySearch = (this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME); const entries = this.collection.index ?? this.collection.contents; // Copy the folderIds to a new set so we can add to the original set without incorrectly adding child entries const matchedFolderIds = new Set(folderIds); for ( const entry of entries ) { const entryId = this._getEntryId(entry); // If we matched a folder, add its children entries if ( matchedFolderIds.has(entry.folder?._id ?? entry.folder) ) entryIds.add(entryId); // Otherwise, if we are searching by name, match the entry name else if ( nameOnlySearch && query.test(SearchFilter.cleanQuery(this._getEntryName(entry))) ) { entryIds.add(entryId); includeFolder(entry.folder); } } if ( nameOnlySearch ) return; // Full Text Search const matches = this.collection.search({query: query.source, exclude: Array.from(entryIds)}); for ( const match of matches ) { if ( entryIds.has(match._id) ) continue; entryIds.add(match._id); includeFolder(match.folder); } } /* -------------------------------------------- */ /** * Get the name to search against for a given entry * @param {Document|object} entry The entry to get the name for * @returns {string} The name of the entry * @protected */ _getEntryName(entry) { return entry.name; } /* -------------------------------------------- */ /** * Get the ID for a given entry * @param {Document|object} entry The entry to get the id for * @returns {string} The id of the entry * @protected */ _getEntryId(entry) { return entry._id; } /* -------------------------------------------- */ /** @inheritDoc */ async getData(options) { const data = await super.getData(options); return foundry.utils.mergeObject(data, { tree: this.collection.tree, entryPartial: this.#getEntryPartial(), folderPartial: this.constructor.folderPartial, canCreateEntry: this.canCreateEntry, canCreateFolder: this.canCreateFolder, sortIcon: this.collection.sortingMode === "a" ? "fa-arrow-down-a-z" : "fa-arrow-down-short-wide", sortTooltip: this.collection.sortingMode === "a" ? "SIDEBAR.SortModeAlpha" : "SIDEBAR.SortModeManual", searchIcon: this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "fa-search" : "fa-file-magnifying-glass", searchTooltip: this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "SIDEBAR.SearchModeName" : "SIDEBAR.SearchModeFull" }); } /* -------------------------------------------- */ /** @inheritDoc */ async _render(force, options) { await loadTemplates([this.#getEntryPartial(), this.constructor.folderPartial]); return super._render(force, options); } /* -------------------------------------------- */ /** * Retrieve the entry partial. * @returns {string} */ #getEntryPartial() { /** * @deprecated since v11 */ if ( this.constructor.documentPartial ) { foundry.utils.logCompatibilityWarning("Your sidebar application defines the documentPartial static property" + " which is deprecated. Please use entryPartial instead.", {since: 11, until: 13}); return this.constructor.documentPartial; } return this.constructor.entryPartial; } /* -------------------------------------------- */ /** @inheritDoc */ activateListeners(html) { super.activateListeners(html); const directory = html.find(".directory-list"); const entries = directory.find(".directory-item"); // Handle folder depth and collapsing html.find(`[data-folder-depth="${this.maxFolderDepth}"] .create-folder`).remove(); html.find(".toggle-sort").click(this.#onToggleSort.bind(this)); html.find(".toggle-search-mode").click(this.#onToggleSearchMode.bind(this)); html.find(".collapse-all").click(this.collapseAll.bind(this)); // Intersection Observer const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), { root: directory[0] }); entries.each((i, li) => observer.observe(li)); // Entry-level events directory.on("click", this.options.entryClickSelector, this._onClickEntryName.bind(this)); directory.on("click", ".folder-header", this._toggleFolder.bind(this)); const dh = this._onDragHighlight.bind(this); html.find(".folder").on("dragenter", dh).on("dragleave", dh); this._contextMenu(html); // Allow folder and entry creation if ( this.canCreateFolder ) html.find(".create-folder").click(this._onCreateFolder.bind(this)); if ( this.canCreateEntry ) html.find(".create-entry").click(this._onCreateEntry.bind(this)); } /* -------------------------------------------- */ /** * Swap the sort mode between "a" (Alphabetical) and "m" (Manual by sort property) * @param {PointerEvent} event The originating pointer event */ #onToggleSort(event) { event.preventDefault(); this.collection.toggleSortingMode(); this.render(); } /* -------------------------------------------- */ /** * Swap the search mode between "name" and "full" * @param {PointerEvent} event The originating pointer event */ #onToggleSearchMode(event) { event.preventDefault(); this.collection.toggleSearchMode(); this.render(); } /* -------------------------------------------- */ /** * Collapse all subfolders in this directory */ collapseAll() { this.element.find("li.folder").addClass("collapsed"); for ( let f of this.collection.folders ) { game.folders._expanded[f.uuid] = false; } if ( this.popOut ) this.setPosition(); } /* -------------------------------------------- */ /** * Create a new Folder in this SidebarDirectory * @param {PointerEvent} event The originating button click event * @protected */ _onCreateFolder(event) { event.preventDefault(); event.stopPropagation(); const button = event.currentTarget; const li = button.closest(".directory-item"); const data = {folder: li?.dataset?.folderId || null, type: this.entryType}; const options = {top: button.offsetTop, left: window.innerWidth - 310 - FolderConfig.defaultOptions.width}; if ( this.collection instanceof CompendiumCollection ) options.pack = this.collection.collection; Folder.createDialog(data, options); } /* -------------------------------------------- */ /** * Handle toggling the collapsed or expanded state of a folder within the directory tab * @param {PointerEvent} event The originating click event * @protected */ _toggleFolder(event) { let folder = $(event.currentTarget.parentElement); let collapsed = folder.hasClass("collapsed"); const folderUuid = folder[0].dataset.uuid; game.folders._expanded[folderUuid] = collapsed; // Expand if ( collapsed ) folder.removeClass("collapsed"); // Collapse else { folder.addClass("collapsed"); const subs = folder.find(".folder").addClass("collapsed"); subs.each((i, f) => game.folders._expanded[folderUuid] = false); } // Resize container if ( this.popOut ) this.setPosition(); } /* -------------------------------------------- */ /** * Handle clicking on a Document name in the Sidebar directory * @param {PointerEvent} event The originating click event * @protected */ async _onClickEntryName(event) { event.preventDefault(); const element = event.currentTarget; const entryId = element.parentElement.dataset.entryId; const entry = this.collection.get(entryId); entry.sheet.render(true); } /* -------------------------------------------- */ /** * Handle new Entry creation request * @param {PointerEvent} event The originating button click event * @protected */ async _onCreateEntry(event) { throw new Error("You must implement the _onCreateEntry method"); } /* -------------------------------------------- */ /** @override */ _onDragStart(event) { if ( ui.context ) ui.context.close({animate: false}); const li = event.currentTarget.closest(".directory-item"); const isFolder = li.classList.contains("folder"); const dragData = isFolder ? this._getFolderDragData(li.dataset.folderId) : this._getEntryDragData(li.dataset.entryId); if ( !dragData ) return; event.dataTransfer.setData("text/plain", JSON.stringify(dragData)); } /* -------------------------------------------- */ /** * Get the data transfer object for a Entry being dragged from this SidebarDirectory * @param {string} entryId The Entry's _id being dragged * @returns {Object} * @private */ _getEntryDragData(entryId) { const entry = this.collection.get(entryId); return entry?.toDragData(); } /* -------------------------------------------- */ /** * Get the data transfer object for a Folder being dragged from this SidebarDirectory * @param {string} folderId The Folder _id being dragged * @returns {Object} * @private */ _getFolderDragData(folderId) { const folder = this.collection.folders.get(folderId); if ( !folder ) return null; return { type: "Folder", uuid: folder.uuid }; } /* -------------------------------------------- */ /** @override */ _canDragStart(selector) { return true; } /* -------------------------------------------- */ /** * Highlight folders as drop targets when a drag event enters or exits their area * @param {DragEvent} event The DragEvent which is in progress */ _onDragHighlight(event) { const li = event.currentTarget; if ( !li.classList.contains("folder") ) return; event.stopPropagation(); // Don't bubble to parent folders // Remove existing drop targets if ( event.type === "dragenter" ) { for ( let t of li.closest(".directory-list").querySelectorAll(".droptarget") ) { t.classList.remove("droptarget"); } } // Remove current drop target if ( event.type === "dragleave" ) { const el = document.elementFromPoint(event.clientX, event.clientY); const parent = el.closest(".folder"); if ( parent === li ) return; } // Add new drop target li.classList.toggle("droptarget", event.type === "dragenter"); } /* -------------------------------------------- */ /** @override */ _onDrop(event) { const data = TextEditor.getDragEventData(event); if ( !data.type ) return; const target = event.target.closest(".directory-item") || null; switch ( data.type ) { case "Folder": return this._handleDroppedFolder(target, data); case this.entryType: return this._handleDroppedEntry(target, data); } } /* -------------------------------------------- */ /** * Handle Folder data being dropped into the directory. * @param {HTMLElement} target The target element * @param {object} data The data being dropped * @protected */ async _handleDroppedFolder(target, data) { // Determine the closest Folder const closestFolder = target ? target.closest(".folder") : null; if ( closestFolder ) closestFolder.classList.remove("droptarget"); const closestFolderId = closestFolder ? closestFolder.dataset.folderId : null; // Obtain the dropped Folder let folder = await fromUuid(data.uuid); if ( !folder ) return; if ( folder?.type !== this.entryType ) { const typeLabel = game.i18n.localize(getDocumentClass(this.collection.documentName).metadata.label); ui.notifications.warn(game.i18n.format("FOLDER.InvalidDocumentType", {type: typeLabel})); return; } // Sort into another Folder const sortData = {sortKey: "sort", sortBefore: true}; const isRelative = target && target.dataset.folderId; if ( isRelative ) { const targetFolder = await fromUuid(target.dataset.uuid); // Sort relative to a collapsed Folder if ( target.classList.contains("collapsed") ) { sortData.target = targetFolder; sortData.parentId = targetFolder.folder?.id; sortData.parentUuid = targetFolder.folder?.uuid; } // Drop into an expanded Folder else { sortData.target = null; sortData.parentId = targetFolder.id; sortData.parentUuid = targetFolder.uuid; } } // Sort relative to existing Folder contents else { sortData.parentId = closestFolderId; sortData.parentUuid = closestFolder?.dataset?.uuid; sortData.target = closestFolder && closestFolder.classList.contains("collapsed") ? closestFolder : null; } if ( sortData.parentId ) { const parentFolder = await fromUuid(sortData.parentUuid); if ( parentFolder === folder ) return; // Prevent assigning a folder as its own parent. if ( parentFolder.ancestors.includes(folder) ) return; // Prevent creating a cycle. // Prevent going beyond max depth const maxDepth = f => Math.max(f.depth, ...f.children.filter(n => n.folder).map(n => maxDepth(n.folder))); if ( (parentFolder.depth + (maxDepth(folder) - folder.depth + 1)) > this.maxFolderDepth ) { ui.notifications.error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: this.maxFolderDepth}), {console: false}); return; } } // Determine siblings sortData.siblings = this.collection.folders.filter(f => { return (f.folder?.id === sortData.parentId) && (f.type === folder.type) && (f !== folder); }); // Handle dropping of some folder that is foreign to this collection if ( this.collection.folders.get(folder.id) !== folder ) { const dropped = await this._handleDroppedForeignFolder(folder, closestFolderId, sortData); if ( !dropped || !dropped.sortNeeded ) return; folder = dropped.folder; } // Resort the collection sortData.updateData = { folder: sortData.parentId }; return folder.sortRelative(sortData); } /* -------------------------------------------- */ /** * Handle a new Folder being dropped into the directory. * This case is not handled by default, but subclasses may implement custom handling here. * @param {Folder} folder The Folder being dropped * @param {string} closestFolderId The closest Folder _id to the drop target * @param {object} sortData The sort data for the Folder * @param {string} sortData.sortKey The sort key to use for sorting * @param {boolean} sortData.sortBefore Sort before the target? * @returns {Promise<{folder: Folder, sortNeeded: boolean}|null>} The handled folder creation, or null * @protected */ async _handleDroppedForeignFolder(folder, closestFolderId, sortData) { return null; } /* -------------------------------------------- */ /** * Handle Entry data being dropped into the directory. * @param {HTMLElement} target The target element * @param {object} data The data being dropped * @protected */ async _handleDroppedEntry(target, data) { // Determine the closest Folder const closestFolder = target ? target.closest(".folder") : null; if ( closestFolder ) closestFolder.classList.remove("droptarget"); let folder = closestFolder ? await fromUuid(closestFolder.dataset.uuid) : null; let entry = await this._getDroppedEntryFromData(data); if ( !entry ) return; // Sort relative to another Document const collection = this.collection.index ?? this.collection; const sortData = {sortKey: "sort"}; const isRelative = target && target.dataset.entryId; if ( isRelative ) { if ( entry.id === target.dataset.entryId ) return; // Don't drop on yourself const targetDocument = collection.get(target.dataset.entryId); sortData.target = targetDocument; folder = targetDocument?.folder; } // Sort within to the closest Folder else sortData.target = null; // Determine siblings if ( folder instanceof foundry.abstract.Document ) folder = folder.id; sortData.siblings = collection.filter(d => !this._entryIsSelf(d, entry) && this._entryBelongsToFolder(d, folder)); if ( !this._entryAlreadyExists(entry) ) { // Try to predetermine the sort order const sorted = SortingHelpers.performIntegerSort(entry, sortData); if ( sorted.length === 1 ) entry = entry.clone({sort: sorted[0].update[sortData.sortKey]}, {keepId: true}); entry = await this._createDroppedEntry(entry, folder); // No need to resort other documents if the document was created with a specific sort order if ( sorted.length === 1 ) return; } // Resort the collection sortData.updateData = {folder: folder || null}; return this._sortRelative(entry, sortData); } /* -------------------------------------------- */ /** * Determine if an Entry is being compared to itself * @param {DirectoryMixinEntry} entry The Entry * @param {DirectoryMixinEntry} otherEntry The other Entry * @returns {boolean} Is the Entry being compared to itself? * @protected */ _entryIsSelf(entry, otherEntry) { return entry._id === otherEntry._id; } /* -------------------------------------------- */ /** * Determine whether an Entry belongs to the target folder * @param {DirectoryMixinEntry} entry The Entry * @param {Folder} folder The target folder * @returns {boolean} Is the Entry a sibling? * @protected */ _entryBelongsToFolder(entry, folder) { if ( !entry.folder && !folder ) return true; if ( entry.folder instanceof foundry.abstract.Document ) return entry.folder.id === folder; return entry.folder === folder; } /* -------------------------------------------- */ /** * Check if an Entry is already present in the Collection * @param {DirectoryMixinEntry} entry The Entry being dropped * @returns {boolean} Is the Entry already present? * @private */ _entryAlreadyExists(entry) { return this.collection.get(entry.id) === entry; } /* -------------------------------------------- */ /** * Get the dropped Entry from the drop data * @param {object} data The data being dropped * @returns {Promise} The dropped Entry * @protected */ async _getDroppedEntryFromData(data) { throw new Error("The _getDroppedEntryFromData method must be implemented"); } /* -------------------------------------------- */ /** * Create a dropped Entry in this Collection * @param {DirectoryMixinEntry} entry The Entry being dropped * @param {string} [folderId] The ID of the Folder to which the Entry should be added * @returns {Promise} The created Entry * @protected */ async _createDroppedEntry(entry, folderId) { throw new Error("The _createDroppedEntry method must be implemented"); } /* -------------------------------------------- */ /** * Sort a relative entry within a collection * @param {DirectoryMixinEntry} entry The entry to sort * @param {object} sortData The sort data * @param {string} sortData.sortKey The sort key to use for sorting * @param {boolean} sortData.sortBefore Sort before the target? * @param {object} sortData.updateData Additional data to update on the entry * @returns {Promise} The sorted entry */ async _sortRelative(entry, sortData) { throw new Error("The _sortRelative method must be implemented"); } /* -------------------------------------------- */ /** @inheritdoc */ _contextMenu(html) { /** * A hook event that fires when the context menu for folders in this DocumentDirectory is constructed. * Substitute the class name in the hook event, for example "getActorDirectoryFolderContext". * @function getSidebarTabFolderContext * @memberof hookEvents * @param {DirectoryApplication} application The Application instance that the context menu is constructed in * @param {ContextMenuEntry[]} entryOptions The context menu entries */ ContextMenu.create(this, html, ".folder .folder-header", this._getFolderContextOptions(), { hookName: "FolderContext" }); ContextMenu.create(this, html, this.options.contextMenuSelector, this._getEntryContextOptions()); } /* -------------------------------------------- */ /** * Get the set of ContextMenu options which should be used for Folders in a SidebarDirectory * @returns {object[]} The Array of context options passed to the ContextMenu instance * @protected */ _getFolderContextOptions() { return [ { name: "FOLDER.Edit", icon: '', condition: game.user.isGM, callback: async header => { const li = header.closest(".directory-item")[0]; const folder = await fromUuid(li.dataset.uuid); const r = li.getBoundingClientRect(); const options = {top: r.top, left: r.left - FolderConfig.defaultOptions.width - 10}; new FolderConfig(folder, options).render(true); } }, { name: "FOLDER.CreateTable", icon: ``, condition: header => { const li = header.closest(".directory-item")[0]; const folder = fromUuidSync(li.dataset.uuid); return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type); }, callback: async header => { const li = header.closest(".directory-item")[0]; const folder = await fromUuid(li.dataset.uuid); return Dialog.confirm({ title: `${game.i18n.localize("FOLDER.CreateTable")}: ${folder.name}`, content: game.i18n.localize("FOLDER.CreateTableConfirm"), yes: () => RollTable.fromFolder(folder), options: { top: Math.min(li.offsetTop, window.innerHeight - 350), left: window.innerWidth - 680, width: 360 } }); } }, { name: "FOLDER.Remove", icon: '', condition: game.user.isGM, callback: async header => { const li = header.closest(".directory-item")[0]; const folder = await fromUuid(li.dataset.uuid); return Dialog.confirm({ title: `${game.i18n.localize("FOLDER.Remove")} ${folder.name}`, content: `

${game.i18n.localize("AreYouSure")}

${game.i18n.localize("FOLDER.RemoveWarning")}

`, yes: () => folder.delete({deleteSubfolders: false, deleteContents: false}), options: { top: Math.min(li.offsetTop, window.innerHeight - 350), left: window.innerWidth - 720, width: 400 } }); } }, { name: "FOLDER.Delete", icon: '', condition: game.user.isGM && (this.entryType !== "Compendium"), callback: async header => { const li = header.closest(".directory-item")[0]; const folder = await fromUuid(li.dataset.uuid); return Dialog.confirm({ title: `${game.i18n.localize("FOLDER.Delete")} ${folder.name}`, content: `

${game.i18n.localize("AreYouSure")}

${game.i18n.localize("FOLDER.DeleteWarning")}

`, yes: () => folder.delete({deleteSubfolders: true, deleteContents: true}), options: { top: Math.min(li.offsetTop, window.innerHeight - 350), left: window.innerWidth - 720, width: 400 } }); } } ]; } /* -------------------------------------------- */ /** * Get the set of ContextMenu options which should be used for Entries in a SidebarDirectory * @returns {object[]} The Array of context options passed to the ContextMenu instance * @protected */ _getEntryContextOptions() { return [ { name: "FOLDER.Clear", icon: '', condition: header => { const li = header.closest(".directory-item"); const entry = this.collection.get(li.data("entryId")); return game.user.isGM && !!entry.folder; }, callback: header => { const li = header.closest(".directory-item"); const entry = this.collection.get(li.data("entryId")); entry.update({folder: null}); } }, { name: "SIDEBAR.Delete", icon: '', condition: () => game.user.isGM, callback: header => { const li = header.closest(".directory-item"); const entry = this.collection.get(li.data("entryId")); if ( !entry ) return; return entry.deleteDialog({ top: Math.min(li[0].offsetTop, window.innerHeight - 350), left: window.innerWidth - 720 }); } }, { name: "SIDEBAR.Duplicate", icon: '', condition: () => game.user.isGM || this.collection.documentClass.canUserCreate(game.user), callback: header => { const li = header.closest(".directory-item"); const original = this.collection.get(li.data("entryId")); return original.clone({name: `${original._source.name} (Copy)`}, {save: true, addSource: true}); } } ]; } }; }