Skip to content

Commit

Permalink
Merge pull request #52 from abrin/add_alternate_touch_event_models
Browse files Browse the repository at this point in the history
Add alternate touch event models
  • Loading branch information
stephenwf authored Jun 13, 2024
2 parents f7e4211 + a6aad8a commit f526bf9
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 100 deletions.
27 changes: 24 additions & 3 deletions src/modules/browser-event-manager/browser-event-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class BrowserEventManager {
activatedEvents: string[] = [];
eventHandlers: [string, any][] = [];
bounds: DOMRect;

listening: boolean;
static eventPool = {
atlas: { x: 0, y: 0 },
};
Expand Down Expand Up @@ -50,6 +50,7 @@ export class BrowserEventManager {
this.runtime = runtime;
this.unsubscribe = runtime.world.addLayoutSubscriber(this.layoutSubscriber.bind(this));
this.bounds = element.getBoundingClientRect();
this.listening = false;
this.options = {
simulationRate: 0,
...(options || {}),
Expand All @@ -70,7 +71,7 @@ export class BrowserEventManager {
}
});

// @todo temp.
// this is necessary for CavnasPanel to initialize the event listener
this.activateEvents();
}

Expand All @@ -80,7 +81,7 @@ export class BrowserEventManager {
}

layoutSubscriber(type: string) {
if (type === 'event-activation') {
if (type === 'event-activation' && this.listening == false) {
this.activateEvents();
}
}
Expand All @@ -92,6 +93,7 @@ export class BrowserEventManager {
}

activateEvents() {
this.listening = true;
this.element.addEventListener('pointermove', this._realPointerMove);
this.element.addEventListener('pointerup', this.onPointerUp);
this.element.addEventListener('pointerdown', this.onPointerDown);
Expand Down Expand Up @@ -292,6 +294,25 @@ export class BrowserEventManager {
}

stop() {
this.listening = false;
this.element.removeEventListener('pointermove', this._realPointerMove);
this.element.removeEventListener('pointerup', this.onPointerUp);
this.element.removeEventListener('pointerdown', this.onPointerDown);

// Normal events.
this.element.removeEventListener('mousedown', this.onPointerEvent);
this.element.removeEventListener('mouseup', this.onPointerEvent);
this.element.removeEventListener('pointercancel', this.onPointerEvent);

// Edge-cases
this.element.removeEventListener('wheel', this.onWheelEvent);

// Touch events.
this.element.removeEventListener('touchstart', this.onTouchEvent);
this.element.removeEventListener('touchcancel', this.onTouchEvent);
this.element.removeEventListener('touchend', this.onTouchEvent);
this.element.removeEventListener('touchmove', this.onTouchEvent);

// Unbind all events.
this.unsubscribe();
for (const [event, handler] of this.eventHandlers) {
Expand Down
201 changes: 106 additions & 95 deletions src/modules/popmotion-controller/popmotion-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { RuntimeController } from '../../types';
import { easingFunctions } from '../../utility/easing-functions';
import { toBox } from '../../utility/to-box';

const INTENT_PAN = 'pan';
const INTENT_SCROLL = 'scroll';
const INTENT_GESTURE = 'gesture';

export type PopmotionControllerConfig = {
zoomOutFactor?: number;
zoomInFactor?: number;
Expand All @@ -23,6 +27,11 @@ export type PopmotionControllerConfig = {
devicePixelRatio?: number;
enableWheel?: boolean;
enableClickToZoom?: boolean;
ignoreSingleFingerTouch?: boolean;
enablePanOnWait?: boolean;
requireMetaKeyForWheelZoom?: boolean;
panOnWaitDelay?: number;
parentElement?: HTMLElement | null;
onPanInSketchMode?: () => void;
};

Expand All @@ -47,16 +56,30 @@ export const defaultConfig: Required<PopmotionControllerConfig> = {
devicePixelRatio: 1,
// Flags
enableWheel: true,
enableClickToZoom: false,
enableClickToZoom: true,
ignoreSingleFingerTouch: false,
enablePanOnWait: false,
requireMetaKeyForWheelZoom: false,
panOnWaitDelay: 40,
onPanInSketchMode: () => {
// no-op
},
parentElement: null,
};

export const popmotionController = (config: PopmotionControllerConfig = {}): RuntimeController => {
return {
start: function (runtime) {
const { zoomWheelConstant, enableWheel, enableClickToZoom } = {
const {
zoomWheelConstant,
enableWheel,
enableClickToZoom,
ignoreSingleFingerTouch,
enablePanOnWait,
panOnWaitDelay,
parentElement,
requireMetaKeyForWheelZoom,
} = {
...defaultConfig,
...config,
};
Expand All @@ -77,97 +100,23 @@ export const popmotionController = (config: PopmotionControllerConfig = {}): Run
'onMouseMove',
'onTouchStart',
'onTouchEnd',
'onTouchMove',
'onPointerUp',
'onPointerDown',
'onPointerMove'
'onTouchMove'
);

// el.onpointerdown = pointerdown_handler;
// el.onpointermove = pointermove_handler;
//
// // Use same handler for pointer{up,cancel,out,leave} events since
// // the semantics for these events - in this app - are the same.
// el.onpointerup = pointerup_handler;
// el.onpointercancel = pointerup_handler;
// el.onpointerout = pointerup_handler;
// el.onpointerleave = pointerup_handler;
const eventCache: PointerEvent[] = [];
const atlasPointsCache: any[] = [];
let prevDiff = -1;
function removeFromEventCache(e: PointerEvent) {
// Remove this event from the target's cache
for (let i = 0; i < eventCache.length; i++) {
if (eventCache[i].pointerId == e.pointerId) {
eventCache.splice(i, 1);
atlasPointsCache.splice(i, 1);
break;
}
}
}

function pointerDown(e: PointerEvent) {
eventCache.push(e);
atlasPointsCache.push({ ...((e as any).atlas || {}) });
}
function pointerMove(e: PointerEvent) {
for (let i = 0; i < eventCache.length; i++) {
if (e.pointerId == eventCache[i].pointerId) {
eventCache[i] = e;
atlasPointsCache[i] = { ...((e as any).atlas || {}) };
break;
}
}
if (eventCache.length == 2) {
const curDiff = Math.abs(eventCache[0].clientX - eventCache[1].clientX);

// - - 2 - - 6- - - - 10
const xDiff =
atlasPointsCache[0].x > atlasPointsCache[1].x
? atlasPointsCache[0].x - atlasPointsCache[1].x
: atlasPointsCache[1].x - atlasPointsCache[0].x;
const yDiff =
atlasPointsCache[0].y > atlasPointsCache[1].y
? atlasPointsCache[0].y - atlasPointsCache[1].y
: atlasPointsCache[1].y - atlasPointsCache[0].y;

if (prevDiff > 0) {
if (curDiff > prevDiff) {
runtime.world.zoomTo(
// Generating a zoom from the wheel delta
0.95,
{ x: xDiff / 2, y: yDiff / 2 },
true
);
}
if (curDiff < prevDiff) {
runtime.world.zoomTo(
// Generating a zoom from the wheel delta
1.05,
{ x: xDiff / 2, y: yDiff / 2 },
true
);
}
}

// Cache the distance for the next move event
prevDiff = curDiff;
}
}

function pointerUp(e: PointerEvent) {
// Remove this pointer from the cache and reset the target's
// background and border
removeFromEventCache(e);
// If the number of pointers down is less than two then reset diff tracker
if (eventCache.length < 2) {
prevDiff = -1;
}
/**
* Resets the event state after the gesture of behavior has finished
*/
function resetState() {
currentDistance = 0;
intent = '';
setDataAttribute();
setDataAttribute(undefined, 'notice');
touchStartTime = 0;
}

function onMouseUp() {
runtime.world.constraintBounds();
currentDistance = 0;
resetState();
}

function onMouseDown(e: MouseEvent & { atlas: { x: number; y: number } }) {
Expand All @@ -187,6 +136,7 @@ export const popmotionController = (config: PopmotionControllerConfig = {}): Run
}

function onWindowMouseUp() {
resetState();
if (state.isPressing) {
if (runtime.mode === 'explore') {
runtime.world.constraintBounds();
Expand All @@ -196,14 +146,23 @@ export const popmotionController = (config: PopmotionControllerConfig = {}): Run
}

let currentDistance = 0;
// the performance.now() time at 'touch-start'
let touchStartTime = 0;
// what the user's intent would be for the behavior
let intent = '';
function onTouchStart(e: TouchEvent & { atlasTouches: Array<{ id: number; x: number; y: number }> }) {
if (runtime.mode === 'explore') {
if (e.atlasTouches.length === 1) {
e.preventDefault();
touchStartTime = performance.now();
if (ignoreSingleFingerTouch == false) {
// this prevents the touch propagation to the window, and thus doesn't drag the page
e.preventDefault();
}
state.pointerStart.x = e.atlasTouches[0].x;
state.pointerStart.y = e.atlasTouches[0].y;
}
if (e.atlasTouches.length === 2) {
intent = INTENT_GESTURE;
e.preventDefault();
const x1 = e.atlasTouches[0].x;
const x2 = e.atlasTouches[1].x;
Expand All @@ -224,12 +183,23 @@ export const popmotionController = (config: PopmotionControllerConfig = {}): Run
}
}

/**
* Sets a data attribute to expose the current intent/behavior/note to the user
*
* @param value {string} - the data-attribute value
* @param dataAttribute {string} - the data-attribute name
*/
function setDataAttribute(value?: string, dataAttribute = 'intent') {
if (parentElement) {
parentElement.dataset[dataAttribute] = value;
}
}

function onTouchMove(e: TouchEvent & { atlasTouches: Array<{ id: number; x: number; y: number }> }) {
let clientX = null;
let clientY = null;
let isMulti = false;
let newDistance = 0;

if (state.isPressing && e.touches.length === 2) {
// We have 2?
const x1 = e.touches[0].clientX;
Expand All @@ -245,7 +215,26 @@ export const popmotionController = (config: PopmotionControllerConfig = {}): Run
);
isMulti = true;
}
setDataAttribute(intent);

if (state.isPressing && e.touches.length === 1) {
if (enablePanOnWait) {
// if there is a delay between the touch-start and the 1st touch-move of < xms, then treat that as a PAN,
// anything faster is a window scroll
if (performance.now() - touchStartTime < panOnWaitDelay && intent == '') {
intent = INTENT_SCROLL;
}
if (intent == '') {
intent = INTENT_PAN;
}
}
setDataAttribute(intent);
// if we are ignoring a single finger touch, or it's a window-scroll, just 'return'
if ((intent == '' && ignoreSingleFingerTouch == true) || intent == INTENT_SCROLL) {
// have CanvasPanel do nothing... scroll the page
setDataAttribute('require-two-finger', 'notice');
return;
}
const touch = e.touches[0];
clientX = touch.clientX;
clientY = touch.clientY;
Expand Down Expand Up @@ -277,6 +266,12 @@ export const popmotionController = (config: PopmotionControllerConfig = {}): Run
}
currentDistance = newDistance;
}

if (intent == INTENT_PAN) {
// if we're panning, prevent default
// this does the same thing as touchEvents: none; pointerEvents: none;
e.preventDefault();
}
}

function onMouseMove(e: MouseEvent | PointerEvent) {
Expand Down Expand Up @@ -318,9 +313,14 @@ export const popmotionController = (config: PopmotionControllerConfig = {}): Run
);
}

// runtime.world.addEventListener('pointerup', pointerUp);
// runtime.world.addEventListener('pointerdown', pointerDown);
// runtime.world.addEventListener('pointermove', pointerMove);
function onWheelGuard(e: WheelEvent) {
if (requireMetaKeyForWheelZoom && e.metaKey == false) {
setDataAttribute('meta-required', 'notice');
e.stopPropagation();
return false;
}
return true;
}

runtime.world.addEventListener('mouseup', onMouseUp);
runtime.world.addEventListener('touchend', onMouseUp);
Expand All @@ -331,7 +331,12 @@ export const popmotionController = (config: PopmotionControllerConfig = {}): Run
window.addEventListener('mouseup', onWindowMouseUp);

window.addEventListener('mousemove', onMouseMove);
window.addEventListener('touchmove', onTouchMove as any);

if (parentElement) {
// if this is bound to the window, then the entire interaction model goes haywire
// unclear 100% why
parentElement.addEventListener('touchmove', onTouchMove as any);
}

if (enableClickToZoom) {
runtime.world.activatedEvents.push('onClick');
Expand All @@ -340,6 +345,10 @@ export const popmotionController = (config: PopmotionControllerConfig = {}): Run

if (enableWheel) {
runtime.world.activatedEvents.push('onWheel');
if (requireMetaKeyForWheelZoom) {
// add an event listener above the world to guard the wheel event if the 'meta' key is pressed
parentElement?.addEventListener('wheel', onWheelGuard as any, { passive: true, capture: true });
}
runtime.world.addEventListener('wheel', onWheel);
}

Expand Down Expand Up @@ -382,8 +391,10 @@ export const popmotionController = (config: PopmotionControllerConfig = {}): Run
window.removeEventListener('mouseup', onWindowMouseUp);

runtime.world.removeEventListener('mousemove', onMouseMove);
runtime.world.removeEventListener('touchmove', onMouseMove);

if (parentElement) {
(parentElement as any).removeEventListener('touchmove', onMouseMove);
(parentElement as any).removeEventListener('wheel', onWheelGuard, { passive: true, capture: true });
}
if (enableClickToZoom) {
runtime.world.removeEventListener('click', onClick);
}
Expand Down
2 changes: 1 addition & 1 deletion src/modules/react-reconciler/Atlas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@ export const Atlas: React.FC<
<style>{`.atlas-width-${widthClassName} { width: ${restProps.width}px; height: ${restProps.height}px; }`}</style>
) : (
<style>{`
.atlas { position: relative; user-select: none; display: flex; background: var(--atlas-background, #000); z-index: var(--atlas-z-index, 10); touch-action: none; }
.atlas { position: relative; display: flex; background: var(--atlas-background, #000); z-index: var(--atlas-z-index, 10); -webkit-touch-callout: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; }
.atlas-width-${widthClassName} { width: ${restProps.width}px; height: ${restProps.height}px; }
.atlas-canvas { flex: 1 1 0px; }
.atlas-canvas:focus, .atlas-static-container:focus { outline: none }
Expand Down
Loading

0 comments on commit f526bf9

Please sign in to comment.