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