Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 210 additions & 39 deletions modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,20 @@ const NO_TRANSITION_PROPS = {

const DEFAULT_INERTIA = 300;
const INERTIA_EASING = t => 1 - (1 - t) * (1 - t);
const MAX_PINCH_ZOOM_DELTA_PER_EVENT = 0.18;
const DOUBLE_TAP_DRAG_INTERVAL = 500;
const DOUBLE_TAP_DRAG_MAX_TAP_DURATION = 350;
const DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE = 28;
const DOUBLE_TAP_DRAG_START_THRESHOLD = 1;
const DOUBLE_TAP_DRAG_PIXELS_PER_ZOOM = 120;

const EVENT_TYPES = {
WHEEL: ['wheel'],
PAN: ['panstart', 'panmove', 'panend'],
PINCH: ['pinchstart', 'pinchmove', 'pinchend'],
MULTI_PAN: ['multipanstart', 'multipanmove', 'multipanend'],
DOUBLE_CLICK: ['dblclick'],
DOUBLE_TAP_DRAG: ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'],
KEYBOARD: ['keydown']
} as const;

Expand Down Expand Up @@ -112,6 +119,28 @@ export type ViewStateChangeParameters<ViewStateT = any> = {

const pinchEventWorkaround: any = {};

type OneFingerTapState = {
pos: [number, number];
time: number;
pointerId?: number;
};

type OneFingerZoomState = {
startPos: [number, number];
pointerId?: number;
active: boolean;
};

function getDistance(a: [number, number], b: [number, number]): number {
const dx = a[0] - b[0];
const dy = a[1] - b[1];
return Math.sqrt(dx * dx + dy * dy);
}

function clampPinchZoomDelta(delta: number): number {
return Math.max(-MAX_PINCH_ZOOM_DELTA_PER_EVENT, Math.min(MAX_PINCH_ZOOM_DELTA_PER_EVENT, delta));
}

export default abstract class Controller<ControllerState extends IViewState<ControllerState>> {
abstract get ControllerState(): ConstructorOf<ControllerState>;
abstract get transition(): TransitionProps;
Expand All @@ -135,6 +164,10 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
private _customEvents: string[] = [];
private _eventStartBlocked: any = null;
private _panMove: boolean = false;
private _tapStart: OneFingerTapState | null = null;
private _lastTap: OneFingerTapState | null = null;
private _oneFingerZoom: OneFingerZoomState | null = null;
private _suppressDoubleClickUntil: number = 0;

protected invertPan: boolean = false;
protected dragMode: 'pan' | 'rotate' = 'rotate';
Expand Down Expand Up @@ -209,10 +242,19 @@ export default abstract class Controller<ControllerState extends IViewState<Cont

switch (event.type) {
case 'panstart':
if (this._oneFingerZoom) {
return false;
}
return eventStartBlocked ? false : this._onPanStart(event);
case 'panmove':
if (this._oneFingerZoom) {
return false;
}
return this._onPan(event);
case 'panend':
if (this._oneFingerZoom) {
return false;
}
return this._onPanEnd(event);
case 'pinchstart':
return eventStartBlocked ? false : this._onPinchStart(event);
Expand All @@ -228,6 +270,13 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
return this._onMultiPanEnd(event);
case 'dblclick':
return this._onDoubleClick(event);
case 'pointerdown':
return this._onPointerDown(event);
case 'pointermove':
return this._onPointerMove(event);
case 'pointerup':
case 'pointercancel':
return this._onPointerUp(event);
case 'wheel':
return this._onWheel(event as MjolnirWheelEvent);
case 'keydown':
Expand Down Expand Up @@ -328,6 +377,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
this.toggleEvents(EVENT_TYPES.PINCH, isInteractive && (touchZoom || touchRotate));
this.toggleEvents(EVENT_TYPES.MULTI_PAN, isInteractive && touchRotate);
this.toggleEvents(EVENT_TYPES.DOUBLE_CLICK, isInteractive && doubleClickZoom);
this.toggleEvents(EVENT_TYPES.DOUBLE_TAP_DRAG, isInteractive && touchZoom);
this.toggleEvents(EVENT_TYPES.KEYBOARD, isInteractive && keyboard);

// Interaction toggles
Expand Down Expand Up @@ -641,6 +691,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont

// Default handler for the `pinchstart` event.
protected _onPinchStart(event: MjolnirGestureEvent): boolean {
this._resetOneFingerZoom();
const pos = this.getCenter(event);
if (!this.isPointInBounds(pos, event)) {
return false;
Expand All @@ -649,7 +700,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
const newControllerState = this.controllerState.zoomStart({pos}).rotateStart({pos});
// hack - hammer's `rotation` field doesn't seem to produce the correct angle
pinchEventWorkaround._startPinchRotation = event.rotation;
pinchEventWorkaround._lastPinchEvent = event;
pinchEventWorkaround._smoothedPinchScaleLog = 0;
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {isDragging: true});
return true;
}
Expand All @@ -667,7 +718,12 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
if (this.touchZoom) {
const {scale} = event;
const pos = this.getCenter(event);
newControllerState = newControllerState.zoom({pos, scale});
const rawScaleLog = Math.log2(scale);
const previousScaleLog = pinchEventWorkaround._smoothedPinchScaleLog ?? 0;
const smoothedScaleLog =
previousScaleLog + clampPinchZoomDelta(rawScaleLog - previousScaleLog);
pinchEventWorkaround._smoothedPinchScaleLog = smoothedScaleLog;
newControllerState = newControllerState.zoom({pos, scale: Math.pow(2, smoothedScaleLog)});
}
if (this.touchRotate) {
const {rotation} = event;
Expand All @@ -682,51 +738,22 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
isZooming: this.touchZoom,
isRotating: this.touchRotate
});
pinchEventWorkaround._lastPinchEvent = event;
return true;
}

protected _onPinchEnd(event: MjolnirGestureEvent): boolean {
if (!this.isDragging()) {
return false;
}
const {inertia} = this;
const {_lastPinchEvent} = pinchEventWorkaround;
if (this.touchZoom && inertia && _lastPinchEvent && event.scale !== _lastPinchEvent.scale) {
const pos = this.getCenter(event);
let newControllerState = this.controllerState.rotateEnd();
const z = Math.log2(event.scale);
const velocityZ =
(z - Math.log2(_lastPinchEvent.scale)) / (event.deltaTime - _lastPinchEvent.deltaTime);
const endScale = Math.pow(2, z + (velocityZ * inertia) / 2);
newControllerState = newControllerState.zoom({pos, scale: endScale}).zoomEnd();

this.updateViewport(
newControllerState,
{
...this._getTransitionProps({around: pos}),
transitionDuration: inertia,
transitionEasing: INERTIA_EASING
},
{
isDragging: false,
isPanning: this.touchZoom,
isZooming: this.touchZoom,
isRotating: false
}
);
this.blockEvents(inertia);
} else {
const newControllerState = this.controllerState.zoomEnd().rotateEnd();
this.updateViewport(newControllerState, null, {
isDragging: false,
isPanning: false,
isZooming: false,
isRotating: false
});
}
const newControllerState = this.controllerState.zoomEnd().rotateEnd();
this.updateViewport(newControllerState, null, {
isDragging: false,
isPanning: false,
isZooming: false,
isRotating: false
});
pinchEventWorkaround._startPinchRotation = null;
pinchEventWorkaround._lastPinchEvent = null;
pinchEventWorkaround._smoothedPinchScaleLog = null;
return true;
}

Expand All @@ -735,6 +762,9 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
if (!this.doubleClickZoom) {
return false;
}
if (Date.now() < this._suppressDoubleClickUntil) {
return false;
}
const pos = this.getCenter(event);
if (!this.isPointInBounds(pos, event)) {
return false;
Expand All @@ -751,6 +781,147 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
return true;
}

protected _onPointerDown(event: MjolnirEvent): boolean {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code feels very low level and should go in mjolnir, WDYT @Pessimistress ?

if (!this.touchZoom || !this._isPrimaryPointer(event)) {
this._resetOneFingerZoom();
return false;
}

const pos = this.getCenter(event as MjolnirGestureEvent);
if (!this.isPointInBounds(pos, event)) {
this._resetOneFingerZoom();
return false;
}

const time = this._getEventTime(event);
const pointerId = (event.srcEvent as PointerEvent).pointerId;
if (
this._lastTap &&
time - this._lastTap.time <= DOUBLE_TAP_DRAG_INTERVAL &&
getDistance(pos, this._lastTap.pos) <= DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE
) {
this._tapStart = null;
this._lastTap = null;
this._oneFingerZoom = {startPos: pos, pointerId, active: false};
event.srcEvent.preventDefault();
event.stopPropagation();
return true;
}

this._tapStart = {pos, time, pointerId};
this._lastTap = null;
if ((event.srcEvent as PointerEvent).pointerType === 'touch') {
event.srcEvent.preventDefault();
}
return false;
}

protected _onPointerMove(event: MjolnirEvent): boolean {
const oneFingerZoom = this._oneFingerZoom;
if (!oneFingerZoom || !this._isSamePointer(event, oneFingerZoom.pointerId)) {
return false;
}

const pos = this.getCenter(event as MjolnirGestureEvent);
const dy = oneFingerZoom.startPos[1] - pos[1];
if (!oneFingerZoom.active && Math.abs(dy) < DOUBLE_TAP_DRAG_START_THRESHOLD) {
event.srcEvent.preventDefault();
event.stopPropagation();
return true;
}

const scale = Math.pow(2, dy / DOUBLE_TAP_DRAG_PIXELS_PER_ZOOM);
const startPos = oneFingerZoom.startPos;
let newControllerState = this.controllerState;
if (!oneFingerZoom.active) {
oneFingerZoom.active = true;
newControllerState = newControllerState.zoomStart({pos: startPos});
}
newControllerState = newControllerState.zoom({pos: startPos, scale});
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
isDragging: true,
isPanning: true,
isZooming: true
});

event.srcEvent.preventDefault();
event.stopPropagation();
return true;
}

