mirror of
https://github.com/keymanapp/keyman.git
synced 2026-10-14 09:57:30 +00:00
194 lines
No EOL
7.6 KiB
TypeScript
194 lines
No EOL
7.6 KiB
TypeScript
import { GestureRecognizerConfiguration } from "./configuration/gestureRecognizerConfiguration.js";
|
|
import { InputEventEngine } from "./inputEventEngine.js";
|
|
import { InputSample } from "./headless/inputSample.js";
|
|
import { Nonoptional } from "./nonoptional.js";
|
|
import { ZoneBoundaryChecker } from "./configuration/zoneBoundaryChecker.js";
|
|
import { GestureSource } from "./headless/gestureSource.js";
|
|
|
|
function touchListToArray(list: TouchList) {
|
|
const arr: Touch[] = [];
|
|
|
|
for(let i=0; i < list.length; i++) {
|
|
arr.push(list.item(i));
|
|
}
|
|
|
|
return arr;
|
|
}
|
|
export class TouchEventEngine<HoveredItemType, StateToken = any> extends InputEventEngine<HoveredItemType, StateToken> {
|
|
private readonly _touchStart: typeof TouchEventEngine.prototype.onTouchStart;
|
|
private readonly _touchMove: typeof TouchEventEngine.prototype.onTouchMove;
|
|
private readonly _touchEnd: typeof TouchEventEngine.prototype.onTouchEnd;
|
|
|
|
private safeBoundMaskMap: {[id: number]: number} = {};
|
|
|
|
public constructor(config: Nonoptional<GestureRecognizerConfiguration<HoveredItemType, StateToken>>) {
|
|
super(config);
|
|
|
|
// We use this approach, rather than .bind, because _this_ version allows hook
|
|
// insertion for unit tests via prototype manipulation. The .bind version doesn't.
|
|
this._touchStart = (event: TouchEvent) => this.onTouchStart(event);
|
|
this._touchMove = (event: TouchEvent) => this.onTouchMove(event);
|
|
this._touchEnd = (event: TouchEvent) => this.onTouchEnd(event);
|
|
}
|
|
|
|
private get eventRoot(): HTMLElement {
|
|
return this.config.touchEventRoot;
|
|
}
|
|
|
|
// public static forPredictiveBanner(banner: SuggestionBanner, handlerRoot: SuggestionManager) {
|
|
// const config: GestureRecognizerConfiguration = {
|
|
// targetRoot: banner.getDiv(),
|
|
// // document.body is the event root b/c we need to track the mouse if it leaves
|
|
// // the VisualKeyboard's hierarchy.
|
|
// eventRoot: banner.getDiv(),
|
|
// };
|
|
|
|
// return new TouchEventEngine(config);
|
|
// }
|
|
|
|
registerEventHandlers() {
|
|
// The 'passive' property ensures we can prevent MouseEvent followups from TouchEvents.
|
|
// It is only specified during `addEventListener`, not during `removeEventListener`.
|
|
this.eventRoot.addEventListener('touchstart', this._touchStart, {capture: true, passive: false});
|
|
this.eventRoot.addEventListener('touchmove', this._touchMove, {capture: false, passive: false});
|
|
// The listener below fails to capture when performing automated testing checks in Chrome emulation unless 'true'.
|
|
this.eventRoot.addEventListener('touchend', this._touchEnd, {capture: true, passive: false});
|
|
}
|
|
|
|
unregisterEventHandlers() {
|
|
this.eventRoot.removeEventListener('touchstart', this._touchStart, true);
|
|
this.eventRoot.removeEventListener('touchmove', this._touchMove, false);
|
|
this.eventRoot.removeEventListener('touchend', this._touchEnd, true);
|
|
}
|
|
|
|
private preventPropagation(e: TouchEvent) {
|
|
// Standard event maintenance
|
|
if(e.cancelable) {
|
|
// Chrome generates error-log messages if this is attempted while
|
|
// the condition is false.
|
|
e.preventDefault();
|
|
}
|
|
|
|
if(typeof e.stopImmediatePropagation == 'function') {
|
|
e.stopImmediatePropagation();
|
|
} else if(typeof e.stopPropagation == 'function') {
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
|
|
public dropTouchpoint(source: GestureSource<HoveredItemType>) {
|
|
super.dropTouchpoint(source);
|
|
|
|
for(const key of Object.keys(this.safeBoundMaskMap)) {
|
|
if(this.getTouchpointWithId(Number.parseInt(key, 10)) == source) {
|
|
delete this.safeBoundMaskMap[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
private buildSampleFromTouch(touch: Touch, timestamp: number) {
|
|
// WILL be null for newly-starting `GestureSource`s / contact points.
|
|
const source = this.getTouchpointWithId(touch.identifier);
|
|
return this.buildSampleFor(touch.clientX, touch.clientY, touch.target, timestamp, source);
|
|
}
|
|
|
|
onTouchStart(event: TouchEvent) {
|
|
// If it's not an event we'd consider handling, do not prevent event
|
|
// propagation! Just don't process it.
|
|
if(!this.config.targetRoot.contains(event.target as Node)) {
|
|
return;
|
|
}
|
|
|
|
this.preventPropagation(event);
|
|
|
|
// In case a touch ID is reused, we can pre-emptively filter it for special cases to cancel the old version,
|
|
// noting that it's included by a changedTouch. (Only _new_ contact points are included in .changedTouches
|
|
// during a touchstart.)
|
|
const allTouches = touchListToArray(event.touches);
|
|
const newTouches = touchListToArray(event.changedTouches);
|
|
// Maintain all touches in the `.touches` array that are NOT marked as `.changedTouches` (and therefore, new)
|
|
this.maintainTouchpointsWithIds(allTouches
|
|
.filter((touch1) => newTouches.findIndex(touch2 => touch1.identifier == touch2.identifier) == -1)
|
|
.map((touch) => touch.identifier)
|
|
);
|
|
|
|
// Ensure the same timestamp is used for all touches being updated.
|
|
const timestamp = performance.now();
|
|
|
|
// During a touch-start, only _new_ touch contact points are listed here;
|
|
// we shouldn't signal "input start" for any previously-existing touch points,
|
|
// so `.changedTouches` is the best way forward.
|
|
for(let i=0; i < event.changedTouches.length; i++) {
|
|
const touch = event.changedTouches.item(i);
|
|
const sample = this.buildSampleFromTouch(touch, timestamp);
|
|
|
|
if(!ZoneBoundaryChecker.inputStartOutOfBoundsCheck(sample, this.config)) {
|
|
// If we started very close to a safe zone border, remember which one(s).
|
|
// This is important for input-sequence cancellation check logic.
|
|
this.safeBoundMaskMap[touch.identifier] = ZoneBoundaryChecker.inputStartSafeBoundProximityCheck(sample, this.config);
|
|
} else {
|
|
// This touchpoint shouldn't be considered; do not signal a touchstart for it.
|
|
continue;
|
|
}
|
|
|
|
this.onInputStart(touch.identifier, sample, event.target, true);
|
|
}
|
|
}
|
|
|
|
onTouchMove(event: TouchEvent) {
|
|
let propagationActive = true;
|
|
// Ensure the same timestamp is used for all touches being updated.
|
|
const timestamp = performance.now();
|
|
|
|
this.maintainTouchpointsWithIds(touchListToArray(event.touches)
|
|
.map((touch) => touch.identifier)
|
|
);
|
|
|
|
// Do not change to `changedTouches` - we need a sample for all active touches in order
|
|
// to facilitate path-update synchronization for multi-touch gestures.
|
|
//
|
|
// May be worth doing changedTouches _first_ though.
|
|
for(let i=0; i < event.touches.length; i++) {
|
|
const touch = event.touches.item(i);
|
|
|
|
if(!this.hasActiveTouchpoint(touch.identifier)) {
|
|
continue;
|
|
}
|
|
|
|
if(propagationActive) {
|
|
this.preventPropagation(event);
|
|
propagationActive = false;
|
|
}
|
|
|
|
const config = this.getConfigForId(touch.identifier);
|
|
|
|
const sample = this.buildSampleFromTouch(touch, timestamp);
|
|
|
|
if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, config, this.safeBoundMaskMap[touch.identifier])) {
|
|
this.onInputMove(touch.identifier, sample, touch.target);
|
|
} else {
|
|
this.onInputMoveCancel(touch.identifier, sample, touch.target);
|
|
}
|
|
}
|
|
}
|
|
|
|
onTouchEnd(event: TouchEvent) {
|
|
let propagationActive = true;
|
|
|
|
// Only lists touch contact points that have been lifted; touchmove is raised separately if any movement occurred.
|
|
for(let i=0; i < event.changedTouches.length; i++) {
|
|
const touch = event.changedTouches.item(i);
|
|
|
|
if(!this.hasActiveTouchpoint(touch.identifier)) {
|
|
continue;
|
|
}
|
|
|
|
if(propagationActive) {
|
|
this.preventPropagation(event);
|
|
propagationActive = false;
|
|
}
|
|
|
|
this.onInputEnd(touch.identifier, event.target);
|
|
}
|
|
}
|
|
} |