From a05cc11f89d107b4d83979c89ce4fb33a96b8c27 Mon Sep 17 00:00:00 2001 From: Charlie Hanekamp Date: Wed, 9 Jul 2025 16:00:56 +0200 Subject: [PATCH 1/7] Move widgets with the keyboard --- src/dd-draggable.ts | 99 +++++++++++++++++++++++++++++++++++++++++ src/gridstack-engine.ts | 2 +- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/dd-draggable.ts b/src/dd-draggable.ts index c1f18316..e6ac486f 100644 --- a/src/dd-draggable.ts +++ b/src/dd-draggable.ts @@ -44,6 +44,9 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt protected dragEls: HTMLElement[]; /** @internal true while we are dragging an item around */ protected dragging: boolean; + + /** @internal true while we are dragging an item around */ + protected keyboardSelected: HTMLElement; /** @internal last drag event */ protected lastDrag: DragEvent; /** @internal */ @@ -74,6 +77,9 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt } // create var event binding so we can easily remove and still look like TS methods (unlike anonymous functions) this._mouseDown = this._mouseDown.bind(this); + this._mouseKeyDown = this._mouseKeyDown.bind(this); + this._keyMove = this._keyMove.bind(this); + this._keyUp = this._keyUp.bind(this); this._mouseMove = this._mouseMove.bind(this); this._mouseUp = this._mouseUp.bind(this); this._keyEvent = this._keyEvent.bind(this); @@ -92,6 +98,7 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt if (this.disabled === false) return; super.enable(); this.dragEls.forEach(dragEl => { + dragEl.addEventListener('keydown', this._mouseKeyDown) dragEl.addEventListener('mousedown', this._mouseDown); if (isTouch) { dragEl.addEventListener('touchstart', touchstart); @@ -131,6 +138,98 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt return this; } + protected _elCoordinates(element: HTMLElement) { + const rect = element.getBoundingClientRect(); + const clientX = rect.left; + const clientY = rect.top; + const offsetX = element.offsetLeft; + const offsetY = element.offsetTop; + const pageX = window.scrollX + rect.left; + const pageY = window.scrollY + rect.top; + const screenX = window.screenX + rect.left; + const screenY = window.screenY + rect.top; + + return { clientX: clientX, + clientY: clientY, + offsetX: offsetX, + offsetY: offsetY, + pageX: pageX, + pageY: pageY, + screenX: screenX, + screenY: screenY } + } + + protected _elNewCoordinates(event: KeyboardEvent, element: HTMLElement) { + let coordinates = this._elCoordinates(element) + + switch (event.code) { + case 'ArrowRight': + coordinates.clientX = coordinates.clientX + 400 + break + case 'ArrowLeft': + coordinates.clientX = coordinates.clientX - 400 + break + case 'ArrowUp': + coordinates.clientY = coordinates.clientY - 400 + break + case 'ArrowDown': + coordinates.clientY = coordinates.clientY + 400 + break + } + return coordinates + } + + protected _mouseKeyDown(e: KeyboardEvent): void { + if(e.code === 'Space') { + e.preventDefault() + + const handle = e.target as HTMLElement + const item: HTMLElement = handle?.closest('.grid-stack-item') + this.keyboardSelected = item + item.classList.add('grid-stack-item-selected') + + e.target.dispatchEvent(new MouseEvent('mousedown')) + document.addEventListener('keyup', this._keyUp) + } + } + + protected _keyUp() { + document.removeEventListener('keyup', this._keyUp) + document.addEventListener('keydown', this._keyMove) + } + + protected _selectedItem (element: HTMLElement): HTMLElement { + const items = document.querySelectorAll('.grid-stack-item') + + return Array.from(items).filter(item => item === element)[0] as HTMLElement + } + + protected _keyMove(e: KeyboardEvent) { + if (e.code === 'Space') { + e.preventDefault() + + this.keyboardSelected.classList.remove('grid-stack-item-selected') + this.keyboardSelected.dispatchEvent(new MouseEvent('mouseup')) + document.removeEventListener('keydown', this._keyMove) + + return + } + + if (e.code === 'ArrowRight' || + e.code === 'ArrowLeft' || + e.code === 'ArrowUp' || + e.code === 'ArrowDown') { + e.target.dispatchEvent(new MouseEvent('mousemove', { ...this._elCoordinates(this.keyboardSelected)})) + e.target.dispatchEvent(new MouseEvent('mousemove', { ...this._elNewCoordinates(e, this.keyboardSelected)})) + e.target.dispatchEvent(new MouseEvent('mouseup')) + + this.keyboardSelected = this._selectedItem(this.keyboardSelected) + const handle: HTMLElement = this.keyboardSelected.querySelector('.grid-item-handle') + + handle?.dispatchEvent(new MouseEvent('mousedown')) + } + } + /** @internal call when mouse goes down before a dragstart happens */ protected _mouseDown(e: MouseEvent): boolean { // don't let more than one widget handle mouseStart diff --git a/src/gridstack-engine.ts b/src/gridstack-engine.ts index 8e1fe98d..4e257f31 100644 --- a/src/gridstack-engine.ts +++ b/src/gridstack-engine.ts @@ -424,7 +424,7 @@ export class GridStackEngine { copy.w = Math.min(this.defaultColumn, copy.w || 1); this.cacheOneLayout(copy, this.defaultColumn); } - + if (node.w > this.column) { node.w = this.column; } else if (node.w < 1) { From b1463474cca4589685446e878ae689fad4a37351 Mon Sep 17 00:00:00 2001 From: Charlie Hanekamp Date: Wed, 9 Jul 2025 16:24:30 +0200 Subject: [PATCH 2/7] Get the correct new position When using the arrow keys the item moves one colum left or right. Or its own height up or down. --- src/dd-draggable.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/dd-draggable.ts b/src/dd-draggable.ts index e6ac486f..441c5a15 100644 --- a/src/dd-draggable.ts +++ b/src/dd-draggable.ts @@ -77,7 +77,7 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt } // create var event binding so we can easily remove and still look like TS methods (unlike anonymous functions) this._mouseDown = this._mouseDown.bind(this); - this._mouseKeyDown = this._mouseKeyDown.bind(this); + this._keyDown = this._keyDown.bind(this); this._keyMove = this._keyMove.bind(this); this._keyUp = this._keyUp.bind(this); this._mouseMove = this._mouseMove.bind(this); @@ -98,7 +98,7 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt if (this.disabled === false) return; super.enable(); this.dragEls.forEach(dragEl => { - dragEl.addEventListener('keydown', this._mouseKeyDown) + dragEl.addEventListener('keydown', this._keyDown) dragEl.addEventListener('mousedown', this._mouseDown); if (isTouch) { dragEl.addEventListener('touchstart', touchstart); @@ -160,26 +160,38 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt } protected _elNewCoordinates(event: KeyboardEvent, element: HTMLElement) { + const node = this.el.gridstackNode + const cellHeight = node.grid.getCellHeight() * node.h + const cellWidth = node.grid.cellWidth() + const maxColumn = node.grid.opts.column + let coordinates = this._elCoordinates(element) switch (event.code) { case 'ArrowRight': - coordinates.clientX = coordinates.clientX + 400 + if(typeof(maxColumn) == 'number' && node.x === (maxColumn - 1)) { break } + + coordinates.clientX = coordinates.clientX + cellWidth + (cellWidth / 2) break case 'ArrowLeft': - coordinates.clientX = coordinates.clientX - 400 + if (node.x === 0) { break } + + coordinates.clientX = coordinates.clientX - cellWidth - (cellWidth / 2) break case 'ArrowUp': - coordinates.clientY = coordinates.clientY - 400 + if (node.y === 0) { break } + + coordinates.clientY = coordinates.clientY - cellHeight break case 'ArrowDown': - coordinates.clientY = coordinates.clientY + 400 + coordinates.clientY = coordinates.clientY + cellHeight break } + return coordinates } - protected _mouseKeyDown(e: KeyboardEvent): void { + protected _keyDown(e: KeyboardEvent): void { if(e.code === 'Space') { e.preventDefault() @@ -219,6 +231,8 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt e.code === 'ArrowLeft' || e.code === 'ArrowUp' || e.code === 'ArrowDown') { + e.preventDefault() + e.target.dispatchEvent(new MouseEvent('mousemove', { ...this._elCoordinates(this.keyboardSelected)})) e.target.dispatchEvent(new MouseEvent('mousemove', { ...this._elNewCoordinates(e, this.keyboardSelected)})) e.target.dispatchEvent(new MouseEvent('mouseup')) From d5498810836c574b532ac074d6dc527ec58d4a9f Mon Sep 17 00:00:00 2001 From: Charlie Hanekamp Date: Wed, 9 Jul 2025 17:25:09 +0200 Subject: [PATCH 3/7] Scroll with the moving item The item is moved with the keyboard and the item stays in view of the user. --- src/dd-draggable.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/dd-draggable.ts b/src/dd-draggable.ts index 441c5a15..92cdf461 100644 --- a/src/dd-draggable.ts +++ b/src/dd-draggable.ts @@ -238,6 +238,8 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt e.target.dispatchEvent(new MouseEvent('mouseup')) this.keyboardSelected = this._selectedItem(this.keyboardSelected) + this.keyboardSelected.scrollIntoView({ block: "center" }) + const handle: HTMLElement = this.keyboardSelected.querySelector('.grid-item-handle') handle?.dispatchEvent(new MouseEvent('mousedown')) From 0a33b8f8d2be569c99ea1e2e26881b24cfda3ca0 Mon Sep 17 00:00:00 2001 From: Charlie Hanekamp Date: Wed, 16 Jul 2025 15:18:09 +0200 Subject: [PATCH 4/7] Extract the setting of the new coordinates to a function --- src/dd-draggable.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/dd-draggable.ts b/src/dd-draggable.ts index 92cdf461..1bfa10a1 100644 --- a/src/dd-draggable.ts +++ b/src/dd-draggable.ts @@ -159,36 +159,44 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt screenY: screenY } } + _setCoordinates(element: HTMLElement, x: number, y:number) { + let coordinates = this._elCoordinates(element) + + coordinates.clientX += x || 0; + coordinates.clientY += y || 0; + + return coordinates + } + protected _elNewCoordinates(event: KeyboardEvent, element: HTMLElement) { const node = this.el.gridstackNode const cellHeight = node.grid.getCellHeight() * node.h const cellWidth = node.grid.cellWidth() const maxColumn = node.grid.opts.column - - let coordinates = this._elCoordinates(element) + let xCoord: number, yCoord: number switch (event.code) { case 'ArrowRight': if(typeof(maxColumn) == 'number' && node.x === (maxColumn - 1)) { break } - coordinates.clientX = coordinates.clientX + cellWidth + (cellWidth / 2) + xCoord = cellWidth break case 'ArrowLeft': if (node.x === 0) { break } - coordinates.clientX = coordinates.clientX - cellWidth - (cellWidth / 2) + xCoord = -cellWidth break case 'ArrowUp': if (node.y === 0) { break } - coordinates.clientY = coordinates.clientY - cellHeight + yCoord = -cellHeight break case 'ArrowDown': - coordinates.clientY = coordinates.clientY + cellHeight + yCoord = cellHeight break } - return coordinates + return this._setCoordinates(element, xCoord, yCoord); } protected _keyDown(e: KeyboardEvent): void { From e2f1b795d777af42713083e293fda84d6142bc47 Mon Sep 17 00:00:00 2001 From: Charlie Hanekamp Date: Wed, 16 Jul 2025 15:20:02 +0200 Subject: [PATCH 5/7] Extract node and grid assignment Move assignments to the position they are used --- src/dd-draggable.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/dd-draggable.ts b/src/dd-draggable.ts index 1bfa10a1..dec60342 100644 --- a/src/dd-draggable.ts +++ b/src/dd-draggable.ts @@ -168,26 +168,34 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt return coordinates } + _node() { + return this.el.gridstackNode; + } + + _grid() { + return this._node().grid + } + protected _elNewCoordinates(event: KeyboardEvent, element: HTMLElement) { - const node = this.el.gridstackNode - const cellHeight = node.grid.getCellHeight() * node.h - const cellWidth = node.grid.cellWidth() - const maxColumn = node.grid.opts.column + const selectedNode = this._node(); + const cellHeight = this._grid().getCellHeight() * selectedNode.h let xCoord: number, yCoord: number switch (event.code) { case 'ArrowRight': - if(typeof(maxColumn) == 'number' && node.x === (maxColumn - 1)) { break } + const maxColumn = this._grid().opts.column + + if(typeof(maxColumn) == 'number' && selectedNode.x === (maxColumn - 1)) { break } - xCoord = cellWidth + xCoord = this._grid().cellWidth() break case 'ArrowLeft': - if (node.x === 0) { break } + if (selectedNode.x === 0) { break } - xCoord = -cellWidth + xCoord = -this._grid().cellWidth() break case 'ArrowUp': - if (node.y === 0) { break } + if (selectedNode.y === 0) { break } yCoord = -cellHeight break From 0707d1e06f363f4659e994a9b177b7857fa4a595 Mon Sep 17 00:00:00 2001 From: Charlie Hanekamp Date: Wed, 16 Jul 2025 15:28:57 +0200 Subject: [PATCH 6/7] Check for the first available space to move the item up This takes the selected elements width and its position to check for the first item above. When no direct item is found find the first item in the row above and move the item above that. --- src/dd-draggable.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/dd-draggable.ts b/src/dd-draggable.ts index dec60342..2d4b1b53 100644 --- a/src/dd-draggable.ts +++ b/src/dd-draggable.ts @@ -83,6 +83,7 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt this._mouseMove = this._mouseMove.bind(this); this._mouseUp = this._mouseUp.bind(this); this._keyEvent = this._keyEvent.bind(this); + this._sortByRow = this._sortByRow.bind(this); this.enable(); } @@ -176,6 +177,57 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt return this._node().grid } + _itemNode(item) { + return item['gridstackNode'] + } + + _nodePosition(node) { + return { + width: node.w - 1, + height: node.h, + column: node.x, + row: node.y + } + } + + _items() { + return document.querySelectorAll('.grid-stack-item:not(.grid-stack-placeholder)') + } + + _sortByRow(a, b) { + return this._itemNode(a).y - this._itemNode(b).y + } + + // Find the first item above the selectedNode. + // Add the items row and its height, this should be the same as the selectedNodes row, if so, the item is in the row directly + // above the selectedNode. + // Also check if the item column overlaps the selectedNodes columns and include the items width in this calculation + _findItemAbove () { + const selectedNode = this._nodePosition(this._node()) + + return Array.from(this._items()).filter(item => { + const itemNode = this._nodePosition(this._itemNode(item)) + + if ((itemNode.row + itemNode.height) !== selectedNode.row) { return false } + if (selectedNode.column < itemNode.column) { return false } + if (selectedNode.column > (itemNode.column + itemNode.width)) { return false } + return item + })[0] + } + + // When we have not found any items in the row directly above the selectedNode. + // Look for the first item it can find above the selectedNodes row. + _findFirstItemAbove () { + const selectedNode = this._nodePosition(this._node()) + + return Array.from(this._items()).filter(item => { + if (item === this.el) { return false } + const itemNode = this._nodePosition(this._itemNode(item)) + + if (itemNode.row < selectedNode.row) { return item } + }).sort(this._sortByRow).reverse()[0] + } + protected _elNewCoordinates(event: KeyboardEvent, element: HTMLElement) { const selectedNode = this._node(); const cellHeight = this._grid().getCellHeight() * selectedNode.h @@ -197,7 +249,10 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt case 'ArrowUp': if (selectedNode.y === 0) { break } - yCoord = -cellHeight + let itemAbove = this._findItemAbove() + if (itemAbove === undefined) { itemAbove = this._findFirstItemAbove() } + + yCoord = -(this._itemNode(itemAbove).h * this._grid().getCellHeight()) break case 'ArrowDown': yCoord = cellHeight From 78dfd52afa270a4a4670fece172238a412dbd9ed Mon Sep 17 00:00:00 2001 From: Charlie Hanekamp Date: Wed, 16 Jul 2025 15:34:56 +0200 Subject: [PATCH 7/7] Move the item down to the first available position First look for the next item in the row directly below the selected item. When nothing is find look for the first item below the selected item. And when that fails because of whitespacing, look for the first item which is below and overlaps the selected item. Also check if the selected item has sibling, because when the item below spans more columns, it can mean there is no space for that item. Instead, move the selected item below the next item. --- src/dd-draggable.ts | 89 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/src/dd-draggable.ts b/src/dd-draggable.ts index 2d4b1b53..3e33dfa6 100644 --- a/src/dd-draggable.ts +++ b/src/dd-draggable.ts @@ -228,9 +228,74 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt }).sort(this._sortByRow).reverse()[0] } + // Find the first item below the selectedNode. + // Add the selectedNodes row and its height, this should be the same as the items row, if so, the item is in the row directly + // below the selectedNode. + // Also check if the item column overlaps the selectedNodes columns and include the items width in this calculation + _findItemBelow () { + const selectedNode = this._nodePosition(this._node()) + + return Array.from(this._items()).filter(item => { + const itemNode = this._nodePosition(this._itemNode(item)) + const row = selectedNode.height + selectedNode.row + + if (itemNode.row !== row) { return false } + if (selectedNode.column < itemNode.column) { return false } + if (selectedNode.column > (itemNode.column + itemNode.width)) { return false } + return item + })[0] + } + + // When we have not found any items in the row directly below the selectedNode. + // Look for the first item it can find below the selectedNodes row. + _findFirstItemBelow () { + const selectedNode = this._nodePosition(this._node()) + + return Array.from(this._items()).filter(item => { + const itemNode = this._nodePosition(this._itemNode(item)) + + if (item === this.el) { return false } + if (selectedNode.column < itemNode.column) { return false } + if (selectedNode.column > (itemNode.column + itemNode.width)) { return false } + if (itemNode.row <= selectedNode.row) { return false } + + return item + }).sort(this._sortByRow)[0] + } + + // When the selected item spans more than one column and the position directly below are all empty. + // When this happens we want to look for the first item in the row below which overlap the selected item on the columns. + _findFirstRowBelow() { + const selectedNode = this._nodePosition(this._node()) + + return Array.from(this._items()).filter(item => { + if (item === this.el) { return false } + const itemNode = this._nodePosition(this._itemNode(item)) + + if (itemNode.row < (selectedNode.row + selectedNode.height)) { return false } + return item + }).sort(this._sortByRow)[0] + } + + // Check if the selectedNode has any siblings to the left or right + _findSiblings(itemBelow: Element) { + const itemBelowNode = this._nodePosition(this._itemNode(itemBelow)) + const selectedNode = this._nodePosition(this._node()) + + return Array.from(this._items()).filter(item => { + const itemNode = this._nodePosition(this._itemNode(item)) + + if (item === this.el) { return false } + if (itemNode.row !== selectedNode.row) { return false } + + if (itemNode.column < itemBelowNode.column) { return false } + if (itemNode.column > (itemBelowNode.column + itemBelowNode.width)) { return false } + return item + }) + } + protected _elNewCoordinates(event: KeyboardEvent, element: HTMLElement) { const selectedNode = this._node(); - const cellHeight = this._grid().getCellHeight() * selectedNode.h let xCoord: number, yCoord: number switch (event.code) { @@ -255,8 +320,26 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt yCoord = -(this._itemNode(itemAbove).h * this._grid().getCellHeight()) break case 'ArrowDown': - yCoord = cellHeight - break + let itemBelow = this._findItemBelow() + + if (itemBelow === undefined) { itemBelow = this._findFirstItemBelow() } + if (itemBelow === undefined) { itemBelow = this._findFirstRowBelow() } + + const itemBelowNode = this._nodePosition(this._itemNode(itemBelow)) + const siblings = this._findSiblings(itemBelow) + + if (siblings.length >= 1) { + const rowPosition = (itemBelowNode.row - selectedNode.y) * this._grid().getCellHeight(); + + yCoord = rowPosition + (itemBelowNode.height * this._grid().getCellHeight()) + } else if (selectedNode.h < itemBelowNode.height) { + yCoord = (itemBelowNode.height * this._grid().getCellHeight()) + } else { + const cellHeight = this._grid().getCellHeight() * selectedNode.h; + + yCoord = (cellHeight + this._grid().getCellHeight()) + } + break; } return this._setCoordinates(element, xCoord, yCoord);