Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add alternate touch event models #52

Merged
merged 10 commits into from
Jun 13, 2024
Merged
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
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();
abrin marked this conversation as resolved.
Show resolved Hide resolved
}

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

layoutSubscriber(type: string) {
if (type === 'event-activation') {
abrin marked this conversation as resolved.
Show resolved Hide resolved
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 }
stephenwf marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
Loading