Initial
This commit is contained in:
560
resources/app/client/apps/sidebar/document-directory.js
Normal file
560
resources/app/client/apps/sidebar/document-directory.js
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user