Skip to content

Commit

Permalink
[#4876] Add support for modifier keys during drag operations
Browse files Browse the repository at this point in the history
Adds the ability to use the OS-defined modifier key (usually
Alt on Windows and Option on Mac) to toggle between the default
drop behavior and the opposite behavior. So if dragging within
the same actor this changes from the default move behaior to
copy behavior and the opposite when dragging between different
actors or to the sidebar.

Enabling this required access to the current drag payload during
the `ondragover` event, so this extends the `DragDrop` class
provided by core to store that information during the `ondragstart`
event and adds a new handler for the `ondragend` event to clear
the stored payload.

Currently this only covers dragging items, with some minor
improvements to dragging favorites on the character sheet.
This framework could be expanded in the future to support
dragging actors into the bastion tab as well as dragging active
effects, advancements, or activities.

Closes #4876
  • Loading branch information
arbron committed Dec 14, 2024
1 parent 899639b commit 6847a29
Show file tree
Hide file tree
Showing 10 changed files with 352 additions and 59 deletions.
3 changes: 3 additions & 0 deletions dnd5e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,6 +46,8 @@ globalThis.dnd5e = {
utils
};

DragDrop = DragDrop5e;

/* -------------------------------------------- */
/* Foundry VTT Initialization */
/* -------------------------------------------- */
Expand Down
20 changes: 14 additions & 6 deletions module/applications/actor/base-sheet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

/* -------------------------------------------- */
Expand All @@ -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<Item5e[]>}
* @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;
Expand All @@ -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;
}

/* -------------------------------------------- */
Expand Down
10 changes: 10 additions & 0 deletions module/applications/actor/character-sheet-2.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

/* -------------------------------------------- */
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 8 additions & 5 deletions module/applications/actor/group-sheet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/* -------------------------------------------- */
Expand All @@ -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
Expand All @@ -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;
}

/* -------------------------------------------- */
Expand Down
120 changes: 79 additions & 41 deletions module/applications/actor/sheet-mixin.mjs
Original file line number Diff line number Diff line change
@@ -1,51 +1,89 @@
import { parseInputDelta } from "../../utils.mjs";
import DragDropApplicationMixin from "../mixins/drag-drop-mixin.mjs";

/**
* Mixin method for common uses between all actor sheets.
* @param {typeof Application} Base Application class being extended.
* @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<Item5e>|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<Item5e>|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");

// TODO: Restrict move operations if user doesn't have permission to delete from source
// - If dragging from items sidebar but doesn't have permission to delete world items
// - If dragging from an actor/item with observer permission, but not owner

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";
}
};
}
49 changes: 45 additions & 4 deletions module/applications/item/item-directory.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading

0 comments on commit 6847a29

Please sign in to comment.