define([ 'dojo/_base/declare', 'dojo/_base/lang', 'dojo/_base/array', 'dojo/aspect', 'dojo/dom-class', 'dojo/on', 'dojo/topic', 'dojo/has', 'dojo/when', 'dojo/dnd/Source', 'dojo/dnd/Manager', 'dojo/_base/NodeList', '../Selection', 'dojo/has!touch?../util/touch' ], function (declare, lang, arrayUtil, aspect, domClass, on, topic, has, when, DnDSource, DnDManager, NodeList, Selection, touchUtil) { // Requirements // * requires a store (sounds obvious, but not all Lists/Grids have stores...) // * must support options.before in put calls // (if undefined, put at end) // * should support copy // (copy should also support options.before as above) // TODOs // * consider sending items rather than nodes to onDropExternal/Internal var GridDnDSource = declare(DnDSource, { grid: null, getObject: function (node) { // summary: // getObject is a method which should be defined on any source intending // on interfacing with dgrid DnD. var grid = this.grid; // Extract item id from row node id (gridID-row-*). return grid._trackError(function () { return grid.collection.get(node.id.slice(grid.id.length + 5)); }); }, _legalMouseDown: function (evt) { // Fix _legalMouseDown to only allow starting drag from an item // (not from bodyNode outside contentNode). var legal = this.inherited(arguments); return legal && evt.target !== this.grid.bodyNode; }, // DnD method overrides onDrop: function (sourceSource, nodes, copy) { var targetSource = this, targetRow = this._targetAnchor = this.targetAnchor, // save for Internal grid = this.grid, store = grid.collection; if (!this.before && targetRow) { // target before next node if dropped within bottom half of this node // (unless there's no node to target at all) targetRow = targetRow.nextSibling; } targetRow = targetRow && grid.row(targetRow); when(targetRow && store.get(targetRow.id), function (target) { // Note: if dropping after the last row, or into an empty grid, // target will be undefined. Thus, it is important for store to place // item last in order if options.before is undefined. // Delegate to onDropInternal or onDropExternal for rest of logic. // These are passed the target item as an additional argument. if (targetSource !== sourceSource) { targetSource.onDropExternal(sourceSource, nodes, copy, target); } else { targetSource.onDropInternal(nodes, copy, target); } }); }, onDropInternal: function (nodes, copy, targetItem) { var grid = this.grid, store = grid.collection, targetSource = this, anchor = targetSource._targetAnchor, targetRow, nodeRow; if (anchor) { // (falsy if drop occurred in empty space after rows) targetRow = this.before ? anchor.previousSibling : anchor.nextSibling; } // Don't bother continuing if the drop is really not moving anything. // (Don't need to worry about edge first/last cases since dropping // directly on self doesn't fire onDrop, but we do have to worry about // dropping last node into empty space beyond rendered rows.) nodeRow = grid.row(nodes[0]); if (!copy && (targetRow === nodes[0] || (!targetItem && nodeRow && grid.down(nodeRow).element === nodes[0]))) { return; } nodes.forEach(function (node) { when(targetSource.getObject(node), function (object) { var id = store.getIdentity(object); // For copy DnD operations, copy object, if supported by store; // otherwise settle for put anyway. // (put will relocate an existing item with the same id, i.e. move). grid._trackError(function () { // Do no store operation if the object being moved is targetItem. This can happen when // multiple, non-adjacent rows are being dragged. var objectId = store.getIdentity(object); var targetId = targetItem ? store.getIdentity(targetItem) : null; var promise = objectId === targetId ? targetSource._getNextItem(targetItem) .then(function (nextItem) { targetItem = nextItem; }) : store[copy && store.copy ? 'copy' : 'put'](object, { beforeId: targetId }); return promise.then(function () { // Self-drops won't cause the dgrid-select handler to re-fire, // so update the cached node manually if (targetSource._selectedNodes[id]) { targetSource._selectedNodes[id] = grid.row(id).element; } }); }); }); }); }, _getNextItem: function (item) { var grid = this.grid; if (item) { var row = grid.row(item); if (row.element) { row = grid.down(row); if (row.element) { return when(row.data); } } } return when(null); }, onDropExternal: function (sourceSource, nodes, copy, targetItem) { // Note: this default implementation expects that two grids do not // share the same store. There may be more ideal implementations in the // case of two grids using the same store (perhaps differentiated by // query), dragging to each other. var grid = this.grid, store = this.grid.collection, sourceGrid = sourceSource.grid; // TODO: bail out if sourceSource.getObject isn't defined? nodes.forEach(function (node, i) { when(sourceSource.getObject(node), function (object) { // Copy object, if supported by store; otherwise settle for put // (put will relocate an existing item with the same id). // Note that we use store.copy if available even for non-copy dnd: // since this coming from another dnd source, always behave as if // it is a new store item if possible, rather than replacing existing. grid._trackError(function () { return store[store.copy ? 'copy' : 'put'](object, { beforeId: targetItem ? store.getIdentity(targetItem) : null }).then(function () { if (!copy) { if (sourceGrid) { // Remove original in the case of inter-grid move. // (Also ensure dnd source is cleaned up properly) var id = sourceGrid.collection.getIdentity(object); !i && sourceSource.selectNone(); // Deselect all, one time sourceSource.delItem(node.id); return sourceGrid.collection.remove(id); } else { sourceSource.deleteSelectedNodes(); } } }); }); }); }); }, onDndStart: function (source) { // Listen for start events to apply style change to avatar. this.inherited(arguments); // DnDSource.prototype.onDndStart.apply(this, arguments); if (source === this) { // Set avatar width to half the grid's width. // Kind of a naive default, but prevents ridiculously wide avatars. DnDManager.manager().avatar.node.style.width = this.grid.domNode.offsetWidth / 2 + 'px'; } }, onMouseDown: function (evt) { // Cancel the drag operation on presence of more than one contact point. // (This check will evaluate to false under non-touch circumstances.) if (has('touch') && this.isDragging && touchUtil.countCurrentTouches(evt, this.grid.touchNode) > 1) { topic.publish('/dnd/cancel'); DnDManager.manager().stopDrag(); } else { this.inherited(arguments); } }, onMouseMove: function (evt) { // If we're handling touchmove, only respond to single-contact events. if (!has('touch') || touchUtil.countCurrentTouches(evt, this.grid.touchNode) <= 1) { this.inherited(arguments); } }, checkAcceptance: function (source) { // Augment checkAcceptance to block drops from sources without getObject. return source.getObject && DnDSource.prototype.checkAcceptance.apply(this, arguments); }, getSelectedNodes: function () { // If dgrid's Selection mixin is in use, synchronize with it, using a // map of node references (updated on dgrid-[de]select events). if (!this.grid.selection) { return this.inherited(arguments); } var t = new NodeList(), id; for (id in this.grid.selection) { t.push(this._selectedNodes[id]); } return t; // NodeList } // TODO: could potentially also implement copyState to jive with default // onDrop* implementations (checking whether store.copy is available); // not doing that just yet until we're sure about default impl. }); // Mix in Selection for more resilient dnd handling, particularly when part // of the selection is scrolled out of view and unrendered (which we // handle below). var DnD = declare(Selection, { // dndSourceType: String // Specifies the type which will be set for DnD items in the grid, // as well as what will be accepted by it by default. dndSourceType: 'dgrid-row', // dndParams: Object // Object containing params to be passed to the DnD Source constructor. dndParams: null, // dndConstructor: Function // Constructor from which to instantiate the DnD Source. // Defaults to the GridSource constructor defined/exposed by this module. dndConstructor: GridDnDSource, postMixInProperties: function () { this.inherited(arguments); // ensure dndParams is initialized this.dndParams = lang.mixin({ accept: [this.dndSourceType] }, this.dndParams); }, postCreate: function () { this.inherited(arguments); // Make the grid's content a DnD source/target. var Source = this.dndConstructor || GridDnDSource; var dndParams = lang.mixin(this.dndParams, { // add cross-reference to grid for potential use in inter-grid drop logic grid: this, dropParent: this.contentNode }); if (typeof this.expand === 'function') { // If the Tree mixin is being used, allowNested needs to be set to true for DnD to work properly // with the child rows. Without it, child rows will always move to the last child position. dndParams.allowNested = true; } this.dndSource = new Source(this.bodyNode, dndParams); // Set up select/deselect handlers to maintain references, in case selected // rows are scrolled out of view and unrendered, but then dragged. var selectedNodes = this.dndSource._selectedNodes = {}; function selectRow(row) { selectedNodes[row.id] = row.element; } function deselectRow(row) { delete selectedNodes[row.id]; // Re-sync dojo/dnd UI classes based on deselection // (unfortunately there is no good programmatic hook for this) domClass.remove(row.element, 'dojoDndItemSelected dojoDndItemAnchor'); } this.on('dgrid-select', function (event) { arrayUtil.forEach(event.rows, selectRow); }); this.on('dgrid-deselect', function (event) { arrayUtil.forEach(event.rows, deselectRow); }); aspect.after(this, 'destroy', function () { delete this.dndSource._selectedNodes; selectedNodes = null; this.dndSource.destroy(); }, true); }, insertRow: function (object) { // override to add dojoDndItem class to make the rows draggable var row = this.inherited(arguments), type = typeof this.getObjectDndType === 'function' ? this.getObjectDndType(object) : [this.dndSourceType]; domClass.add(row, 'dojoDndItem'); this.dndSource.setItem(row.id, { data: object, type: type instanceof Array ? type : [type] }); if (this.selection) { var objectId = this.collection.getIdentity(object); if (objectId in this.selection) { this.dndSource._selectedNodes[objectId] = row; } } return row; }, removeRow: function (rowElement) { var row = this.row(rowElement); if (this.selection && (row.id in this.selection)) { delete this.dndSource._selectedNodes[row.id]; } this.dndSource.delItem(row.element.id); this.inherited(arguments); } }); DnD.GridSource = GridDnDSource; return DnD; });