protected _onPointerUp(event: MjolnirEvent): boolean {
const oneFingerZoom = this._oneFingerZoom;
if (oneFingerZoom && this._isSamePointer(event, oneFingerZoom.pointerId)) {
this._oneFingerZoom = null;
if (oneFingerZoom.active) {
const newControllerState = this.controllerState.zoomEnd();
this.updateViewport(newControllerState, null, {
isDragging: false,
isPanning: false,
isZooming: false
});
this._suppressDoubleClickUntil = Date.now() + 100;
this.blockEvents(100);
event.srcEvent.preventDefault();
event.stopPropagation();
return true;
}
return false;
}

if (event.type === 'pointercancel') {
this._resetOneFingerZoom();
return false;
}

const tapStart = this._tapStart;
if (!tapStart || !this._isSamePointer(event, tapStart.pointerId)) {
return false;
}

const pos = this.getCenter(event as MjolnirGestureEvent);
const time = this._getEventTime(event);
if (
time - tapStart.time <= DOUBLE_TAP_DRAG_MAX_TAP_DURATION &&
getDistance(pos, tapStart.pos) <= DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE
) {
this._lastTap = {pos, time, pointerId: tapStart.pointerId};
} else {
this._lastTap = null;
}
this._tapStart = null;
if ((event.srcEvent as PointerEvent).pointerType === 'touch') {
event.srcEvent.preventDefault();
}
return false;
}

private _resetOneFingerZoom(): void {
this._tapStart = null;
this._lastTap = null;
this._oneFingerZoom = null;
}

private _getEventTime(event: MjolnirEvent): number {
return (event as any).timeStamp || event.srcEvent.timeStamp || Date.now();
}

private _isPrimaryPointer(event: MjolnirEvent): boolean {
const pointers = (event as any).pointers;
if (pointers && pointers.length > 1) {
return false;
}
const srcEvent = event.srcEvent as PointerEvent;
if (srcEvent.pointerType === 'mouse') {
return (event as any).leftButton !== false;
}
return true;
}

private _isSamePointer(event: MjolnirEvent, pointerId?: number): boolean {
return pointerId === undefined || (event.srcEvent as PointerEvent).pointerId === pointerId;
}

// Default handler for the `keydown` event
protected _onKeyDown(event: MjolnirKeyEvent): boolean {
if (!this.keyboard) {
Expand Down
Loading
Loading