From 270dde46c176662484fbeea330951e3a403a4e56 Mon Sep 17 00:00:00 2001 From: Alain Dumesny Date: Mon, 15 Feb 2021 10:17:40 -0800 Subject: [PATCH] collision overall - support for swap big overall of the collision dectection code - you can now: * swap items of same size vertically/horizontally when grid is full (maxRow) ***** this has been 5.5years in the making and the most asked question **** It is now also the default in float:false as it feels more natural than pushing new row (could add Alt-key to get either behavior of push vs swap ?) * moving items down or up behave the same way (used to have to push WAY past to insert after) * optimized the collision code to not do extra work multiple times and only check if change and not tried before * heuristics now checks for >50% past the grid mid point (taking margin into account)a TODO part II: handle mid point of dragged over items rather 50% of row/column and check for the most covered when multiple items collide. * fix #149 #1605 and partial #1094 (need better support for larger items mid point) --- doc/CHANGES.md | 3 + spec/e2e/html/141_1534_swap.html | 92 +++++++++ spec/e2e/html/141_swap_old.html | 63 ++++++ spec/gridstack-engine-spec.ts | 28 +-- src/gridstack-dd.ts | 77 +++---- src/gridstack-engine.ts | 345 +++++++++++++++++-------------- src/gridstack.ts | 14 +- src/types.ts | 46 ++--- src/utils.ts | 8 +- 9 files changed, 433 insertions(+), 243 deletions(-) create mode 100644 spec/e2e/html/141_1534_swap.html create mode 100644 spec/e2e/html/141_swap_old.html diff --git a/doc/CHANGES.md b/doc/CHANGES.md index 7519520a0..d2516c89d 100644 --- a/doc/CHANGES.md +++ b/doc/CHANGES.md @@ -50,6 +50,9 @@ Change log ## 3.3.0-dev +- fix [#149](https://github.com/gridstack/gridstack.js/issues/149) [#1094](https://github.com/gridstack/gridstack.js/issues/1094) [#1605](https://github.com/gridstack/gridstack.js/issues/1605) re-write of the **collision code**! you can now swap items of the same size (vertical/horizontal) when grid is full, and is the default in `float:false` (top gravity) as it feels more natural. Could add Alt key for swap vs push behavior later.
+Dragging up and down now behave the same (used to require push WAY down past to swap/append). Also much more efficient collision code.
+Still TODO: handle mid point of dragged over items rather 50% of row/column and check for the most covered when multiple items collide. - fix [1617](https://github.com/gridstack/gridstack.js/issues/1617) FireFox DOM order issue. Thanks [@marcel-necker](https://github.com/marcel-necker) ## 3.3.0 (2021-2-2) diff --git a/spec/e2e/html/141_1534_swap.html b/spec/e2e/html/141_1534_swap.html new file mode 100644 index 000000000..63412c8eb --- /dev/null +++ b/spec/e2e/html/141_1534_swap.html @@ -0,0 +1,92 @@ + + + + + + + swap demo + + + + + + +
+

Swap collision demo

+
+ Add Widget + + + +
+

+
+
+ + + + diff --git a/spec/e2e/html/141_swap_old.html b/spec/e2e/html/141_swap_old.html new file mode 100644 index 000000000..8d6f6df01 --- /dev/null +++ b/spec/e2e/html/141_swap_old.html @@ -0,0 +1,63 @@ + + + + + + + old swap + + + + + + + +
+

Swap collision demo from older 1.x builds

+
+ Add Widget + + +
+

+
+
0
+
1
+
2
+
3
+
4
+
5
+
+
+ + + diff --git a/spec/gridstack-engine-spec.ts b/spec/gridstack-engine-spec.ts index b5284c623..1066c5195 100644 --- a/spec/gridstack-engine-spec.ts +++ b/spec/gridstack-engine-spec.ts @@ -45,10 +45,10 @@ describe('gridstack engine', function() { describe('batch update', function() { it('should set float and batchMode when calling batchUpdate.', function() { - // Note: legacy weird call on global window to hold data - e.prototype.batchUpdate.call(w); - expect(w.float).toBe(undefined); - expect(w.batchMode).toBeTrue(); + engine = new GridStackEngine({float: true}); + engine.batchUpdate(); + expect(engine.float).toBe(true); + expect(engine.batchMode).toBeTrue(); }); }); @@ -327,29 +327,29 @@ describe('gridstack engine', function() { }); }); - describe('test isNodeChangedPosition', function() { + describe('test changedPos', function() { beforeAll(function() { engine = new GridStackEngine(); }); it('should return true for changed x', function() { let widget = { x: 1, y: 2, w: 3, h: 4 }; - expect(engine.isNodeChangedPosition(widget, 2, 2)).toEqual(true); + expect(engine.changedPos(widget, 2, 2)).toEqual(true); }); it('should return true for changed y', function() { let widget = { x: 1, y: 2, w: 3, h: 4 }; - expect(engine.isNodeChangedPosition(widget, 1, 1)).toEqual(true); + expect(engine.changedPos(widget, 1, 1)).toEqual(true); }); it('should return true for changed width', function() { let widget = { x: 1, y: 2, w: 3, h: 4 }; - expect(engine.isNodeChangedPosition(widget, 2, 2, 4, 4)).toEqual(true); + expect(engine.changedPos(widget, 2, 2, 4, 4)).toEqual(true); }); it('should return true for changed height', function() { let widget = { x: 1, y: 2, w: 3, h: 4 }; - expect(engine.isNodeChangedPosition(widget, 1, 2, 3, 3)).toEqual(true); + expect(engine.changedPos(widget, 1, 2, 3, 3)).toEqual(true); }); it('should return false for unchanged position', function() { let widget = { x: 1, y: 2, w: 3, h: 4 }; - expect(engine.isNodeChangedPosition(widget, 1, 2, 3, 4)).toEqual(false); + expect(engine.changedPos(widget, 1, 2, 3, 4)).toEqual(false); }); }); @@ -371,13 +371,15 @@ describe('gridstack engine', function() { expect(findNode(engine, 2)).toEqual(jasmine.objectContaining({x: 1, y: 2})); // prevents moving locked item let node1 = findNode(engine, 1); - expect(engine.moveNode(node1, 6, 6)).toEqual(null); + expect(engine.moveNode(node1, 6, 6)).toEqual(false); // but moves regular one (gravity ON) let node2 = findNode(engine, 2); - expect(engine.moveNode(node2, 6, 6)).toEqual(jasmine.objectContaining({x: 6, y: 2, w: 2, h: 3,})); + expect(engine.moveNode(node2, 6, 6)).toEqual(true); + expect(node2).toEqual(jasmine.objectContaining({x: 6, y: 2, w: 2, h: 3})); // but moves regular one (gravity OFF) engine.float = true; - expect(engine.moveNode(node2, 7, 6)).toEqual(jasmine.objectContaining({x: 7, y: 6, w: 2, h: 3,})); + expect(engine.moveNode(node2, 7, 6)).toEqual(true); + expect(node2).toEqual(jasmine.objectContaining({x: 7, y: 6, w: 2, h: 3})); }); }); diff --git a/src/gridstack-dd.ts b/src/gridstack-dd.ts index 092807b5e..bfbf7d1b5 100644 --- a/src/gridstack-dd.ts +++ b/src/gridstack-dd.ts @@ -75,7 +75,7 @@ export abstract class GridStackDD extends GridStackDDI { * https://www.typescriptlang.org/docs/handbook/mixins.html ********************************************************************************/ -/** @internal called to add drag over support to support widgets */ +/** @internal called to add drag over to support widgets being added externally */ GridStack.prototype._setupAcceptWidget = function(): GridStack { if (this.opts.staticGrid) return this; @@ -106,19 +106,16 @@ GridStack.prototype._setupAcceptWidget = function(): GridStack { node._added = true; node.el = el; - this.engine.cleanNodes(); - this.engine.beginUpdate(node); - this.engine.addNode(node); + this.engine.cleanNodes() + .beginUpdate(node) + .addNode(node); this._writePosAttr(this.placeholder, node.x, node.y, node.w, node.h); this.el.appendChild(this.placeholder); node.el = this.placeholder; // dom we update while dragging... - node._beforeDragX = node.x; - node._beforeDragY = node.y; this._updateContainerHeight(); - } else if ((x !== node.x || y !== node.y) && this.engine.canMoveNode(node, x, y)) { - this.engine.moveNode(node, x, y); + } else if (this.engine.moveNodeCheck(node, x, y)) { this._updateContainerHeight(); } }; @@ -229,7 +226,7 @@ GridStack.prototype._setupAcceptWidget = function(): GridStack { } GridStackDD.get().off(el, 'drag'); // if we made a copy ('helper' which is temp) of the original node then insert a copy, else we move the original node (#1102) - // as the helper will be nuked by jqueryui otherwise + // as the helper will be nuked by jquery-ui otherwise if (helper !== el) { helper.remove(); el.gridstackNode = origNode; // original item (left behind) is re-stored to pre dragging as the node now has drop info @@ -273,7 +270,7 @@ GridStack.prototype._setupRemoveDrop = function(): GridStack { if (!this.opts.staticGrid && typeof this.opts.removable === 'string') { let trashEl = document.querySelector(this.opts.removable) as HTMLElement; if (!trashEl) return this; - // only register ONE dropover/dropout callback for the 'trash', and it will + // only register ONE drop-over/dropout callback for the 'trash', and it will // update the passed in item and parent grid because the 'trash' is a shared resource anyway, // and Native DD only has 1 event CB (having a list and technically a per grid removableOptions complicates things greatly) if (!GridStackDD.get().isDroppable(trashEl)) { @@ -369,30 +366,35 @@ GridStack.prototype._prepareDragDropByNode = function(node: GridStackNode): Grid this._gsEventHandler[event.type](event, target); } - this.engine.cleanNodes(); - this.engine.beginUpdate(node); + this.engine.cleanNodes() + .beginUpdate(node); this._writePosAttr(this.placeholder, node.x, node.y, node.w, node.h) this.el.append(this.placeholder); node.el = this.placeholder; - node._beforeDragX = node.x; - node._beforeDragY = node.y; + node._lastUiPosition = ui.position; node._prevYPix = ui.position.top; + node._moving = (event.type === 'dragstart'); + delete node._lastTried; // set the min/max resize info cellWidth = this.cellWidth(); cellHeight = this.getCellHeight(true); // force pixels for calculations - GridStackDD.get().resizable(el, 'option', 'minWidth', cellWidth * (node.minW || 1)); - GridStackDD.get().resizable(el, 'option', 'minHeight', cellHeight * (node.minH || 1)); - if (node.maxW) { GridStackDD.get().resizable(el, 'option', 'maxWidth', cellWidth * node.maxW); } - if (node.maxH) { GridStackDD.get().resizable(el, 'option', 'maxHeight', cellHeight * node.maxH); } + let dd = GridStackDD.get() + .resizable(el, 'option', 'minWidth', cellWidth * (node.minW || 1)) + .resizable(el, 'option', 'minHeight', cellHeight * (node.minH || 1)); + if (node.maxW) { dd.resizable(el, 'option', 'maxWidth', cellWidth * node.maxW); } + if (node.maxH) { dd.resizable(el, 'option', 'maxHeight', cellHeight * node.maxH); } } /** called when item is being dragged/resized */ let dragOrResize = (event: Event, ui: DDUIData): void => { - let x = Math.round(ui.position.left / cellWidth); - let y = Math.round(ui.position.top / cellHeight); + // calculate the place where we're landing by offsetting margin so actual edge crosses mid point + let left = ui.position.left + (ui.position.left > node._lastUiPosition.left ? -this.opts.marginRight : this.opts.marginLeft); + let top = ui.position.top + (ui.position.top > node._lastUiPosition.top ? -this.opts.marginBottom : this.opts.marginTop); + let x = Math.round(left / cellWidth); + let y = Math.round(top / cellHeight); let w: number; let h: number; let resizing: boolean; @@ -408,8 +410,8 @@ GridStack.prototype._prepareDragDropByNode = function(node: GridStackNode): Grid this._setupRemovingTimeout(el); } - x = node._beforeDragX; - y = node._beforeDragY; + x = node._beforeDrag.x; + y = node._beforeDrag.y; if (this.placeholder.parentNode === this.el) { this.placeholder.remove(); @@ -420,7 +422,7 @@ GridStack.prototype._prepareDragDropByNode = function(node: GridStackNode): Grid node._temporaryRemoved = true; delete node._added; // no need for this now } else { - this._clearRemovingTimeout(el); + if (node._removeTimeout) this._clearRemovingTimeout(el); if (node._temporaryRemoved) { this.engine.addNode(node); @@ -430,25 +432,26 @@ GridStack.prototype._prepareDragDropByNode = function(node: GridStackNode): Grid delete node._temporaryRemoved; } } - if (node._lastTriedX === x && node._lastTriedY === y) return; + if (node.x === x && node.y === y) return; // skip same + if (node._lastTried && node._lastTried.x === x && node._lastTried.y === y) return; // skip one we tried (but failed) } else if (event.type === 'resize') { if (x < 0) return; // Scrolling page if needed Utils.updateScrollResize(event as MouseEvent, el, cellHeight); w = Math.round(ui.size.width / cellWidth); h = Math.round(ui.size.height / cellHeight); - if (w === node.w && h === node.h) return; + if (node.w === w && node.h === h) return; + if (node._lastTried && node._lastTried.w === w && node._lastTried.h === h) return; // skip one we tried (but failed) resizing = true; } - if (!this.engine.canMoveNode(node, x, y, w, h)) return; - node._lastTriedX = x; - node._lastTriedY = y; - node._lastTriedW = w; - node._lastTriedH = h; - this.engine.moveNode(node, x, y, w, h); - if (resizing && node.subGrid) { (node.subGrid as GridStack).onParentResize(); } - this._updateContainerHeight(); + node._lastTried = {x, y, w, h}; // set as last tried (will nuke if we go there) + if (this.engine.moveNodeCheck(node, x, y, w, h)) { + node._lastUiPosition = ui.position; + delete node._skipDown; + if (resizing && node.subGrid) { (node.subGrid as GridStack).onParentResize(); } + this._updateContainerHeight(); + } } /** called when the item stops moving/resizing */ @@ -456,6 +459,8 @@ GridStack.prototype._prepareDragDropByNode = function(node: GridStackNode): Grid if (this.placeholder.parentNode === this.el) { this.placeholder.remove(); } + delete node._moving; + delete node._lastTried; // if the item has moved to another grid, we're done here let target: GridItemHTMLElement = event.target as GridItemHTMLElement; @@ -482,9 +487,9 @@ GridStack.prototype._prepareDragDropByNode = function(node: GridStackNode): Grid this._writePosAttr(target, node.x, node.y, node.w, node.h); } else { Utils.removePositioningStyles(target); - this._writePosAttr(target, node._beforeDragX, node._beforeDragY, node.w, node.h); - node.x = node._beforeDragX; - node.y = node._beforeDragY; + this._writePosAttr(target, node._beforeDrag.x, node._beforeDrag.y, node.w, node.h); + node.x = node._beforeDrag.x; + node.y = node._beforeDrag.y; delete node._temporaryRemoved; this.engine.addNode(node); } diff --git a/src/gridstack-engine.ts b/src/gridstack-engine.ts index 07fcc5791..aea65ffda 100644 --- a/src/gridstack-engine.ts +++ b/src/gridstack-engine.ts @@ -7,7 +7,7 @@ */ import { Utils, obsolete } from './utils'; -import { GridStackNode, ColumnOptions, GridStackWidget } from './types'; +import { GridStackNode, ColumnOptions } from './types'; export type onChangeCB = (nodes: GridStackNode[], removeDOM?: boolean) => void; @@ -66,34 +66,47 @@ export class GridStackEngine { this.batchMode = false; this._float = this._prevFloat; delete this._prevFloat; - this._packNodes(); - this._notify(); - return this; + return this._packNodes() + ._notify(); } - /** @internal */ - private _fixCollisions(node: GridStackNode): GridStackEngine { - this._sortNodes(-1); + /** @internal fix collision on given 'node', going to given new location 'nn', with optional 'collide' node already found. + * return true if we moved. */ + private _fixCollisions(node: GridStackNode, nn = node, collide?: GridStackNode, disableSwap?: boolean): boolean { + // this._sortNodes(-1); collision doesn't care about sorting + collide = collide || this.collision(node, nn); + if (!collide) return false; - let nn = node; - let hasLocked = Boolean(this.nodes.find(n => n.locked)); - if (!this.float && !hasLocked) { - nn = {x: 0, y: node.y, w: this.column, h: node.h}; + // swap check: if we're actively moving in gravity mode, see if we collide with an object the same size + if (node._moving && !disableSwap && !this.float) { + if (this.swap(node, collide)) return true; } - while (true) { - let collisionNode = this.collide(node, nn); - if (!collisionNode) return this; - let moved; - if (collisionNode.locked) { - // if colliding with a locked item, move ourself instead - moved = this.moveNode(node, node.x, collisionNode.y + collisionNode.h, - node.w, node.h, true); + + let didMove = false; + while (collide = collide || this.collision(node, nn)) { // we could start colliding more than 1 item... so repeat for each + let moved: boolean; + // if colliding with locked item, OR moving down to a different sized item (not handle with swap) that could take our place, move ourself past instead + if (collide.locked || (node._moving && !node._skipDown && nn.y > node.y && + !this.float && !this.collision(collide, {...collide, y: node.y}, node)) && + Utils.isIntercepted(collide, {x: node.x-0.5, y: node.y-0.5, w: node.w+1, h: node.h+1})) { + + node._skipDown = (node._skipDown || nn.y > node.y); + moved = this.moveNode(node, nn.x, collide.y + collide.h, nn.w, nn.h, false, false, true); // pack & sanitize = false, disableSwap = true + nn = moved ? {x: node.x, y: node.y, w: node.w, h: node.h} : nn; + didMove = didMove || moved; } else { - moved = this.moveNode(collisionNode, collisionNode.x, node.y + node.h, - collisionNode.w, collisionNode.h, true); + // move collide down *after* us + moved = this.moveNode(collide, collide.x, nn.y + nn.h, collide.w, collide.h, false, false, true); // pack & sanitize = false, disableSwap = true } - if (!moved) return this; // break inf loop if we couldn't move after all (ex: maxRow, fixed) + if (!moved) { return didMove; } // break inf loop if we couldn't move after all (ex: maxRow, fixed) + collide = undefined; } + return didMove; + } + + /** return the first node that intercept the given node. Optionally a different area can be used, as well as a second node to skip */ + public collision(skip: GridStackNode, area = skip, skip2?: GridStackNode): GridStackNode { + return this.nodes.find(n => n !== skip && n !== skip2 && Utils.isIntercepted(n, area)) } /** return any intercepted node with the given area, skipping the passed in node (usually self) */ @@ -101,6 +114,28 @@ export class GridStackEngine { return this.nodes.find(n => n !== node && Utils.isIntercepted(n, area)); } + /** called to possibly swap between 2 nodes (same size, not locked, touching), returning true if successful */ + public swap(a: GridStackNode, b: GridStackNode): boolean { + if (!b || b.locked || !a || a.locked) return false; + + function _doSwap(): true { + let x = a.x, y = a.y; + a.x = b.x; a.y = b.y; + b.x = x; b.y = y; + a._dirty = b._dirty = true; + return true; + } + + if (a.w === b.w && a.h === b.h && Utils.isIntercepted(b, {x: a.x-0.5, y:a.y-0.5, w: a.w+1, h: a.h+1})) // same size and touching + return _doSwap(); + /* different X will be weird (expect vertical swap) and different height overlap, so too complex. user regular layout instead + // else check if swapping would not collide with anything else (requiring a re-layout) + if (!this.collision(a, {x: a.x, y: a.y, w: b.w, h: b.h}, b) && + !this.collision(a, {x: b.x, y: b.y, w: a.w, h: a.h}, b)) + return _doSwap(); */ + return false; + } + public isAreaEmpty(x: number, y: number, w: number, h: number): boolean { let nn: GridStackNode = {x: x || 0, y: y || 0, w: w || 1, h: h || 1}; return !this.collide(nn); @@ -109,19 +144,18 @@ export class GridStackEngine { /** re-layout grid items to reclaim any empty space */ public compact(): GridStackEngine { if (this.nodes.length === 0) return this; - this.batchUpdate(); - this._sortNodes(); + this.batchUpdate() + ._sortNodes(); let copyNodes = this.nodes; this.nodes = []; // pretend we have no nodes to conflict layout to start with... copyNodes.forEach(node => { - if (!node.noMove && !node.locked) { + if (!node.locked) { node.autoPosition = true; } this.addNode(node, false); // 'false' for add event trigger - node._dirty = true; // force attr update + node._dirty = true; // will force attr update }); - this.commit(); - return this; + return this.commit(); } /** enable/disable floating widgets (default: `false`) See [example](http://gridstackjs.com/demo/float.html) */ @@ -129,8 +163,7 @@ export class GridStackEngine { if (this._float === val) return; this._float = val || false; if (!val) { - this._packNodes(); - this._notify(); + this._packNodes()._notify(); } } @@ -143,20 +176,17 @@ export class GridStackEngine { return this; } - /** @internal */ + /** @internal called to top gravity pack the items back */ private _packNodes(): GridStackEngine { this._sortNodes(); if (this.float) { - this.nodes.forEach((n, i) => { - if (n._updating || n._packY === undefined || n.y === n._packY) { - return this; - } + this.nodes.forEach(n => { + if (n._updating || n._packY === undefined || n.y === n._packY) return; let newY = n.y; while (newY >= n._packY) { - let box: GridStackWidget = {x: n.x, y: newY, w: n.w, h: n.h}; - let collisionNode = this.nodes.slice(0, i).find(bn => Utils.isIntercepted(box, bn)); - if (!collisionNode) { + let collide = this.collision(n, {x: n.x, y: newY, w: n.w, h: n.h}) + if (!collide) { n._dirty = true; n.y = newY; } @@ -165,17 +195,11 @@ export class GridStackEngine { }); } else { this.nodes.forEach((n, i) => { - if (n.locked) return this; + if (n.locked) return; while (n.y > 0) { - let newY = n.y - 1; - let canBeMoved = i === 0; - let box: GridStackWidget = {x: n.x, y: newY, w: n.w, h: n.h}; - if (i > 0) { - let collisionNode = this.nodes.slice(0, i).find(bn => Utils.isIntercepted(box, bn)); - canBeMoved = !collisionNode; - } - - if (!canBeMoved) { break; } + let newY = i === 0 ? 0 : n.y - 1; + let canBeMoved = i === 0 || !this.collision(n, {x: n.x, y: newY, w: n.w, h: n.h}); + if (!canBeMoved) break; // Note: must be dirty (from last position) for GridStack::OnChange CB to update positions // and move items back. The user 'change' CB should detect changes from the original // starting position instead. @@ -262,38 +286,38 @@ export class GridStackEngine { } public getDirtyNodes(verify?: boolean): GridStackNode[] { - // compare original X,Y,W,H (or entire node?) instead as _dirty can be a temporary state + // compare original x,y,w,h instead as _dirty can be a temporary state if (verify) { - let dirtNodes: GridStackNode[] = []; - this.nodes.forEach(n => { - if (n._dirty) { - if (n.y === n._origY && n.x === n._origX && n.w === n._origW && n.h === n._origH) { - delete n._dirty; - } else { - dirtNodes.push(n); - } - } - }); - return dirtNodes; + return this.nodes.filter(n => n._dirty && !(n.y === n._orig.y && n.x === n._orig.x && n.w === n._orig.w && n.h === n._orig.h)); } - return this.nodes.filter(n => n._dirty); } - /** @internal */ + /** @internal call this to call onChange CB with dirty nodes */ private _notify(nodes?: GridStackNode | GridStackNode[], removeDOM = true): GridStackEngine { if (this.batchMode) return this; nodes = (nodes === undefined ? [] : (Array.isArray(nodes) ? nodes : [nodes]) ); let dirtyNodes = nodes.concat(this.getDirtyNodes()); - if (this.onChange) { - this.onChange(dirtyNodes, removeDOM); - } + this.onChange && this.onChange(dirtyNodes, removeDOM); return this; } + /** @internal remove dirty and last tried info */ public cleanNodes(): GridStackEngine { if (this.batchMode) return this; - this.nodes.forEach(n => { delete n._dirty; }); + this.nodes.forEach(n => { + delete n._dirty; + delete n._lastTried; + }); + return this; + } + + /** @internal called to save initial position/size to track real dirty state. Note: should ONLY be called right after we call change event */ + public saveInitial(): GridStackEngine { + this.nodes.forEach(n => { + n._orig = {x: n.x, y: n.y, w: n.w, h: n.h}; + delete n._dirty; + }); return this; } @@ -320,13 +344,11 @@ export class GridStackEngine { } this.nodes.push(node); - if (triggerAddEvent) { - this.addedNodes.push(node); - } + triggerAddEvent && this.addedNodes.push(node); this._fixCollisions(node); - this._packNodes(); - this._notify(); + this._packNodes() + ._notify(); return node; } @@ -337,35 +359,32 @@ export class GridStackEngine { node._id = null; // hint that node is being removed // don't use 'faster' .splice(findIndex(),1) in case node isn't in our list, or in multiple times. this.nodes = this.nodes.filter(n => n !== node); - if (!this.float) { - this._packNodes(); - } - this._notify(node, removeDOM); - return this; + !this.float && this._packNodes(); + return this._notify(node, removeDOM); } public removeAll(removeDOM = true): GridStackEngine { delete this._layouts; if (this.nodes.length === 0) return this; - if (removeDOM) { - this.nodes.forEach(n => { n._id = null; }); // hint that node is being removed - } + removeDOM && this.nodes.forEach(n => n._id = null); // hint that node is being removed this.removedNodes = this.nodes; this.nodes = []; - this._notify(this.removedNodes, removeDOM); - return this; + return this._notify(this.removedNodes, removeDOM); } - public canMoveNode(node: GridStackNode, x: number, y: number, w?: number, h?: number): boolean { - if (!this.isNodeChangedPosition(node, x, y, w, h)) { - return false; - } - let hasLocked = this.nodes.some(n => n.locked); + /** checks if item can be moved (layout constrain) vs moveNode(), returning true if was able to move. + * In more complicated cases (locked items, or maxRow) it will attempt at moving the item and fixing + * others in a clone first, then apply those changes if within specs. */ + public moveNodeCheck(node: GridStackNode, x: number, y: number, w = node.w, h = node.h): boolean { + if (node.locked) return false; + if (!this.changedPos(node, x, y, w, h)) return false; - if (!this.maxRow && !hasLocked) { - return true; + // simpler case: move item directly... + if (!this.maxRow && !this.nodes.some(n => n.locked)) { + return this.moveNode(node, x, y, w, h, true, false); // pack=true, sanitize=false } + // complex case: create a clone with NO maxRow (will check for out of bounds at the end) let clonedNode: GridStackNode; let clone = new GridStackEngine({ column: this.column, @@ -378,26 +397,42 @@ export class GridStackEngine { return {...n}; }) }); + if (!clonedNode) return false; - if (!clonedNode) return true; - - clone.moveNode(clonedNode, x, y, w, h); - - let canMove = true; - if (hasLocked) { - canMove = !clone.nodes.some(n => n.locked && n._dirty && n !== clonedNode); - } + let canMove = clone.moveNode(clonedNode, x, y, w, h, true, false); // pack=true, sanitize=false + // if maxRow make sure we are still valid size if (this.maxRow && canMove) { canMove = (clone.getRow() <= this.maxRow); + // turns out we can't grow, then see if we can swap instead (ex: full grid) + if (!canMove) { + let collide = this.collision(node, {x, y, w, h}); + if (collide && this.swap(node, collide)) { + this._notify(); + return true; + } + } } - - return canMove; + if (!canMove) return false; + + // if clone was able to move, copy those mods over to us now instead of caller trying to do this all over! + // Note: we can't use the list directly as elements and other parts point to actual node struct, so copy content + clone.nodes.filter(n => n._dirty).forEach(c => { + let n = this.nodes.find(a => a._id === c._id); + if (!n) return; + n.x = c.x; + n.y = c.y; + n.w = c.w; + n.h = c.h; + n._dirty = true; + }); + this._notify(); + return true; } /** return true if can fit in grid height constrain only (always true if no maxRow) */ public willItFit(node: GridStackNode): boolean { if (!this.maxRow) return true; - + // create a clone with NO maxRow and check if still within size let clone = new GridStackEngine({ column: this.column, float: this.float, @@ -432,61 +467,71 @@ export class GridStackEngine { return node._temporaryRemoved; // if still outside so we don't flicker back & forth } - public isNodeChangedPosition(node: GridStackNode, x: number, y: number, w?: number, h?: number): boolean { - if (typeof x !== 'number') { x = node.x; } - if (typeof y !== 'number') { y = node.y; } - if (typeof w !== 'number') { w = node.w; } - if (typeof h !== 'number') { h = node.h; } - + /** true if x,y or w,h are different after clamping to min/max */ + public changedPos(node: GridStackNode, x: number, y: number, w = node.w, h = node.h): boolean { + if (node.x !== x || node.y !== y) return true; + // check constrained w,h if (node.maxW) { w = Math.min(w, node.maxW); } if (node.maxH) { h = Math.min(h, node.maxH); } if (node.minW) { w = Math.max(w, node.minW); } if (node.minH) { h = Math.max(h, node.minH); } - - if (node.x === x && node.y === y && node.w === w && node.h === h) { - return false; - } - return true; + return (node.w !== w || node.h !== h); } - public moveNode(node: GridStackNode, x: number, y: number, w?: number, h?: number, noPack?: boolean): GridStackNode { - if (node.locked) return null; - if (typeof x !== 'number') { x = node.x; } - if (typeof y !== 'number') { y = node.y; } - if (typeof w !== 'number') { w = node.w; } - if (typeof h !== 'number') { h = node.h; } - - // constrain the passed in values and check if we're still changing our node - let resizing = (node.w !== w || node.h !== h); - let nn: GridStackNode = { x, y, w, h, maxW: node.maxW, maxH: node.maxH, minW: node.minW, minH: node.minH}; - nn = this.prepareNode(nn, resizing); - if (node.x === nn.x && node.y === nn.y && node.w === nn.w && node.h === nn.h) { - return null; + /** return true if the passed in node was actually moved (checks for no-op and locked) */ + public moveNode(node: GridStackNode, x: number, y: number, w = node.w, h = node.h, pack = true, sanitize = true, disableSwap = false): boolean { + if (!node || node.locked) return false; + let nn: GridStackNode; + if (sanitize) { + if (typeof x !== 'number') { x = node.x; } + if (typeof y !== 'number') { y = node.y; } + + // constrain the passed in values and check if we're still changing our node + let resizing = (node.w !== w || node.h !== h); + nn = { x, y, w, h, maxW: node.maxW, maxH: node.maxH, minW: node.minW, minH: node.minH}; + nn = this.prepareNode(nn, resizing); + } + nn = nn || {x, y, w, h} + if (this._samePos(node, nn)) return false; + let prevPos: GridStackNode = {...node}; + + // check if we will need to fix collision at our new location + let collide = this.collision(node, nn); + let moved = false; + if (collide) { + moved = this._fixCollisions(node, nn, collide, disableSwap); } - node._dirty = true; - - node.x = node._lastTriedX = nn.x; - node.y = node._lastTriedY = nn.y; - node.w = node._lastTriedW = nn.w; - node.h = node._lastTriedH = nn.h; - - this._fixCollisions(node); - if (!noPack) { - this._packNodes(); - this._notify(); + // now move (to the original ask vs the collision version which might differ) and repack things + if (!moved) { + node._dirty = true; + node.x = nn.x; + node.y = nn.y; + node.w = nn.w; + node.h = nn.h; } - return node; + if (pack) { + this._packNodes() + ._notify(); + } + return !this._samePos(node, prevPos); // pack might have moved things back + } + + /* @private true if a and b has same size/position */ + private _samePos(a: GridStackNode, b: GridStackNode): boolean { + return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; } public getRow(): number { - return this.nodes.reduce((memo, n) => Math.max(memo, n.y + n.h), 0); + return this.nodes.reduce((row, n) => Math.max(row, n.y + n.h), 0); } public beginUpdate(node: GridStackNode): GridStackEngine { if (node._updating) return this; node._updating = true; - this.nodes.forEach(n => { n._packY = n.y; }); + node._beforeDrag = {x: node.x, y: node.y, w: node.w, h: node.h}; + delete node._skipDown; + this.nodes.forEach(n => n._packY = n.y); return this; } @@ -494,7 +539,9 @@ export class GridStackEngine { let n = this.nodes.find(n => n._updating); if (n) { delete n._updating; - this.nodes.forEach(n => { delete n._packY; }); + delete n._beforeDrag; + delete n._skipDown; + this.nodes.forEach(n => delete n._packY); } return this; } @@ -502,7 +549,7 @@ export class GridStackEngine { /** saves the current layout returning a list of widgets for serialization */ public save(saveElement = true): GridStackNode[] { let widgets: GridStackNode[] = []; - Utils.sort(this.nodes); + this._sortNodes(); this.nodes.forEach(n => { let w: GridStackNode = {}; for (let key in n) { if (key[0] !== '_' && n[key] !== null && n[key] !== undefined ) w[key] = n[key]; } @@ -537,15 +584,15 @@ export class GridStackEngine { let ratio = column / this.column; // Y changed, push down same amount // TODO: detect doing item 'swaps' will help instead of move (especially in 1 column mode) - if (node.y !== node._origY) { - n.y += (node.y - node._origY); + if (node.y !== node._orig.y) { + n.y += (node.y - node._orig.y); } // X changed, scale from new position - if (node.x !== node._origX) { + if (node.x !== node._orig.x) { n.x = Math.round(node.x * ratio); } // width changed, scale from new width - if (node.w !== node._origW) { + if (node.w !== node._orig.w) { n.w = Math.round(node.w * ratio); } // ...height always carries over from cache @@ -652,18 +699,6 @@ export class GridStackEngine { return this; } - /** @internal called to save initial position/size */ - public saveInitial(): GridStackEngine { - this.nodes.forEach(n => { - n._origX = n.x; - n._origY = n.y; - n._origW = n.w; - n._origH = n.h; - delete n._dirty; - }); - return this; - } - /** * call to cache the given layout internally to the given location so we can restore back when column changes size * @param nodes list of nodes diff --git a/src/gridstack.ts b/src/gridstack.ts index d6d66c41b..1390793a4 100644 --- a/src/gridstack.ts +++ b/src/gridstack.ts @@ -347,7 +347,7 @@ export class GridStack { elements.sort((a, b) => a.i - b.i).forEach(e => this._prepareElement(e.el)); this.commit(); } - this.engine.saveInitial(); // initial start of items + this.engine.saveInitial(); // initial start of items (reset after we call _triggerChangeEvent) this.setAnimation(this.opts.animate); @@ -1012,9 +1012,9 @@ export class GridStack { // finally move the widget if (m) { - this.engine.cleanNodes(); - this.engine.beginUpdate(n); - this.engine.moveNode(n, m.x, m.y, m.w, m.h); + this.engine.cleanNodes() + .beginUpdate(n) + .moveNode(n, m.x, m.y, m.w, m.h); this._updateContainerHeight(); this._triggerChangeEvent(); this.engine.endUpdate(); @@ -1240,7 +1240,7 @@ export class GridStack { return this; } - /** @internal call to write x,y,w,h attributes back to element */ + /** @internal call to write position x,y,w,h attributes back to element */ private _writePosAttr(el: HTMLElement, x?: number, y?: number, w?: number, h?: number): GridStack { if (x !== undefined && x !== null) { el.setAttribute('gs-x', String(x)); } if (y !== undefined && y !== null) { el.setAttribute('gs-y', String(y)); } @@ -1501,7 +1501,7 @@ export class GridStack { * @param includeNewWidgets will force new widgets to be draggable as per * doEnable`s value by changing the disableResize grid option (default: true). */ - public enableResize(doEnable: boolean, includeNewWidgets = true): GridStack { return this } + public enableResize(doEnable: boolean, includeNewWidgets = true): GridStack { return this } /** @internal called to add drag over support to support widgets */ public _setupAcceptWidget(): GridStack { return this } /** @internal called to setup a trash drop zone if the user specifies it */ @@ -1521,7 +1521,7 @@ export class GridStack { /** @internal */ public maxWidth(els: GridStackElement, maxW: number): GridStack { return this.update(els, {maxW}) } /** @internal */ - public minWidth(els: GridStackElement, minW: number): GridStack { return this.update(els, {minW}) } + public minWidth(els: GridStackElement, minW: number): GridStack { return this.update(els, {minW}) } /** @internal */ public maxHeight(els: GridStackElement, maxH: number): GridStack { return this.update(els, {maxH}) } /** @internal */ diff --git a/src/types.ts b/src/types.ts index 89a3152d8..7f77c8f90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -194,11 +194,7 @@ export interface GridStackOptions { _styleSheetClass?: string; } - -/** - * GridStack Widget creation options - */ -export interface GridStackWidget { +export interface GridStackPosition { /** widget position x (default?: 0) */ x?: number; /** widget position y (default?: 0) */ @@ -207,6 +203,12 @@ export interface GridStackWidget { w?: number; /** widget dimension height (default?: 1) */ h?: number; +} + +/** + * GridStack Widget creation options + */ +export interface GridStackWidget extends GridStackPosition { /** if true then x, y parameters will be ignored and widget will be places on the first available position (default?: false) */ autoPosition?: boolean; /** minimum width allowed during resize/creation (default?: undefined = un-constrained) */ @@ -312,33 +314,25 @@ export interface GridStackNode extends GridStackWidget { _temporary?: boolean; /** @internal */ _isOutOfGrid?: boolean; - /** @internal */ - _origX?: number; - /** @internal */ - _origY?: number; + /** @internal moving vs resizing */ + _moving?: boolean; + /** @internal true if we jump down past item below (one time jump so we don't have to totally pass it) */ + _skipDown?: boolean; + /** @internal original values before a drag/size */ + _orig?: GridStackPosition; + /** @internal set on the item being dragged/resized to save initial values TODO: vs _orig ? */ + _beforeDrag?: GridStackPosition; + /** @internal top/left pixel location before a drag so we can detect direction of move from last position*/ + _lastUiPosition?: Position; + /** @internal set on the item being dragged/resized remember the last positions we've tried (but failed) so we don't try again during drag/resize */ + _lastTried?: GridStackPosition; /** @internal */ _packY?: number; /** @internal */ - _origW?: number; - /** @internal */ - _origH?: number; - /** @internal */ - _lastTriedX?: number; - /** @internal */ - _lastTriedY?: number; - /** @internal */ - _lastTriedW?: number; - /** @internal */ - _lastTriedH?: number; - /** @internal */ _isAboutToRemove?: boolean; /** @internal */ _removeTimeout?: number; - /** @internal */ - _beforeDragX?: number; - /** @internal */ - _beforeDragY?: number; - /** @internal */ + /** @internal last drag Y pixel position used to incrementally update V scroll bar */ _prevYPix?: number; /** @internal */ _temporaryRemoved?: boolean; diff --git a/src/utils.ts b/src/utils.ts index ee6f60d9a..661046b83 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -96,7 +96,7 @@ export class Utils { /** returns true if a and b overlap */ static isIntercepted(a: GridStackWidget, b: GridStackWidget): boolean { - return !(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y); + return !(a.y >= b.y + b.h || a.y + a.h <= b.y || a.x + a.w <= b.x || a.x >= b.x + b.w); } /** @@ -106,11 +106,7 @@ export class Utils { * @param width width of the grid. If undefined the width will be calculated automatically (optional). **/ static sort(nodes: GridStackNode[], dir?: -1 | 1, column?: number): GridStackNode[] { - if (!column) { - let widths = nodes.map(n => n.x + n.w); - column = Math.max(...widths); - } - + column = column || nodes.reduce((col, n) => Math.max(n.x + n.w, col), 0) || 12; if (dir === -1) return nodes.sort((a, b) => (b.x + b.y * column)-(a.x + a.y * column)); else