From ce46498ee2164980ef6091094ff274824c2d3a70 Mon Sep 17 00:00:00 2001 From: Nick Heinbaugh Date: Fri, 20 Jun 2025 14:31:53 -0700 Subject: [PATCH 1/4] extracted scroll speed logic for better testability and added tests --- spec/utils-spec.ts | 52 ++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 56 +++++++++++++++++++++++++--------------------- 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/spec/utils-spec.ts b/spec/utils-spec.ts index 3aa4f311..25dda760 100644 --- a/spec/utils-spec.ts +++ b/spec/utils-spec.ts @@ -116,6 +116,58 @@ describe('gridstack utils', function() { }); }); + describe('_getScrollAmount', () => { + const innerHeight = 800; + const elHeight = 600; + + it('should not scroll if element is inside viewport', () => { + const rect = { top: 100, bottom: 700, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; + const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, elHeight, -10); + expect(scrollAmount).toBe(0); + }); + + describe('scrolling up', () => { + it('should scroll up', () => { + const rect = { top: -20, bottom: 580, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; + const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, elHeight, -30); + expect(scrollAmount).toBe(-20); + }); + it('should scroll up to bring dragged element into view', () => { + const rect = { top: -20, bottom: 580, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; + const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, elHeight, -10); + expect(scrollAmount).toBe(-10); + }); + it('should scroll up when dragged element is larger than viewport', () => { + const rect = { top: -20, bottom: 880, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; + const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, 900, -30); + expect(scrollAmount).toBe(-30); + }); + }); + + describe('scrolling down', () => { + it('should not scroll down if element is inside viewport', () => { + const rect = { top: 100, bottom: 700, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; + const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, elHeight, 10); + expect(scrollAmount).toBe(0); + }); + it('should scroll down', () => { + const rect = { top: 220, bottom: 820, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; + const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, elHeight, 10); + expect(scrollAmount).toBe(10); + }); + it('should scroll down to bring dragged element into view', () => { + const rect = { top: 220, bottom: 820, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; + const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, elHeight, 30); + expect(scrollAmount).toBe(20); + }); + it('should scroll down when dragged element is larger than viewport', () => { + const rect = { top: -100, bottom: 820, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; + const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, 920, 10); + expect(scrollAmount).toBe(10); + }); + }); + }); + describe('clone', () => { const a: any = {first: 1, second: 'text'}; const b: any = {first: 1, second: {third: 3}}; diff --git a/src/utils.ts b/src/utils.ts index 4014a189..58efb151 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -350,38 +350,42 @@ export class Utils { } } + /** @internal */ + static _getScrollAmount(rect: DOMRect, viewportHeight: number, elHeight: number, distance: number): number { + const offsetDiffDown = rect.bottom - viewportHeight; + const offsetDiffUp = rect.top; + const elementIsLargerThanViewport = elHeight > viewportHeight; + + if (rect.top < 0 && distance < 0) { + // moving up + if (elementIsLargerThanViewport) { + return distance; + } else { + return Math.abs(offsetDiffUp) > Math.abs(distance) ? distance : offsetDiffUp; + } + } else if (rect.bottom > viewportHeight && distance > 0) { + // moving down + if (elementIsLargerThanViewport) { + return distance; + } else { + return offsetDiffDown > distance ? distance : offsetDiffDown; + } + } + return 0; + } + /** @internal */ static updateScrollPosition(el: HTMLElement, position: {top: number}, distance: number): void { // is widget in view? const rect = el.getBoundingClientRect(); - const innerHeightOrClientHeight = (window.innerHeight || document.documentElement.clientHeight); - if (rect.top < 0 || - rect.bottom > innerHeightOrClientHeight - ) { - // set scrollTop of first parent that scrolls - // if parent is larger than el, set as low as possible - // to get entire widget on screen - const offsetDiffDown = rect.bottom - innerHeightOrClientHeight; - const offsetDiffUp = rect.top; + const viewportHeight = (window.innerHeight || document.documentElement.clientHeight); + const scrollAmount = this._getScrollAmount(rect, viewportHeight, el.offsetHeight, distance); + + if (scrollAmount) { const scrollEl = this.getScrollElement(el); - if (scrollEl !== null) { + if (scrollEl) { const prevScroll = scrollEl.scrollTop; - if (rect.top < 0 && distance < 0) { - // moving up - if (el.offsetHeight > innerHeightOrClientHeight) { - scrollEl.scrollTop += distance; - } else { - scrollEl.scrollTop += Math.abs(offsetDiffUp) > Math.abs(distance) ? distance : offsetDiffUp; - } - } else if (distance > 0) { - // moving down - if (el.offsetHeight > innerHeightOrClientHeight) { - scrollEl.scrollTop += distance; - } else { - scrollEl.scrollTop += offsetDiffDown > distance ? distance : offsetDiffDown; - } - } - // move widget y by amount scrolled + scrollEl.scrollTop += scrollAmount; position.top += scrollEl.scrollTop - prevScroll; } } From a86d11be90238e6923aea0675c5ec5df61b078c0 Mon Sep 17 00:00:00 2001 From: Nick Heinbaugh Date: Fri, 20 Jun 2025 14:35:00 -0700 Subject: [PATCH 2/4] added the new user option to control scroll speed --- spec/utils-spec.ts | 30 ++++++++++++++++++++++++++++++ src/gridstack.ts | 2 +- src/types.ts | 3 +++ src/utils.ts | 26 ++++++++++++++++++-------- 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/spec/utils-spec.ts b/spec/utils-spec.ts index 25dda760..ba43d2a5 100644 --- a/spec/utils-spec.ts +++ b/spec/utils-spec.ts @@ -126,6 +126,18 @@ describe('gridstack utils', function() { expect(scrollAmount).toBe(0); }); + it('should not limit the scroll speed if the user has set maxScrollSpeed to 0', () => { + const rect = { top: 220, bottom: 850, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; + const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, elHeight, 50); + expect(scrollAmount).toBe(50); + }); + + it('should treat a negative maxScrollSpeed as positive', () => { + const rect = { top: 220, bottom: 850, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; + const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, elHeight, 50, -4 ); + expect(scrollAmount).toBe(4); + }); + describe('scrolling up', () => { it('should scroll up', () => { const rect = { top: -20, bottom: 580, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; @@ -142,6 +154,15 @@ describe('gridstack utils', function() { const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, 900, -30); expect(scrollAmount).toBe(-30); }); + + it('should limit the scroll speed when the expected scroll speed is greater than the maxScrollSpeed', () => { + const rect = { top: -30, bottom: 880, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; + const scrollAmountWithoutLimit = Utils._getScrollAmount(rect as DOMRect, innerHeight, elHeight, -30); + expect(scrollAmountWithoutLimit).toBe(-30); // be completely sure that the scroll amount should be limited + + const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, elHeight, -30, 10); + expect(scrollAmount).toBe(-10); + }); }); describe('scrolling down', () => { @@ -165,6 +186,15 @@ describe('gridstack utils', function() { const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, 920, 10); expect(scrollAmount).toBe(10); }); + + it('should limit the scroll speed when the expected scroll speed is greater than the maxScrollSpeed', () => { + const rect = { top: 220, bottom: 850, left: 0, right: 0, width: 50, height: 50, toJSON: () => '' }; + const scrollAmountWithoutLimit = Utils._getScrollAmount(rect as DOMRect, innerHeight, elHeight, 50); + expect(scrollAmountWithoutLimit).toBe(50); // be completely sure that the scroll amount should be limited + + const scrollAmount = Utils._getScrollAmount(rect as DOMRect, innerHeight, elHeight, 10, 10); + expect(scrollAmount).toBe(10); + }); }); }); diff --git a/src/gridstack.ts b/src/gridstack.ts index 3ca758fc..07fe4a16 100644 --- a/src/gridstack.ts +++ b/src/gridstack.ts @@ -2541,7 +2541,7 @@ export class GridStack { const distance = ui.position.top - node._prevYPix; node._prevYPix = ui.position.top; if (this.opts.draggable.scroll !== false) { - Utils.updateScrollPosition(el, ui.position, distance); + Utils.updateScrollPosition(el, ui.position, distance, this.opts.maxScrollSpeed); } // get new position taking into account the margin in the direction we are moving! (need to pass mid point by margin) diff --git a/src/types.ts b/src/types.ts index b59735e5..3520e1e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -217,6 +217,9 @@ export interface GridStackOptions { /** maximum rows amount. Default? is 0 which means no maximum rows */ maxRow?: number; + /** maximum scroll speed when dragging items. Any negative value will be converted to the positive value. (default?: 0 = no limit) */ + maxScrollSpeed?: number; + /** minimum rows amount. Default is `0`. You can also do this with `min-height` CSS attribute * on the grid div in pixels, which will round to the closest row. */ diff --git a/src/utils.ts b/src/utils.ts index 58efb151..84841d2d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -351,35 +351,45 @@ export class Utils { } /** @internal */ - static _getScrollAmount(rect: DOMRect, viewportHeight: number, elHeight: number, distance: number): number { + static _getScrollAmount(rect: DOMRect, viewportHeight: number, elHeight: number, distance: number, maxScrollSpeed?: number): number { const offsetDiffDown = rect.bottom - viewportHeight; const offsetDiffUp = rect.top; const elementIsLargerThanViewport = elHeight > viewportHeight; + let scrollAmount = 0; if (rect.top < 0 && distance < 0) { // moving up if (elementIsLargerThanViewport) { - return distance; + scrollAmount = distance; } else { - return Math.abs(offsetDiffUp) > Math.abs(distance) ? distance : offsetDiffUp; + scrollAmount = Math.abs(offsetDiffUp) > Math.abs(distance) ? distance : offsetDiffUp; } } else if (rect.bottom > viewportHeight && distance > 0) { // moving down if (elementIsLargerThanViewport) { - return distance; + scrollAmount = distance; } else { - return offsetDiffDown > distance ? distance : offsetDiffDown; + scrollAmount = offsetDiffDown > distance ? distance : offsetDiffDown; } } - return 0; + + if (maxScrollSpeed) { + maxScrollSpeed = Math.abs(maxScrollSpeed); + if (scrollAmount > maxScrollSpeed) { + scrollAmount = maxScrollSpeed; + } else if (scrollAmount < -maxScrollSpeed) { + scrollAmount = -maxScrollSpeed; + } + } + return scrollAmount; } /** @internal */ - static updateScrollPosition(el: HTMLElement, position: {top: number}, distance: number): void { + static updateScrollPosition(el: HTMLElement, position: {top: number}, distance: number, maxScrollSpeed?: number): void { // is widget in view? const rect = el.getBoundingClientRect(); const viewportHeight = (window.innerHeight || document.documentElement.clientHeight); - const scrollAmount = this._getScrollAmount(rect, viewportHeight, el.offsetHeight, distance); + const scrollAmount = this._getScrollAmount(rect, viewportHeight, el.offsetHeight, distance, maxScrollSpeed); if (scrollAmount) { const scrollEl = this.getScrollElement(el); From a65a9974b8e9c9a7296e9f1d2752ad45dd3f9e7f Mon Sep 17 00:00:00 2001 From: Nick Heinbaugh Date: Fri, 20 Jun 2025 14:35:43 -0700 Subject: [PATCH 3/4] updated documentation --- doc/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/README.md b/doc/README.md index e4c31a9e..5b3b3ae5 100644 --- a/doc/README.md +++ b/doc/README.md @@ -116,6 +116,7 @@ gridstack.js API - `marginBottom`: numberOrString - `marginLeft`: numberOrString - `maxRow` - maximum rows amount. Default is `0` which means no max. +- `maxScrollSpeed` - (number) limits the speed that the user wll scroll up and down in the grid. This is most noticable in large grids. Default: `0` which indicates no limit on the scroll speed. Any value provided here should be positive. - `minRow` - minimum rows amount which is handy to prevent grid from collapsing when empty. Default is `0`. You can also do this with `min-height` CSS attribute on the grid div in pixels, which will round to the closest row. - `nonce` - If you are using a nonce-based Content Security Policy, pass your nonce here and GridStack will add it to the `