diff --git a/dnd5e.mjs b/dnd5e.mjs index 17d1c31fc3..c721e9e37c 100644 --- a/dnd5e.mjs +++ b/dnd5e.mjs @@ -18,6 +18,7 @@ import * as canvas from "./module/canvas/_module.mjs"; import * as dataModels from "./module/data/_module.mjs"; import * as dice from "./module/dice/_module.mjs"; import * as documents from "./module/documents/_module.mjs"; +import DragDrop5e from "./module/drag-drop.mjs"; import * as enrichers from "./module/enrichers.mjs"; import * as Filter from "./module/filter.mjs"; import * as migrations from "./module/migration.mjs"; @@ -45,6 +46,8 @@ globalThis.dnd5e = { utils }; +DragDrop = DragDrop5e; + /* -------------------------------------------- */ /* Foundry VTT Initialization */ /* -------------------------------------------- */ diff --git a/module/applications/actor/base-sheet.mjs b/module/applications/actor/base-sheet.mjs index c624a23fd0..83a36fc8a8 100644 --- a/module/applications/actor/base-sheet.mjs +++ b/module/applications/actor/base-sheet.mjs @@ -27,6 +27,10 @@ import ToolsConfig from "./config/tools-config.mjs"; import TraitsConfig from "./config/traits-config.mjs"; import WeaponsConfig from "./config/weapons-config.mjs"; +/** + * @typedef {import("../../drag-drop.mjs").DropEffectValue} DropEffectValue + */ + /** * Extend the basic ActorSheet class to suppose system-specific logic and functionality. * @abstract @@ -930,16 +934,17 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { /** @override */ async _onDropItem(event, data) { - if ( !this.actor.isOwner ) return false; + const behavior = this._dropBehavior(event, data); + if ( !this.actor.isOwner || (behavior === "none") ) return false; const item = await Item.implementation.fromDropData(data); // Handle moving out of container & item sorting - if ( this.actor.uuid === item.parent?.uuid ) { - if ( item.system.container !== null ) await item.update({"system.container": null}); + if ( (behavior === "move") && (this.actor.uuid === item.parent?.uuid) ) { + if ( item.system.container !== null ) await item.update({ "system.container": null }); return this._onSortItem(event, item.toObject()); } - return this._onDropItemCreate(item, event); + return this._onDropItemCreate(item, event, behavior); } /* -------------------------------------------- */ @@ -962,10 +967,11 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { * Handle the final creation of dropped Item data on the Actor. * @param {Item5e[]|Item5e} itemData The item or items requested for creation. * @param {DragEvent} event The concluding DragEvent which provided the drop data. + * @param {DropEffectValue} behavior The specific drop behavior. * @returns {Promise} * @protected */ - async _onDropItemCreate(itemData, event) { + async _onDropItemCreate(itemData, event, behavior) { let items = itemData instanceof Array ? itemData : [itemData]; const itemsWithoutAdvancement = items.filter(i => !i.system.advancement?.length); const multipleAdvancements = (items.length - itemsWithoutAdvancement.length) > 1; @@ -982,7 +988,9 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { const toCreate = await Item5e.createWithContents(items, { transformFirst: item => this._onDropSingleItem(item.toObject(), event) }); - return Item5e.createDocuments(toCreate, {pack: this.actor.pack, parent: this.actor, keepId: true}); + const created = await Item5e.createDocuments(toCreate, { pack: this.actor.pack, parent: this.actor, keepId: true }); + if ( behavior === "move" ) items.forEach(i => fromUuid(i.uuid).then(d => d?.delete({ deleteContents: true }))); + return created; } /* -------------------------------------------- */ diff --git a/module/applications/actor/character-sheet-2.mjs b/module/applications/actor/character-sheet-2.mjs index 7f873368a8..5ced512527 100644 --- a/module/applications/actor/character-sheet-2.mjs +++ b/module/applications/actor/character-sheet-2.mjs @@ -446,6 +446,7 @@ export default class ActorSheet5eCharacter2 extends ActorSheetV2Mixin(ActorSheet if ( type === "slots" ) dragData.dnd5e.id = (preparationMode === "prepared") ? `spell${level}` : preparationMode; else dragData.dnd5e.id = key; event.dataTransfer.setData("application/json", JSON.stringify(dragData)); + event.dataTransfer.effectAllowed = "link"; } /* -------------------------------------------- */ @@ -613,6 +614,15 @@ export default class ActorSheet5eCharacter2 extends ActorSheetV2Mixin(ActorSheet /* Favorites */ /* -------------------------------------------- */ + /** @override */ + _defaultDropBehavior(event, data) { + if ( data.dnd5e?.action === "favorite" || (["Activity", "Item"].includes(data.type) + && event.target.closest(".favorites")) ) return "link"; + return super._defaultDropBehavior(event, data); + } + + /* -------------------------------------------- */ + /** @inheritDoc */ async _onDrop(event) { if ( !event.target.closest(".favorites") ) return super._onDrop(event); diff --git a/module/applications/actor/group-sheet.mjs b/module/applications/actor/group-sheet.mjs index 95eafd6277..b3026c3b3f 100644 --- a/module/applications/actor/group-sheet.mjs +++ b/module/applications/actor/group-sheet.mjs @@ -345,16 +345,17 @@ export default class GroupActorSheet extends ActorSheetMixin(ActorSheet) { /** @override */ async _onDropItem(event, data) { - if ( !this.actor.isOwner ) return false; + const behavior = this._dropBehavior(event, data); + if ( !this.actor.isOwner || (behavior === "none") ) return false; const item = await Item.implementation.fromDropData(data); // Handle moving out of container & item sorting - if ( this.actor.uuid === item.parent?.uuid ) { + if ( (behavior === "move") && (this.actor.uuid === item.parent?.uuid) ) { if ( item.system.container !== null ) await item.update({"system.container": null}); return this._onSortItem(event, item.toObject()); } - return this._onDropItemCreate(item, event); + return this._onDropItemCreate(item, event, behavior); } /* -------------------------------------------- */ @@ -374,7 +375,7 @@ export default class GroupActorSheet extends ActorSheetMixin(ActorSheet) { /* -------------------------------------------- */ /** @override */ - async _onDropItemCreate(itemData, event) { + async _onDropItemCreate(itemData, event, behavior) { let items = itemData instanceof Array ? itemData : [itemData]; // Filter out items already in containers to avoid creating duplicates @@ -385,7 +386,9 @@ export default class GroupActorSheet extends ActorSheetMixin(ActorSheet) { const toCreate = await Item5e.createWithContents(items, { transformFirst: item => this._onDropSingleItem(item.toObject(), event) }); - return Item5e.createDocuments(toCreate, {pack: this.actor.pack, parent: this.actor, keepId: true}); + const created = await Item5e.createDocuments(toCreate, { pack: this.actor.pack, parent: this.actor, keepId: true }); + if ( behavior === "move" ) items.forEach(i => fromUuid(i.uuid).then(d => d?.delete({ deleteContents: true }))); + return created; } /* -------------------------------------------- */ diff --git a/module/applications/actor/sheet-mixin.mjs b/module/applications/actor/sheet-mixin.mjs index 129f7755b1..319435737e 100644 --- a/module/applications/actor/sheet-mixin.mjs +++ b/module/applications/actor/sheet-mixin.mjs @@ -1,4 +1,5 @@ import { parseInputDelta } from "../../utils.mjs"; +import DragDropApplicationMixin from "../mixins/drag-drop-mixin.mjs"; /** * Mixin method for common uses between all actor sheets. @@ -6,46 +7,79 @@ import { parseInputDelta } from "../../utils.mjs"; * @returns {class} * @mixin */ -export default Base => class extends Base { - /** - * Handle input changes to numeric form fields, allowing them to accept delta-typed inputs. - * @param {Event} event Triggering event. - * @protected - */ - _onChangeInputDelta(event) { - const input = event.target; - const target = this.actor.items.get(input.closest("[data-item-id]")?.dataset.itemId) ?? this.actor; - const { activityId } = input.closest("[data-activity-id]")?.dataset ?? {}; - const activity = target?.system.activities?.get(activityId); - const result = parseInputDelta(input, activity ?? target); - if ( result !== undefined ) { - // Special case handling for Item uses. - if ( input.dataset.name === "system.uses.value" ) { - target.update({ "system.uses.spent": target.system.uses.max - result }); - } else if ( activity && (input.dataset.name === "uses.value") ) { - target.updateActivity(activityId, { "uses.spent": activity.uses.max - result }); +export default function ActorSheetMixin(Base) { + return class ActorSheet extends DragDropApplicationMixin(Base) { + + /** + * Handle input changes to numeric form fields, allowing them to accept delta-typed inputs. + * @param {Event} event Triggering event. + * @protected + */ + _onChangeInputDelta(event) { + const input = event.target; + const target = this.actor.items.get(input.closest("[data-item-id]")?.dataset.itemId) ?? this.actor; + const { activityId } = input.closest("[data-activity-id]")?.dataset ?? {}; + const activity = target?.system.activities?.get(activityId); + const result = parseInputDelta(input, activity ?? target); + if ( result !== undefined ) { + // Special case handling for Item uses. + if ( input.dataset.name === "system.uses.value" ) { + target.update({ "system.uses.spent": target.system.uses.max - result }); + } else if ( activity && (input.dataset.name === "uses.value") ) { + target.updateActivity(activityId, { "uses.spent": activity.uses.max - result }); + } + else target.update({ [input.dataset.name]: result }); } - else target.update({ [input.dataset.name]: result }); } - } - - /* -------------------------------------------- */ - - /** - * Stack identical consumables when a new one is dropped rather than creating a duplicate item. - * @param {object} itemData The item data requested for creation. - * @param {object} [options={}] - * @param {string} [options.container=null] ID of the container into which this item is being dropped. - * @returns {Promise|null} If a duplicate was found, returns the adjusted item stack. - */ - _onDropStackConsumables(itemData, { container=null }={}) { - const droppedSourceId = itemData._stats?.compendiumSource ?? itemData.flags.core?.sourceId; - if ( itemData.type !== "consumable" || !droppedSourceId ) return null; - const similarItem = this.actor.sourcedItems.get(droppedSourceId, { legacy: false }) - ?.filter(i => (i.system.container === container) && (i.name === itemData.name))?.first(); - if ( !similarItem ) return null; - return similarItem.update({ - "system.quantity": similarItem.system.quantity + Math.max(itemData.system.quantity, 1) - }); - } -}; + + /* -------------------------------------------- */ + + /** + * Stack identical consumables when a new one is dropped rather than creating a duplicate item. + * @param {object} itemData The item data requested for creation. + * @param {object} [options={}] + * @param {string} [options.container=null] ID of the container into which this item is being dropped. + * @returns {Promise|null} If a duplicate was found, returns the adjusted item stack. + */ + _onDropStackConsumables(itemData, { container=null }={}) { + const droppedSourceId = itemData._stats?.compendiumSource ?? itemData.flags.core?.sourceId; + if ( itemData.type !== "consumable" || !droppedSourceId ) return null; + const similarItem = this.actor.sourcedItems.get(droppedSourceId, { legacy: false }) + ?.filter(i => (i.system.container === container) && (i.name === itemData.name))?.first(); + if ( !similarItem ) return null; + return similarItem.update({ + "system.quantity": similarItem.system.quantity + Math.max(itemData.system.quantity, 1) + }); + } + + /* -------------------------------------------- */ + /* Drag & Drop */ + /* -------------------------------------------- */ + + /** @override */ + _allowedDropBehaviors(event, data) { + if ( !data.uuid ) return new Set(["copy"]); + const allowed = new Set(["copy", "move"]); + const s = foundry.utils.parseUuid(data.uuid); + const t = foundry.utils.parseUuid(this.document.uuid); + const sCompendium = s.collection instanceof CompendiumCollection; + const tCompendium = t.collection instanceof CompendiumCollection; + + // If either source or target are within a compendium, but not inside the same compendium, move not allowed + if ( (sCompendium || tCompendium) && (s.collection !== t.collection) ) allowed.delete("move"); + + return allowed; + } + + /* -------------------------------------------- */ + + /** @override */ + _defaultDropBehavior(event, data) { + if ( !data.uuid ) return "copy"; + const d = foundry.utils.parseUuid(data.uuid); + const t = foundry.utils.parseUuid(this.document.uuid); + return (d.collection === t.collection) && (d.documentId === t.documentId) && (d.documentType === t.documentType) + ? "move" : "copy"; + } + }; +} diff --git a/module/applications/item/item-directory.mjs b/module/applications/item/item-directory.mjs index e440283e97..6f42f269a2 100644 --- a/module/applications/item/item-directory.mjs +++ b/module/applications/item/item-directory.mjs @@ -1,21 +1,62 @@ import Item5e from "../../documents/item.mjs"; +import DragDropApplicationMixin from "../mixins/drag-drop-mixin.mjs"; /** * Items sidebar with added support for item containers. */ -export default class ItemDirectory5e extends ItemDirectory { +export default class ItemDirectory5e extends DragDropApplicationMixin(ItemDirectory) { + + /** @override */ + _allowedDropBehaviors(event, data) { + const allowed = new Set(["copy"]); + if ( !data.uuid ) return allowed; + const s = foundry.utils.parseUuid(data.uuid); + if ( !(s.collection instanceof CompendiumCollection) ) allowed.add("move"); + return allowed; + } + + /* -------------------------------------------- */ + + /** @override */ + _defaultDropBehavior(event, data) { + if ( !data.uuid ) return "copy"; + if ( data.type !== "Item" ) return "none"; + return foundry.utils.parseUuid(data.uuid).collection === this.collection ? "move" : "copy"; + } + + /* -------------------------------------------- */ + + /** @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, event); + } + } + + /* -------------------------------------------- */ + /** @inheritDoc */ - async _handleDroppedEntry(target, data) { + async _handleDroppedEntry(target, data, event) { // Obtain the dropped Document + const behavior = this._dropBehavior(event, data); let item = await this._getDroppedEntryFromData(data); - if ( !item ) return; + if ( (behavior === "none") || !item ) return; // Create item and its contents if it doesn't already exist here - if ( !this._entryAlreadyExists(item) ) { + if ( (behavior === "copy") || !this._entryAlreadyExists(item) ) { const toCreate = await Item5e.createWithContents([item]); const folder = target?.closest("[data-folder-id]")?.dataset.folderId; if ( folder ) toCreate.map(d => d.folder = folder); [item] = await Item5e.createDocuments(toCreate, {keepId: true}); + if ( behavior === "move" ) fromUuid(data.uuid).then(d => d?.delete({ deleteContents: true })); } // Otherwise, if it is within a container, take it out diff --git a/module/applications/mixins/drag-drop-mixin.mjs b/module/applications/mixins/drag-drop-mixin.mjs new file mode 100644 index 0000000000..15698fa761 --- /dev/null +++ b/module/applications/mixins/drag-drop-mixin.mjs @@ -0,0 +1,74 @@ +/** + * @typedef {import("../../drag-drop.mjs").DropEffectValue} DropEffectValue + */ + +/** + * Adds drop behavior functionality to all sheets. + * @param {typeof Application} Base The base class being mixed. + * @returns {typeof DragDropApplication} + */ +export default function DragDropApplicationMixin(Base) { + return class DragDropApplication extends Base { + /** @override */ + _onDragOver(event) { + const data = DragDrop.getPayload(event); + if ( foundry.utils.getType(data) === "Object" ) { + event.dataTransfer.dropEffect = this._dropBehavior(event, data); + } else { + event.dataTransfer.dropEffect = "copy"; + } + } + + /* -------------------------------------------- */ + + /** + * The behavior for the dropped data. + * @param {DragEvent} event The drag event. + * @param {object} data The drag payload. + * @returns {DropEffectValue} + */ + _dropBehavior(event, data) { + const allowed = this._allowedDropBehaviors(event, data); + let behavior = event.dataTransfer.dropEffect; + + if ( event.type === "dragover" ) { + behavior = this._defaultDropBehavior(event, data); + + // An initial `dropEffect` of `copy` indicates that a modifier key is held down + if ( (event.type === "dragover") && (event.dataTransfer.dropEffect === "copy") ) { + if ( (behavior === "copy") && allowed.has("move") ) return "move"; + else if ( (behavior === "move") && allowed.has("copy") ) return "copy"; + } + } + + if ( (behavior !== "none") && !allowed.has(behavior) ) return allowed.first() ?? "none"; + return behavior || "copy"; + } + + /* -------------------------------------------- */ + + /** + * Types of allowed drop behaviors based on the origin & target of a drag event. + * @param {DragEvent} event The drag event. + * @param {object} data The drag payload. + * @returns {Set} + * @protected + */ + _allowedDropBehaviors(event, data) { + return new Set(); + } + + /* -------------------------------------------- */ + + /** + * Determine the default drop behavior for the provided operation. + * @param {DragEvent} event The drag event. + * @param {object} data The drag payload. + * @returns {DropEffectValue} + * @protected + */ + _defaultDropBehavior(event, data) { + return "copy"; + } + }; +} diff --git a/module/applications/mixins/sheet-v2-mixin.mjs b/module/applications/mixins/sheet-v2-mixin.mjs index f47f276b50..0b3d18c257 100644 --- a/module/applications/mixins/sheet-v2-mixin.mjs +++ b/module/applications/mixins/sheet-v2-mixin.mjs @@ -1,10 +1,12 @@ +import DragDropApplicationMixin from "./drag-drop-mixin.mjs"; + /** * Adds common V2 sheet functionality. * @param {typeof DocumentSheet} Base The base class being mixed. * @returns {typeof DocumentSheetV2} */ export default function DocumentSheetV2Mixin(Base) { - return class DocumentSheetV2 extends Base { + return class DocumentSheetV2 extends DragDropApplicationMixin(Base) { /** * @typedef {object} SheetTabDescriptor5e * @property {string} tab The tab key. @@ -263,7 +265,7 @@ export default function DocumentSheetV2Mixin(Base) { const content = await renderTemplate("systems/dnd5e/templates/items/parts/item-summary.hbs", context); summary.querySelectorAll(".item-summary").forEach(el => el.remove()); summary.insertAdjacentHTML("beforeend", content); - await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => { requestAnimationFrame(resolve); }); this._expanded.add(item.id); } @@ -271,5 +273,43 @@ export default function DocumentSheetV2Mixin(Base) { icon.classList.toggle("fa-compress", !expanded); icon.classList.toggle("fa-expand", expanded); } + + /* -------------------------------------------- */ + /* Drag & Drop */ + /* -------------------------------------------- */ + + /** @override */ + _allowedDropBehaviors(event, data) { + if ( !data.uuid ) return new Set(["copy", "link"]); + const allowed = new Set(["copy", "move", "link"]); + const s = foundry.utils.parseUuid(data.uuid); + const t = foundry.utils.parseUuid(this.document.uuid); + const sCompendium = s.collection instanceof CompendiumCollection; + const tCompendium = t.collection instanceof CompendiumCollection; + + // If either source or target are within a compendium, but not inside the same compendium, move not allowed + if ( (sCompendium || tCompendium) && (s.collection !== t.collection) ) allowed.delete("move"); + + return allowed; + } + + /* -------------------------------------------- */ + + /** @override */ + _defaultDropBehavior(event, data) { + if ( !data.uuid ) return "copy"; + const d = foundry.utils.parseUuid(data.uuid); + const t = foundry.utils.parseUuid(this.document.uuid); + return (d.collection === t.collection) && (d.documentId === t.documentId) && (d.documentType === t.documentType) + ? "move" : "copy"; + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + async _onDragStart(event) { + await super._onDragStart(event); + if ( !this.document.isOwner || this.document.compendium?.locked ) event.dataTransfer.effectAllowed = "copyLink"; + } }; } diff --git a/module/drag-drop.mjs b/module/drag-drop.mjs new file mode 100644 index 0000000000..491cc9486d --- /dev/null +++ b/module/drag-drop.mjs @@ -0,0 +1,76 @@ +/** + * Valid `dropEffect` value (see https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/dropEffect). + * @typedef {"copy"|"move"|"link"|"none"} DropEffectValue + */ + +/** + * Extension of core's DragDrop class to provide additional information used by the system. Will replace core's + * version in the global namespace. + */ +export default class DragDrop5e extends DragDrop { + + /** + * Stored drag event payload. + * @type {{ data: any, event: DragEvent }|null} + */ + static #payload = null; + + /* -------------------------------------------- */ + + /** @inheritDoc */ + bind(html) { + super.bind(html); + + // Add dragend event handler to the existing dragstart event + if ( this.can("dragstart", this.dragSelector) ) { + const draggables = html.querySelectorAll(this.dragSelector); + for ( const el of draggables ) { + el.ondragend = this._handleDragEnd.bind(this); + } + } + + return this; + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + async _handleDragStart(event) { + await this.callback(event, "dragstart"); + if ( event.dataTransfer.items.length ) { + event.stopPropagation(); + const data = event.dataTransfer.getData("application/json") || event.dataTransfer.getData("text/plain"); + DragDrop5e.#payload = data ? { event, data } : null; + } else { + DragDrop5e.#payload = null; + } + } + + /* -------------------------------------------- */ + + /** + * Handle the end of a drag workflow + * @param {DragEvent} event The drag event being handled + * @private + */ + async _handleDragEnd(event) { + await this.callback(event, "dragend"); + DragDrop5e.#payload = null; + } + + /* -------------------------------------------- */ + + /** + * Get the data payload for the current drag event. + * @param {DragEvent} event + * @returns {object|string|null} + */ + static getPayload(event) { + if ( !DragDrop5e.#payload?.data ) return null; + try { + return JSON.parse(DragDrop5e.#payload.data); + } catch(err) { + return DragDrop5e.#payload.data; + } + } +} diff --git a/templates/shared/inventory2.hbs b/templates/shared/inventory2.hbs index f9ec8e08c6..9473ca5dbd 100644 --- a/templates/shared/inventory2.hbs +++ b/templates/shared/inventory2.hbs @@ -54,7 +54,7 @@ style="--bar-percentage: {{ pct }}%"> {{/with}} {{/dnd5e-itemContext}} - {{ name }} + {{ name }} {{/each}}