Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#4876] Add support for modifier keys during drag operations #4879

Open
wants to merge 1 commit into
base: 4.2.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
116 changes: 75 additions & 41 deletions module/applications/actor/sheet-mixin.mjs
Original file line number Diff line number Diff line change
@@ -1,51 +1,85 @@
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");

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