Skip to content

Commit d05284d

Browse files
Xdudugithub-actions[bot]
authored andcommitted
Preserve live gesture when camera setter is called from inside a
GitOrigin-RevId: 9cdc8e49485bd4668ffe1ae3bbb05f5d2262d057
1 parent ea503d7 commit d05284d

3 files changed

Lines changed: 173 additions & 12 deletions

File tree

src/ui/camera.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {getZoomAdjustment} from '../geo/projection/adjustments';
3131
import type Tile from '../source/tile';
3232
import type Transform from '../geo/transform';
3333
import type HandlerManager from './handler_manager';
34+
import type {KeepGesture} from './handler_manager';
3435
import type {TaskID} from '../util/task_queue';
3536
import type {Callback} from '../types/callback';
3637
import type {MapEvents} from './events';
@@ -1103,7 +1104,9 @@ class Camera extends Evented<MapEvents> {
11031104
* @see [Example: Update a feature in realtime](https://docs.mapbox.com/mapbox-gl-js/example/live-update-feature/)
11041105
*/
11051106
jumpTo(options: CameraOptions & {preloadOnly?: AnimationOptions['preloadOnly']}, eventData?: EventData): this {
1106-
this.stop();
1107+
// a camera setter (setCenter/setPitch/…) called from within a 'drag'/'move' handler
1108+
// must not tear down the gesture the user is still performing.
1109+
this._stop({keepGesture: 'ifPointerDown'});
11071110

11081111
const tr = options.preloadOnly ? this.transform.clone() : this.transform;
11091112
let zoomChanged = false,
@@ -1325,7 +1328,9 @@ class Camera extends Evented<MapEvents> {
13251328
},
13261329
eventData?: EventData,
13271330
): this {
1328-
this._stop(false, options.easeId);
1331+
this._stop({
1332+
easeId: options.easeId
1333+
});
13291334

13301335
options = {offset: [0, 0],
13311336
duration: 500,
@@ -1777,7 +1782,24 @@ class Camera extends Evented<MapEvents> {
17771782
// No-op in the Camera class, implemented by the Map class
17781783
_cancelRenderFrame(_: TaskID): void {}
17791784

1780-
_stop(allowGestures?: boolean, easeId?: string): this {
1785+
// Cancels any running camera animation.
1786+
// `keepGesture` controls whether to keep a gesture that is currently in progress or not:
1787+
// - 'never' (default behavior):
1788+
// Never keep the gesture.
1789+
// Used in `stop`.
1790+
// - 'always':
1791+
// Always keep the gesture.
1792+
// Used when only want to cancel any animation.
1793+
// - 'ifPointerDown':
1794+
// Keep the gesture only if a pointer is still physically down (see `_isPointerDown` in `HandlerManager`).
1795+
// Possible use case: when `jumpTo` called from inside a drag/move handler, do not kill the drag the user is still performing.
1796+
_stop({
1797+
easeId,
1798+
keepGesture
1799+
}: {
1800+
easeId?: string;
1801+
keepGesture?: KeepGesture;
1802+
} = {}): this {
17811803
if (this._easeFrameId) {
17821804
this._cancelRenderFrame(this._easeFrameId);
17831805
this._easeFrameId = undefined;
@@ -1792,10 +1814,10 @@ class Camera extends Evented<MapEvents> {
17921814
this._onEaseEnd = undefined;
17931815
onEaseEnd.call(this, easeId);
17941816
}
1795-
if (!allowGestures) {
1796-
const handlers = this.handlers;
1797-
if (handlers) handlers.stop(false);
1798-
}
1817+
1818+
const handlers = this.handlers;
1819+
if (handlers) handlers.stop(keepGesture);
1820+
17991821
return this;
18001822
}
18011823

src/ui/handler_manager.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export type HandlerManagerOptions = {
4141
pitchRotateKey?: PitchRotateKey;
4242
};
4343

44+
export type KeepGesture = 'always' | 'ifPointerDown' | 'never';
45+
4446
type EventsInProgress = {
4547
[T in keyof MapEvents]?: MapEvents[T];
4648
};
@@ -126,6 +128,12 @@ class HandlerManager {
126128
_dragOrigin: vec3 | null | undefined;
127129
_originalZoom: number | null | undefined;
128130

131+
// Whether a pointer is currently physically down.
132+
// Used to avoid tearing down an in-progress gesture when a soft camera update (jumpTo/easeTo/flyTo)
133+
// is called while the user is still dragging.
134+
_mouseBeingPressed: boolean;
135+
_activeTouchCount: number;
136+
129137
constructor(map: Map, options: HandlerManagerOptions) {
130138
this._map = map;
131139
this._el = this._map.getCanvasContainer();
@@ -138,6 +146,8 @@ class HandlerManager {
138146
this._previousActiveHandlers = {};
139147
this._trackingEllipsoid = new TrackingEllipsoid();
140148
this._dragOrigin = null;
149+
this._mouseBeingPressed = false;
150+
this._activeTouchCount = 0;
141151

142152
// Track whether map is currently moving, to compute start/move/end events
143153
this._eventsInProgress = {};
@@ -259,15 +269,17 @@ class HandlerManager {
259269
this._handlersById[handlerName] = handler;
260270
}
261271

262-
stop(allowEndAnimation: boolean) {
272+
stop(keepGesture: KeepGesture = 'never') {
263273
// do nothing if this method was triggered by a gesture update
264274
if (this._updatingCamera) return;
265275

276+
if (keepGesture === 'always' || (keepGesture === 'ifPointerDown' && this._isPointerDown())) return;
277+
266278
for (const {handler} of this._handlers) {
267279
handler.reset();
268280
}
269281
this._inertia.clear();
270-
this._fireEvents({}, {}, allowEndAnimation);
282+
this._fireEvents({}, {}, false);
271283
this._changes = [];
272284
this._originalZoom = undefined;
273285
}
@@ -326,7 +338,43 @@ class HandlerManager {
326338
return mapTouches as unknown as TouchList;
327339
}
328340

341+
// Track raw physical pointer state from the DOM events
342+
_updatePointerLiveness(e: InputEvent | RenderFrameEvent) {
343+
switch (e.type) {
344+
case 'mousedown':
345+
this._mouseBeingPressed = true;
346+
break;
347+
case 'mousemove':
348+
// A mousemove with no buttons held means the press was released
349+
// outside the window/iframe and no mouseup was delivered.
350+
if ((e as MouseEvent).buttons === 0) {
351+
this._mouseBeingPressed = false;
352+
}
353+
break;
354+
case 'mouseup':
355+
this._mouseBeingPressed = false;
356+
break;
357+
case 'touchstart':
358+
case 'touchmove':
359+
case 'touchend':
360+
case 'touchcancel':
361+
this._activeTouchCount = (e as TouchEvent).touches ? (e as TouchEvent).touches.length : 0;
362+
break;
363+
case 'blur':
364+
this._mouseBeingPressed = false;
365+
this._activeTouchCount = 0;
366+
break;
367+
}
368+
}
369+
370+
// Whether the user is still physically holding a pointer down (mouse or touch).
371+
// Helps avoid an in-progress gesture being torn down unexpectedly.
372+
_isPointerDown(): boolean {
373+
return this._mouseBeingPressed || this._activeTouchCount > 0;
374+
}
375+
329376
handleEvent(e: InputEvent | RenderFrameEvent, eventName?: string) {
377+
this._updatePointerLiveness(e);
330378
this._updatingCamera = true;
331379
assert(e.timeStamp !== undefined);
332380

@@ -384,7 +432,7 @@ class HandlerManager {
384432
}
385433

386434
if (Object.keys(activeHandlers).length || hasChange(mergedHandlerResult)) {
387-
this._map._stop(true);
435+
this._map._stop({keepGesture: 'always'});
388436
}
389437

390438
this._updatingCamera = false;
@@ -478,7 +526,7 @@ class HandlerManager {
478526
}
479527

480528
// Catches double click and double tap zooms when camera is constrained over terrain
481-
if (tr._isCameraConstrained) map._stop(true);
529+
if (tr._isCameraConstrained) map._stop({keepGesture: 'always'});
482530

483531
if (!hasChange(combinedResult)) {
484532
this._fireEvents(combinedEventsInProgress, deactivatedHandlers, true);
@@ -509,7 +557,7 @@ class HandlerManager {
509557
tr.cameraElevationReference = "sea";
510558

511559
// stop any ongoing camera animations (easeTo, flyTo)
512-
map._stop(true);
560+
map._stop({keepGesture: 'always'});
513561

514562
around = around || map.transform.centerPoint;
515563
if (bearingDelta) tr.bearing += bearingDelta;

test/unit/ui/handler/drag_pan.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,3 +628,94 @@ test('Dragging to the left', async () => {
628628
expect(equalWithPrecision(-35.15596, lng, 0.001)).toBeTruthy();
629629
expect(equalWithPrecision(-0.00029, lat, 0.001)).toBeTruthy();
630630
});
631+
632+
test('TouchPanHandler keeps the drag alive when a camera setter is called from a drag handler', () => {
633+
const map = createMap();
634+
const target = map.getCanvas();
635+
636+
let dragCount = 0;
637+
map.on('drag', () => {
638+
dragCount++;
639+
map.setPitch(map.getPitch() + 1);
640+
});
641+
642+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
643+
simulate.touchstart(target, {touches: [constructTouch(target, {target, identifier: 1, clientX: 0, clientY: 0})]});
644+
map._renderTaskQueue.run();
645+
646+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
647+
simulate.touchmove(target, {touches: [constructTouch(target, {target, identifier: 1, clientX: 0, clientY: 10})]});
648+
map._renderTaskQueue.run();
649+
expect(dragCount).toBe(1);
650+
651+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
652+
simulate.touchmove(target, {touches: [constructTouch(target, {target, identifier: 1, clientX: 0, clientY: 20})]});
653+
map._renderTaskQueue.run();
654+
expect(dragCount).toBe(2);
655+
656+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
657+
simulate.touchend(target);
658+
map._renderTaskQueue.run();
659+
660+
map.remove();
661+
});
662+
663+
test('DragPanHandler keeps the drag alive when a camera setter is called from a drag handler (GLJS-1102)', () => {
664+
const map = createMap();
665+
666+
let dragCount = 0;
667+
map.on('drag', () => {
668+
dragCount++;
669+
map.setPitch(map.getPitch() + 1);
670+
});
671+
672+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
673+
simulate.mousedown(map.getCanvas());
674+
map._renderTaskQueue.run();
675+
676+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
677+
simulate.mousemove(window.document, {buttons, clientX: 10, clientY: 10});
678+
map._renderTaskQueue.run();
679+
expect(dragCount).toBe(1);
680+
681+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
682+
simulate.mousemove(window.document, {buttons, clientX: 20, clientY: 20});
683+
map._renderTaskQueue.run();
684+
expect(dragCount).toBe(2);
685+
686+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
687+
simulate.mouseup(window.document);
688+
map._renderTaskQueue.run();
689+
690+
map.remove();
691+
});
692+
693+
test('map.stop() ends an in-progress drag even while the pointer is still down', () => {
694+
const map = createMap();
695+
696+
const drag = vi.fn();
697+
map.on('drag', drag);
698+
699+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
700+
simulate.mousedown(map.getCanvas());
701+
map._renderTaskQueue.run();
702+
703+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
704+
simulate.mousemove(window.document, {buttons, clientX: 10, clientY: 10});
705+
map._renderTaskQueue.run();
706+
expect(drag).toHaveBeenCalledTimes(1);
707+
708+
// The public stop() uses keepGesture 'never' and tears down even a live gesture.
709+
map.stop();
710+
711+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
712+
simulate.mousemove(window.document, {buttons, clientX: 20, clientY: 20});
713+
map._renderTaskQueue.run();
714+
expect(drag).toHaveBeenCalledTimes(1);
715+
716+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
717+
simulate.mouseup(window.document);
718+
map._renderTaskQueue.run();
719+
720+
map.remove();
721+
});

0 commit comments

Comments
 (0)