Skip to content

Commit 7dcb74f

Browse files
committed
* fix #2666 iOS auto-scroll uses visualViewport, and un-bind touch handlers
1 parent 3f0469d commit 7dcb74f

4 files changed

Lines changed: 101 additions & 12 deletions

File tree

‎doc/CHANGES.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Change log
55
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
66
**Table of Contents** *generated with [DocToc](http://doctoc.herokuapp.com/)*
77

8+
- [14.0.1 (TBD)](#1401-tbd)
89
- [14.0.0 (2026-09-20)](#1400-2026-09-20)
910
- [13.3.0 (2026-09-11)](#1330-2026-09-11)
1011
- [13.2.0 (2026-08-19)](#1320-2026-08-19)
@@ -147,6 +148,9 @@ Change log
147148

148149
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
149150

151+
## 14.0.1 (TBD)
152+
* fix: [#2666](https://github.com/gridstack/gridstack.js/issues/2666) iOS auto-scroll uses visualViewport, and un-bind touch handlers
153+
150154
## 14.0.0 (2026-09-20)
151155
* feat: [#754](https://github.com/gridstack/gridstack.js/issues/754), [#2866](https://github.com/gridstack/gridstack.js/issues/2866) new `mode?: 'top' | 'float' | 'list' | 'compact'` - items are continuously re-flowed in sequential (row-major) order, like a re-orderable list: dragging, resizing, adding or removing an item re-flows everyone else instead of pushing them down, and dropping an item on another takes its place. See new
152156
[list.html](https://gridstackjs.com/demo/list.html) demo.

‎spec/dd-draggable-touch-spec.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* #2666 - touch drag listener lifetime + iOS visible-viewport clipping
3+
*/
4+
import { Utils } from '../src/utils';
5+
6+
// force the touch code path (jsdom has no ontouchstart so isTouch is false by default)
7+
vi.mock('../src/dd-touch', async (importOriginal) => {
8+
const actual = await importOriginal<typeof import('../src/dd-touch')>();
9+
return { ...actual, isTouch: true };
10+
});
11+
12+
describe('2666 touch drag >', () => {
13+
'use strict';
14+
15+
it('un-binds the touch handlers it bound, on the same element', async () => {
16+
const { DDDraggable } = await import('../src/dd-draggable');
17+
const el = document.createElement('div');
18+
el.className = 'grid-stack-item';
19+
document.body.appendChild(el);
20+
21+
const added: [string, unknown, unknown][] = [];
22+
const removed: [string, unknown, unknown][] = [];
23+
const origAdd = el.addEventListener.bind(el);
24+
const origRemove = el.removeEventListener.bind(el);
25+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
26+
el.addEventListener = ((t: string, f: any, o: any) => { added.push([t, f, o]); return origAdd(t, f, o); }) as any;
27+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
28+
el.removeEventListener = ((t: string, f: any, o: any) => { removed.push([t, f, o]); return origRemove(t, f, o); }) as any;
29+
30+
const dd = new DDDraggable(el, {});
31+
const down = new MouseEvent('mousedown', { button: 0, bubbles: true });
32+
Object.defineProperty(down, 'currentTarget', { value: el });
33+
Object.defineProperty(down, 'target', { value: el });
34+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
35+
(dd as any)._mouseDown(down);
36+
37+
const touchAdds = added.filter(([t]) => t.startsWith('touch') && t !== 'touchstart');
38+
expect(touchAdds.length).toBe(3); // touchmove, touchend, touchcancel
39+
40+
// _mouseUp is a *document* handler, so its currentTarget is not `el` - it must still clean up `el`
41+
const up = new MouseEvent('mouseup', { bubbles: true });
42+
Object.defineProperty(up, 'currentTarget', { value: document });
43+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44+
(dd as any)._mouseUp(up);
45+
46+
for (const [type, fn, opt] of touchAdds) {
47+
const match = removed.find(([t, f, o]) => t === type && f === fn && !!o === !!opt);
48+
expect(match, `${type} listener was never removed from the element it was added to`).toBeTruthy();
49+
}
50+
51+
dd.destroy();
52+
document.body.removeChild(el);
53+
});
54+
55+
it('getVisibleViewport() uses visualViewport (iOS bars/pinch-zoom) with innerHeight fallback', () => {
56+
const orig = window.visualViewport;
57+
Object.defineProperty(window, 'visualViewport', { value: { offsetTop: 30, height: 500 }, configurable: true });
58+
expect(Utils.getVisibleViewport()).toEqual({ top: 30, bottom: 530 });
59+
60+
Object.defineProperty(window, 'visualViewport', { value: undefined, configurable: true });
61+
expect(Utils.getVisibleViewport()).toEqual({ top: 0, bottom: window.innerHeight });
62+
63+
Object.defineProperty(window, 'visualViewport', { value: orig, configurable: true });
64+
});
65+
});

‎src/dd-draggable.ts‎

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt
6262
xOffset: 0,
6363
yOffset: 0
6464
};
65+
/** @internal element we bound the touch handlers to in _mouseDown(), so _mouseUp() can un-bind the same one */
66+
protected _touchTarget?: HTMLElement;
6567
/** @internal auto-scroll animation variables */
6668
protected _autoScrollAnimId?: number;
6769
protected _autoScrollContainer?: HTMLElement;
@@ -222,9 +224,13 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt
222224
document.addEventListener('mousemove', this._mouseMove, { capture: true, passive: true }); // true=capture, not bubble
223225
document.addEventListener('mouseup', this._mouseUp as EventListener, true);
224226
if (isTouch && e.currentTarget) {
225-
(e.currentTarget as HTMLElement).addEventListener('touchmove', touchmove);
226-
(e.currentTarget as HTMLElement).addEventListener('touchend', touchend);
227-
(e.currentTarget as HTMLElement).addEventListener('touchcancel', touchend); // browser aborting on us, clean up too
227+
// remember what we bound to: _mouseUp() is a document handler so its own currentTarget is
228+
// NOT this element, and these were added bubbling - removing with capture=true never matched,
229+
// leaking a handler per drag that then replayed each touchmove N times over (#2666).
230+
this._touchTarget = e.currentTarget as HTMLElement;
231+
this._touchTarget.addEventListener('touchmove', touchmove);
232+
this._touchTarget.addEventListener('touchend', touchend);
233+
this._touchTarget.addEventListener('touchcancel', touchend); // browser aborting on us, clean up too
228234
}
229235
if (DDTouch.wasDelayed) {
230236
// the long-press wait just elapsed - signal the item is now armed & ready to drag
@@ -306,10 +312,11 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt
306312
this.el.classList.remove('ui-draggable-armed');
307313
document.removeEventListener('mousemove', this._mouseMove, true);
308314
document.removeEventListener('mouseup', this._mouseUp as EventListener, true);
309-
if (isTouch && e.currentTarget) { // destroy() during nested grid call us again wit fake _mouseUp
310-
e.currentTarget.removeEventListener('touchmove', touchmove as EventListener, true);
311-
e.currentTarget.removeEventListener('touchend', touchend as EventListener, true);
312-
e.currentTarget.removeEventListener('touchcancel', touchend as EventListener, true);
315+
if (isTouch && this._touchTarget) { // destroy() during nested grid call us again wit fake _mouseUp
316+
this._touchTarget.removeEventListener('touchmove', touchmove as EventListener);
317+
this._touchTarget.removeEventListener('touchend', touchend as EventListener);
318+
this._touchTarget.removeEventListener('touchcancel', touchend as EventListener);
319+
delete this._touchTarget;
313320
}
314321
if (this.dragging) {
315322
delete this.dragging;
@@ -512,10 +519,11 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt
512519
protected _getClipping(el: HTMLElement, scrollEl: HTMLElement): number {
513520
const elRect = el.getBoundingClientRect();
514521
const scrollRect = scrollEl.getBoundingClientRect();
515-
const viewportH = window.innerHeight || document.documentElement.clientHeight;
522+
// what's really on screen, NOT window.innerHeight which sits under iOS's overlaying tool bars (#2666)
523+
const view = Utils.getVisibleViewport();
516524
if (elRect.bottom < scrollRect.top || elRect.top > scrollRect.bottom) return 0; // fully outside
517-
const clippedBelow = elRect.bottom - Math.min(scrollRect.bottom, viewportH);
518-
const clippedAbove = elRect.top - Math.max(scrollRect.top, 0);
525+
const clippedBelow = elRect.bottom - Math.min(scrollRect.bottom, view.bottom);
526+
const clippedAbove = elRect.top - Math.max(scrollRect.top, view.top);
519527
if (clippedAbove < 0) return clippedAbove;
520528
if (clippedBelow > 0) return clippedBelow;
521529
return 0;
@@ -530,8 +538,8 @@ export class DDDraggable extends DDBaseImplement implements HTMLElementExtendOpt
530538
if (clipping === 0) { this._stopScrolling(); return; }
531539

532540
if (!this._autoScrollMaxSpeed) {
533-
const viewportH = window.innerHeight || document.documentElement.clientHeight;
534-
this._autoScrollMaxSpeed = Math.max(viewportH / 150, 4);
541+
const view = Utils.getVisibleViewport();
542+
this._autoScrollMaxSpeed = Math.max((view.bottom - view.top) / 150, 4);
535543
}
536544
const absPx = Math.abs(clipping);
537545
const speed = Math.min(absPx * 0.5, this._autoScrollMaxSpeed);

‎src/utils.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,18 @@ export class Utils {
520520
}
521521
}
522522

523+
/** @internal V bounds (viewport coordinates) of what the user can actually see.
524+
* Use this instead of `window.innerHeight` for clipping math: on iOS Safari `innerHeight` is the
525+
* *layout* viewport, which extends underneath the overlaying URL/tool bars (and ignores pinch-zoom),
526+
* so an item hidden behind the bottom bar still measured as fully visible and auto-scroll never
527+
* kicked in - see #2666. `visualViewport` reports what is truly on screen on every browser. */
528+
static getVisibleViewport(): { top: number, bottom: number } {
529+
const vv = typeof window !== 'undefined' ? window.visualViewport : undefined;
530+
if (vv) return { top: vv.offsetTop, bottom: vv.offsetTop + vv.height };
531+
const h = window.innerHeight || document.documentElement.clientHeight;
532+
return { top: 0, bottom: h };
533+
}
534+
523535
/** @internal returns the passed element if vertically scrollable, else the closest parent that will, up to the entire document scrolling element */
524536
static getScrollElement(el?: HTMLElement): HTMLElement {
525537
if (!el) return document.scrollingElement as HTMLElement || document.documentElement; // IE support

0 commit comments

Comments
 (0)