Merge pull request #9752 from keymanapp/refactor/web/banner-gesture-integration

refactor(web): banner integration with the new gesture engine 🐵
This commit is contained in:
Joshua Horton 2023-10-24 09:13:58 +07:00 committed by GitHub
commit 101daa0f2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 298 additions and 1194 deletions

View file

@ -15,7 +15,7 @@ export const pluginForDowncompiledClassTreeshaking: esbuild.Plugin = {
return {
// Marks any classes compiled by TS (as per the /** @class */ annotation)
// as __PURE__ in order to facilitate tree-shaking.
contents: source.replace('/** @class */', '/* @__PURE__ */ /** @class */'),
contents: source.replaceAll('/** @class */', '/** @__PURE__ */'),
loader: 'js'
}
});

View file

@ -3,10 +3,17 @@ import EventEmitter from 'eventemitter3';
import { DeviceSpec } from '@keymanapp/web-utils';
import { Keyboard, KeyboardProperties } from '@keymanapp/keyboard-processor';
import { type PredictionContext } from '@keymanapp/input-processor';
import InputEventEngine, { InputEventEngineConfig } from '../input/event-interpreter/inputEventEngine.js';
import MouseEventEngine from '../input/event-interpreter/mouseEventEngine.js';
import TouchEventEngine from '../input/event-interpreter/touchEventEngine.js';
import UITouchHandlerBase from '../input/event-interpreter/uiTouchHandlerBase.js';
import {
GestureRecognizer,
GestureRecognizerConfiguration,
GestureSource,
InputSample,
PaddedZoneSource
} from '@keymanapp/gesture-recognizer';
import { BANNER_GESTURE_SET } from './bannerGestureSet.js';
import { createUnselectableElement } from 'keyman/engine/dom-utils';
@ -49,7 +56,7 @@ export abstract class Banner {
* @return {boolean} true if the banner styling changed
* Description Update the height and display styling of the banner
*/
private update() : boolean {
protected update() : boolean {
let ds = this.div.style;
let currentHeightStyle = ds.height;
let currentDisplayStyle = ds.display;
@ -233,6 +240,18 @@ export class BannerSuggestion {
this.display = display;
}
public highlight(on: boolean) {
const elem = this.div;
let classes = elem.className;
let cs = ' ' + SuggestionBanner.TOUCHED_CLASS;
if(on && classes.indexOf(cs) < 0) {
elem.className=classes+cs;
} else {
elem.className=classes.replace(cs,'');
}
}
public isEmpty(): boolean {
return !this._suggestion;
}
@ -287,7 +306,7 @@ export class SuggestionBanner extends Banner {
private options : BannerSuggestion[] = [];
private hostDevice: DeviceSpec;
private manager: SuggestionInputManager;
private gestureEngine: GestureRecognizer<BannerSuggestion>;
private _predictionContext: PredictionContext;
@ -299,13 +318,11 @@ export class SuggestionBanner extends Banner {
this.hostDevice = hostDevice;
this.getDiv().className = this.getDiv().className + ' ' + SuggestionBanner.BANNER_CLASS;
this.buildInternals(false);
this.manager = new SuggestionInputManager(this.getDiv());
this.events = this.manager.events;
this.events = new EventEmitter<SuggestionInputEventMap>(); //this.manager.events;
this.setupInputHandling();
this.gestureEngine = this.setupInputHandling();
}
buildInternals(rtl: boolean) {
@ -342,34 +359,118 @@ export class SuggestionBanner extends Banner {
}
}
private setupInputHandling() {
let inputEngine: InputEventEngine;
if(this.hostDevice.touchable) { // /*&& ('ontouchstart' in window)*/ // Except Chrome emulation doesn't set this.
// Not to mention, it's rather redundant.
inputEngine = this.touchEventConfig;
} else {
inputEngine = this.mouseEventConfig;
private setupInputHandling(): GestureRecognizer<BannerSuggestion> {
const findTargetFrom = (e: HTMLElement): HTMLDivElement => {
try {
if(e) {
if(e.classList.contains('kmw-suggest-option')) {
return e as HTMLDivElement;
}
if(e.parentElement && e.parentElement.classList.contains('kmw-suggest-option')) {
return e.parentElement as HTMLDivElement;
}
}
} catch(ex) {}
return null;
}
inputEngine.registerEventHandlers();
const config: GestureRecognizerConfiguration<BannerSuggestion> = {
targetRoot: this.getDiv(),
maxRoamingBounds: new PaddedZoneSource(this.getDiv(), [-0.333 * this.height]),
// touchEventRoot: this.element, // is the default
itemIdentifier: (sample, target: HTMLElement) => {
let bestMatch: BannerSuggestion = null;
let bestDist = Number.MAX_VALUE;
this.manager.events.on('highlight', (suggestion, on) => {
const elem = suggestion.div;
let classes = elem.className;
let cs = ' ' + SuggestionBanner.TOUCHED_CLASS;
for(const option of this.options) {
const optionBounding = option.div.getBoundingClientRect();
if(on && classes.indexOf(cs) < 0) {
elem.className=classes+cs;
} else {
elem.className=classes.replace(cs,'');
if(optionBounding.left <= sample.clientX && sample.clientX < optionBounding.right) {
return option;
} else {
const dist = (sample.clientX < optionBounding.left ? -1 : 1) * (sample.clientX - optionBounding.left);
if(dist < bestDist) {
bestDist = dist;
bestMatch = option;
}
}
}
return bestMatch;
}
};
const engine = new GestureRecognizer<BannerSuggestion>(BANNER_GESTURE_SET, config);
const sourceTracker: {
source: GestureSource<BannerSuggestion>,
roamingHighlightHandler: (sample: InputSample<BannerSuggestion>) => void,
suggestion: BannerSuggestion
} = {
source: null,
roamingHighlightHandler: null,
suggestion: null
};
engine.on('inputstart', (source) => {
// The banner does not support multi-touch - if one is still current, block all others.
if(sourceTracker.source) {
source.terminate(true);
return;
}
sourceTracker.source = source;
sourceTracker.roamingHighlightHandler = (sample) => {
// Maintain highlighting
const suggestion = sample.item;
if(suggestion != sourceTracker.suggestion) {
sourceTracker.suggestion.highlight(false);
suggestion.highlight(true);
sourceTracker.suggestion = suggestion;
}
};
sourceTracker.suggestion = source.currentSample.item;
source.currentSample.item.highlight(true);
const terminationHandler = () => {
sourceTracker.suggestion.highlight(false);
sourceTracker.source = null;
sourceTracker.roamingHighlightHandler = null;
sourceTracker.suggestion = null;
}
source.path.on('complete', terminationHandler);
source.path.on('invalidated', terminationHandler);
source.path.on('step', sourceTracker.roamingHighlightHandler);
});
this.manager.events.on('apply', (option) => {
if(this.predictionContext) {
this.predictionContext.accept(option.suggestion);
}
engine.on('recognizedgesture', (sequence) => {
// The actual result comes in via the sequence's `stage` event.
sequence.once('stage', (result) => {
const suggestion = result.item; // Should also == sourceTracker.suggestion.
if(suggestion) {
this.predictionContext.accept(suggestion.suggestion);
}
});
});
return engine;
}
protected update() {
const result = super.update();
// Ensure the banner's extended recognition zone is based on proper, up-to-date layout info.
// Note: during banner init, `this.gestureEngine` may only be defined after
// the first call to this setter!
(this.gestureEngine?.config.maxRoamingBounds as PaddedZoneSource)?.updatePadding([-0.333 * this.height]);
return result;
}
public configureForKeyboard(keyboard: Keyboard, keyboardProperties: KeyboardProperties) {
@ -389,36 +490,6 @@ export class SuggestionBanner extends Banner {
this.onSuggestionUpdate(this.currentSuggestions); // restore suggestions
}
private get mouseEventConfig() {
const config: InputEventEngineConfig = {
targetRoot: this.getDiv(),
// document.body is the event root b/c we need to track the mouse if it leaves
// the VisualKeyboard's hierarchy.
eventRoot: document.body,
inputStartHandler: this.manager.touchStart.bind(this.manager),
inputMoveHandler: this.manager.touchMove.bind(this.manager),
inputEndHandler: this.manager.touchEnd.bind(this.manager),
coordConstrainedWithinInteractiveBounds: function() { return true; }
};
return new MouseEventEngine(config);
}
private get touchEventConfig() {
const config: InputEventEngineConfig = {
targetRoot: this.getDiv(),
// document.body is the event root b/c we need to track the mouse if it leaves
// the VisualKeyboard's hierarchy.
eventRoot: this.getDiv(),
inputStartHandler: this.manager.touchStart.bind(this.manager),
inputMoveHandler: this.manager.touchMove.bind(this.manager),
inputEndHandler: this.manager.touchEnd.bind(this.manager),
coordConstrainedWithinInteractiveBounds: function() { return true; }
};
return new TouchEventEngine(config);
}
public get predictionContext(): PredictionContext {
return this._predictionContext;
}
@ -454,117 +525,4 @@ interface SuggestionInputEventMap {
highlight: (bannerSuggestion: BannerSuggestion, state: boolean) => void,
apply: (bannerSuggestion: BannerSuggestion) => void;
hold: (bannerSuggestion: BannerSuggestion) => void;
}
class SuggestionInputManager extends UITouchHandlerBase<HTMLDivElement> {
public readonly events = new EventEmitter<SuggestionInputEventMap>();
private eventDisablePromise: Promise<any>;
platformHold: (suggestion: BannerSuggestion, isCustom: boolean) => void;
//#region Touch handling implementation
findTargetFrom(e: HTMLElement): HTMLDivElement {
try {
if(e) {
if(e.classList.contains('kmw-suggest-option')) {
return e as HTMLDivElement;
}
if(e.parentElement && e.parentElement.classList.contains('kmw-suggest-option')) {
return e.parentElement as HTMLDivElement;
}
// if(e.firstChild && util.hasClass(<HTMLElement> e.firstChild,'kmw-suggest-option')) {
// return e.firstChild as HTMLDivElement;
// }
}
} catch(ex) {}
return null;
}
protected highlight(t: HTMLDivElement, on: boolean): void {
let suggestion = t['suggestion'] as BannerSuggestion;
// Never highlight an empty suggestion button.
if(suggestion.isEmpty()) {
on = false;
}
this.events.emit('highlight', suggestion, on);
}
protected select(t: HTMLDivElement): void {
this.events.emit('apply', t['suggestion'] as BannerSuggestion);
}
//#region Long-press support
protected hold(t: HTMLDivElement): void {
// let suggestionObj = t['suggestion'] as BannerSuggestion;
//
// // Is this the <keep> suggestion? It's never in this.currentSuggestions, so check against that.
// let isCustom = this.currentSuggestions.indexOf(suggestionObj.suggestion) == -1;
this.events.emit('hold', t['suggestion'] as BannerSuggestion);
}
protected clearHolds(): void {
// Temp, pending implementation of suggestion longpress submenus
// - nothing to clear without them -
// only really used in native-KMW
}
protected hasModalPopup(): boolean {
return this.eventsBlocked;
}
protected dealiasSubTarget(target: HTMLDivElement): HTMLDivElement {
return target;
}
protected hasSubmenu(t: HTMLDivElement): boolean {
// Temp, pending implementation of suggestion longpress submenus
// Only really used by native-KMW - see kmwnative's highlightSubKeys func.
return false;
}
protected isSubmenuActive(): boolean {
// Temp, pending implementation of suggestion longpress submenus
// Utilized only by native-KMW - it parallels hasModalPopup() in purpose.
return false;
}
protected displaySubmenuFor(target: HTMLDivElement) {
// Utilized only by native-KMW to show submenus.
throw new Error("Method not implemented.");
}
//#endregion
//#endregion
public get eventsBlocked(): boolean {
return !!this.eventDisablePromise;
}
/**
* Intended for use by the mobile apps, which sometimes 'takes over' touch handling.
* For such cases, input should be blocked within KMW when the apps are managing an
* ongoing touch-hold for any other interaction.
*
* Formerly:
```
let keyman = com.keyman.singleton;
return keyman['osk'].vkbd.subkeyGesture && keyman.isEmbedded;
```
*/
public temporarilyBlockEvents(promise: Promise<void>) { // TODO: ensure connection for embedded mode!
this.eventDisablePromise = promise; // Will require routing; this class is not exported!
promise.finally(() => {
this.eventDisablePromise = null;
})
}
constructor(div: HTMLElement) {
// TODO: Determine appropriate CSS styling names, etc.
super(div, Banner.BANNER_CLASS, SuggestionBanner.TOUCHED_CLASS);
}
}
}

View file

@ -0,0 +1,27 @@
import { deepCopy } from '@keymanapp/web-utils';
import {
gestures,
GestureModelDefs,
InputSample
} from '@keymanapp/gesture-recognizer';
import { BannerSuggestion } from './banner.js';
import { SimpleTapModelWithReset } from "../input/gestures/specsForLayout.js";
export const BannerSimpleTap: gestures.specs.GestureModel<BannerSuggestion> = {
...deepCopy(SimpleTapModelWithReset),
resolutionAction: {
type: 'complete',
item: 'current'
}
};
export const BANNER_GESTURE_SET: GestureModelDefs<BannerSuggestion> = {
gestures: [
BannerSimpleTap
],
sets: {
default: [SimpleTapModelWithReset.id]
}
}

View file

@ -1,7 +1,4 @@
import GlobeHint from "../globehint.interface.js";
import PendingGesture from "../input/gestures/pendingGesture.interface.js";
import { KeyElement } from "../keyElement.js";
import KeyTip from "../keytip.interface.js";
import VisualKeyboard from "../visualKeyboard.js";
export default interface EmbeddedGestureConfig {

View file

@ -14,9 +14,6 @@ export { type KeyElement } from './keyElement.js';
export { type default as OSKBaseKey } from './keyboard-layout/oskBaseKey.js';
export { type default as GlobeHint } from './globehint.interface.js';
export { type default as KeyTip } from './keytip.interface.js';
export { type default as PendingGesture } from './input/gestures/pendingGesture.interface.js';
export { type default as RealizedGesture } from './input/gestures/realizedGesture.interface.js';
export { type default as InputEventCoordinate } from './input/inputEventCoordinate.js';
export { type default as EmbeddedGestureConfig } from './config/embeddedGestureConfig.js';
export { default as Activator, StaticActivator } from './views/activator.js';

View file

@ -1,57 +0,0 @@
import InputEventCoordinate from '../inputEventCoordinate.js';
export type InputHandler = (coord: InputEventCoordinate) => void;
export interface InputEventEngineConfig {
/**
* Specifies the element that input listeners should be attached to.
*/
readonly eventRoot: HTMLElement;
/**
* Specifies the most specific common ancestor element of any event target
* that the InputEventEngine should consider.
*/
readonly targetRoot: HTMLElement;
readonly coordConstrainedWithinInteractiveBounds: (coord: InputEventCoordinate) => boolean;
readonly inputStartHandler?: InputHandler;
readonly inputMoveHandler?: InputHandler;
readonly inputMoveCancelHandler?: InputHandler;
readonly inputEndHandler?: InputHandler;
}
export default abstract class InputEventEngine {
protected readonly config: InputEventEngineConfig;
public constructor(config: InputEventEngineConfig) {
this.config = config;
}
abstract registerEventHandlers();
abstract unregisterEventHandlers();
onInputStart(coord: InputEventCoordinate) {
if(this.config.inputStartHandler) {
this.config.inputStartHandler(coord);
}
}
onInputMove(coord: InputEventCoordinate) {
if(this.config.inputMoveHandler) {
this.config.inputMoveHandler(coord);
}
}
onInputMoveCancel(coord: InputEventCoordinate) {
if(this.config.inputMoveCancelHandler) {
this.config.inputMoveCancelHandler(coord);
}
}
onInputEnd(coord: InputEventCoordinate) {
if(this.config.inputEndHandler) {
this.config.inputEndHandler(coord);
}
}
}

View file

@ -1,95 +0,0 @@
import InputEventEngine, { InputEventEngineConfig } from './inputEventEngine.js';
import InputEventCoordinate from '../inputEventCoordinate.js';
export default class MouseEventEngine extends InputEventEngine {
private readonly _mouseStart: typeof MouseEventEngine.prototype.onMouseStart;
private readonly _mouseMove: typeof MouseEventEngine.prototype.onMouseMove;
private readonly _mouseEnd: typeof MouseEventEngine.prototype.onMouseEnd;
private hasActiveClick: boolean = false;
private ignoreSequence: boolean = false;
public constructor(config: InputEventEngineConfig) {
super(config);
this._mouseStart = this.onMouseStart.bind(this);
this._mouseMove = this.onMouseMove.bind(this);
this._mouseEnd = this.onMouseEnd.bind(this);
}
registerEventHandlers() {
this.config.eventRoot.addEventListener('mousedown', this._mouseStart, true);
this.config.eventRoot.addEventListener('mousemove', this._mouseMove, false);
// The listener below fails to capture when performing automated testing checks in Chrome emulation unless 'true'.
this.config.eventRoot.addEventListener('mouseup', this._mouseEnd, true);
}
unregisterEventHandlers() {
this.config.eventRoot.removeEventListener('mousedown', this._mouseStart, true);
this.config.eventRoot.removeEventListener('mousemove', this._mouseMove, false);
this.config.eventRoot.removeEventListener('mouseup', this._mouseEnd, true);
}
private preventPropagation(e: MouseEvent) {
// Standard event maintenance
e.preventDefault();
e.cancelBubble=true;
e.returnValue=false; // I2409 - Avoid focus loss for visual keyboard events
if(typeof e.stopImmediatePropagation == 'function') {
e.stopImmediatePropagation();
} else if(typeof e.stopPropagation == 'function') {
e.stopPropagation();
}
}
onMouseStart(event: MouseEvent) {
if(!this.config.targetRoot.contains(event.target as Node)) {
this.ignoreSequence = true;
return;
}
this.preventPropagation(event);
this.onInputStart(InputEventCoordinate.fromEvent(event));
this.hasActiveClick = true;
}
onMouseMove(event: MouseEvent) {
if(this.ignoreSequence) {
return;
}
const coord = InputEventCoordinate.fromEvent(event);
if(!event.buttons) {
if(this.hasActiveClick) {
this.hasActiveClick = false;
this.onInputMoveCancel(coord);
}
return;
} else if(!this.hasActiveClick) {
// Can interfere with OSK drag-handlers (title bar, resize bar) otherwise.
return;
}
this.preventPropagation(event);
if(this.config.coordConstrainedWithinInteractiveBounds(coord)) {
this.onInputMove(coord);
} else {
this.onInputMoveCancel(coord);
}
}
onMouseEnd(event: MouseEvent) {
if(this.ignoreSequence) {
this.ignoreSequence = false;
return;
}
if(!event.buttons) {
this.hasActiveClick = false;
}
this.onInputEnd(InputEventCoordinate.fromEvent(event));
}
}

View file

@ -1,60 +0,0 @@
import InputEventEngine, { InputEventEngineConfig } from './inputEventEngine.js';
import InputEventCoordinate from '../inputEventCoordinate.js';
export default class TouchEventEngine extends InputEventEngine {
private readonly _touchStart: typeof TouchEventEngine.prototype.onTouchStart;
private readonly _touchMove: typeof TouchEventEngine.prototype.onTouchMove;
private readonly _touchEnd: typeof TouchEventEngine.prototype.onTouchEnd;
public constructor(config: InputEventEngineConfig) {
super(config);
this._touchStart = this.onTouchStart.bind(this);
this._touchMove = this.onTouchMove.bind(this);
this._touchEnd = this.onTouchEnd.bind(this);
}
registerEventHandlers() {
this.config.eventRoot.addEventListener('touchstart', this._touchStart, true);
this.config.eventRoot.addEventListener('touchmove', this._touchMove, false);
// The listener below fails to capture when performing automated testing checks in Chrome emulation unless 'true'.
this.config.eventRoot.addEventListener('touchend', this._touchEnd, true);
}
unregisterEventHandlers() {
this.config.eventRoot.removeEventListener('touchstart', this._touchStart, true);
this.config.eventRoot.removeEventListener('touchmove', this._touchMove, false);
this.config.eventRoot.removeEventListener('touchend', this._touchEnd, true);
}
private preventPropagation(e: TouchEvent) {
// Standard event maintenance
e.preventDefault();
e.cancelBubble=true;
if(typeof e.stopImmediatePropagation == 'function') {
e.stopImmediatePropagation();
} else if(typeof e.stopPropagation == 'function') {
e.stopPropagation();
}
}
onTouchStart(event: TouchEvent) {
this.onInputStart(InputEventCoordinate.fromEvent(event));
}
onTouchMove(event: TouchEvent) {
this.preventPropagation(event);
const coord = InputEventCoordinate.fromEvent(event);
if(this.config.coordConstrainedWithinInteractiveBounds(coord)) {
this.onInputMove(coord);
} else {
this.onInputMoveCancel(coord);
}
}
onTouchEnd(event: TouchEvent) {
this.onInputEnd(InputEventCoordinate.fromEvent(event));
}
}

View file

@ -1,429 +0,0 @@
import InputEventCoordinate from "../inputEventCoordinate.js";
import { getAbsoluteY } from 'keyman/engine/dom-utils';
/**
* This class was added to facilitate scroll handling for overflow-x elements, though it could
* be extended in the future to accept overflow-y if needed.
*
* This is necessary because of the OSK's need to use `.preventDefault()` for stability; that
* same method blocks native handling of overflow scrolling for touch browsers.
*/
class ScrollState {
// While we don't currently track y-coordinates here, the class is designed
// to permit tracking them with minimal extra effort if we ever decide to do so.
x: number;
totalLength = 0;
// The amount of coordinate 'noise' allowed during a scroll-enabled touch allowed
// before interpreting the currently-ongoing touch command as having scrolled.
static readonly HAS_SCROLLED_FUDGE_FACTOR = 10;
constructor(coord: InputEventCoordinate) {
this.x = coord.x;
this.totalLength = 0;
}
updateTo(coord: InputEventCoordinate): {deltaX: number} {
let x = this.x;
this.x = coord.x;
let deltas = {deltaX: this.x - x};
this.totalLength += Math.abs(deltas.deltaX);
return deltas;
}
public get hasScrolled(): boolean {
// Allow an accidental fudge-factor for overflow element noise during a touch, but not much.
return this.totalLength > ScrollState.HAS_SCROLLED_FUDGE_FACTOR;
}
}
export default abstract class UITouchHandlerBase<Target extends HTMLElement> {
private rowClassMatch: string;
private selectedTargetMatch: string;
private baseElement: HTMLElement;
private touchX: number;
private touchY: number;
private touchCount: number;
private currentTarget: Target;
private scrollTouchState: ScrollState;
private pendingTarget: Target;
constructor(baseElement: HTMLElement, rowClassMatch: string, selectedTargetMatch: string) {
this.baseElement = baseElement;
this.rowClassMatch = rowClassMatch;
this.selectedTargetMatch = selectedTargetMatch;
}
/**
* Finds the internally-preferred target element or submenu target element.
* @param e The DOM element that actually received the touch event.
* May be parent, child, or the actually-desired element itself.
*/
abstract findTargetFrom(e: HTMLElement): Target;
/**
* Highlights the target element as visual feedback representing
* a pending touch.
* @param t The `Target` to highlight
* @param state `true` to apply highlighting, `false` to remove it.
*/
protected abstract highlight(t: Target, state: boolean): void;
/**
* Called whenever the touch-handling analysis determines that the Target has been selected
* @param t The `Target` to activate/execute.
*/
protected abstract select(t: Target): void;
/**
* Requests info on whether or not the indicated `Target` has subkeys or a submenu.
* @param t A `Target`.
*/
protected abstract hasSubmenu(t: Target): boolean;
/**
* Indicates that the user is maintaining a `Touch` on the specified `Target`.
* Popups and-or longpress menus may be appropriate.
* @param t The `Target` being held.
*/
protected abstract hold(t: Target): void;
/**
* Signals that any popup elements (previews, subkey views, etc) should be cancelled.
*/
protected abstract clearHolds(): void;
/**
* Requests a boolean indicating whether or not the UI is currently displaying any input-blocking popup elements.
* Embedded mode should return `true` when the app is displaying popup menus.
*/
protected abstract hasModalPopup(): boolean;
/**
* Designed to support highlighting of prepended base keys on phone form-factor subkey menus.
* @param target The base element with a potential subkey menu alias.
* @returns The aliased submenu version of the `Target`, or the original `Target` if no alias exists.
*/
protected abstract dealiasSubTarget(target: Target): Target;
/**
* Should return true whenever a 'native'-mode submenu (or subkey) display is active.
*/
protected abstract isSubmenuActive(): boolean;
/**
* For 'native' mode - requests that the submenu for the indicated `Target` be instantly displayed.
* @param target The base element with a potential submenu
*/
protected abstract displaySubmenuFor(target: Target);
/**
* Identify the key nearest to (but NOT under) the touch point if at the end of a key row,
* but return null more than about 0.6 key width from the nearest key.
*
* @param {Object} coord A pre-analyzed input coordinate
* @param {Object} t HTML object at touch point
* @param {boolean} omitCurrent Omits any target directly under the touch point.
* @return {Object} nearest key to touch point
*
**/
private findTargetFromTouch(coord: InputEventCoordinate, t: HTMLElement, forMove: boolean): Target {
var x = coord.x;
// Get the UI row beneath touch point (SuggestionBanner div, 'kmw-key-row' if OSK, ...)
while(t && t.className !== undefined && t.className.indexOf(this.rowClassMatch) < 0) {
t = <HTMLElement> t.parentNode;
}
if(!t) {
return null;
}
// Find minimum distance from any key
var k: number, bestMatch=0, dxMax=24, dxMin=100000, x1: number, x2: number;
for(k = 0; k < t.childNodes.length; k++) {
let childNode = t.childNodes[k] as HTMLElement;
if(this.isInvalidTarget(this.findTargetFrom(childNode))) {
continue;
}
x1 = childNode.offsetLeft;
x2 = x1 + childNode.offsetWidth;
// If it lies completely to the right and is the closest so far
let dxRight = x1 - x;
if(dxRight >= 0 && dxRight < dxMin) {
bestMatch = k;
dxMin = dxRight;
}
// If it lies completely to the left and is the closest so far
let dxLeft = x - x2;
if(dxLeft >= 0 && dxLeft < dxMin) {
bestMatch = k;
dxMin = dxLeft;
}
// If it is neither completely to the left nor completely to the right,
// it's under the cursor. Stop the search!
if(dxLeft < 0 && dxRight < 0) {
return this.findTargetFrom(childNode);
}
}
if(dxMin < 100000) {
t = <HTMLElement> t.childNodes[bestMatch];
x1 = t.offsetLeft;
x2 = x1 + t.offsetWidth;
// Limit extended touch area to the larger of 0.6 of the potential target's width and 24 px
if(t.offsetWidth > 40) {
dxMax = 0.6 * t.offsetWidth;
}
if(((x1 - x) >= 0 && (x1 - x) < dxMax) || ((x - x2) >= 0 && (x - x2) < dxMax)) {
return this.findTargetFrom(t);
}
}
return null;
}
findBestTarget(coord: InputEventCoordinate, forMove?: boolean) {
var eventTarget: HTMLElement;
if(forMove) {
const clientX = coord.x + document.body.scrollLeft;
const clientY = coord.y + document.body.scrollTop;
eventTarget = document.elementFromPoint(clientX, clientY) as HTMLElement;
} else {
eventTarget = coord.target as HTMLElement;
}
let target = this.findTargetFrom(eventTarget);
// Should refactor this multi-check a bit for more overall reliability.
if(!target) {
// We didn't find a direct target, so we should look for the closest possible one.
// Filters out invalid targets.
target = this.findTargetFromTouch(coord, eventTarget, forMove);
}
return target;
}
/**
* Reports whether or not a `Target` should be considered invalid. Needed by the OSK for
* hidden keys.
* @param target A `Target` element to be validated.
*/
protected isInvalidTarget(target: Target): boolean {
return false;
}
touchStart(coord: InputEventCoordinate) {
// Determine the selected Target, manage state.
this.currentTarget = this.findBestTarget(coord);
this.touchX = coord.x;
this.touchY = coord.y;
// If popup stuff, immediately return.
this.touchCount = coord.activeInputCount;
if(!this.currentTarget) {
return;
}
// Establish scroll tracking.
let shouldScroll = (this.currentTarget.clientWidth < this.currentTarget.scrollWidth);
this.scrollTouchState = shouldScroll ? new ScrollState(coord) : null;
// Alright, Target acquired! Now to use it:
// Highlight the touched key
this.highlight(this.currentTarget,true);
// If used by the OSK, the special function keys need immediate action
// Add a `checkForImmediates()` to facilitate this.
if(this.pendingTarget) {
this.highlight(this.pendingTarget, false);
this.select(this.pendingTarget);
this.clearHolds();
// Decrement the number of unreleased touch points to prevent
// sending the keystroke again when the key is actually released
this.touchCount--;
} else {
// If this key has subkey, start timer to display subkeys after delay, set up release
this.hold(this.currentTarget);
}
this.pendingTarget = this.currentTarget;
}
touchEnd(coord: InputEventCoordinate): void {
// Prevent incorrect multi-touch behaviour if native or device popup visible
let t = this.currentTarget;
if(this.isSubmenuActive() || this.hasModalPopup()) {
// Ignore release if a multiple touch
if(coord.activeInputCount > 0) {
return;
}
// Cancel (but do not execute) pending key if neither a popup key or the base key
if(t == null || t.id.indexOf('popup') < 0) {
if (this.pendingTarget) {
this.highlight(this.pendingTarget,false);
}
this.clearHolds();
this.pendingTarget = null;
}
}
// Test if moved off screen (effective release point must be corrected for touch point horizontal speed)
// This is not completely effective and needs some tweaking, especially on Android
var x = coord.x;
var beyondEdge = ((x < 2 && this.touchX > 5) || (x > window.innerWidth - 2 && this.touchX < window.innerWidth - 5));
if(this.scrollTouchState) {
beyondEdge = beyondEdge || this.scrollTouchState.hasScrolled;
}
// Save then decrement current touch count
var tc=this.touchCount;
if(this.touchCount > 0) {
this.touchCount--;
}
// Process and clear highlighting of pending target
if(this.pendingTarget) {
this.highlight(this.pendingTarget,false);
// Output character unless moved off key
if(this.pendingTarget.className.indexOf('hidden') < 0 && tc > 0 && !beyondEdge) {
this.select(this.pendingTarget);
}
this.clearHolds();
this.pendingTarget = null;
// Always clear highlighting of current target on release (multi-touch)
} else {
t = this.findBestTarget(coord);
if(t) {
this.highlight(t,false);
}
}
}
/**
* OSK touch move event handler
*
* @param {Object} coord A pre-analyzed input coordinate
*
**/
touchMove(coord: InputEventCoordinate) : void {
//let keyman = com.keyman.singleton;
// Do not attempt to support reselection of target key for overlapped keystrokes
if(coord.activeInputCount > 1 || this.touchCount == 0) {
return;
}
if(this.currentTarget && this.scrollTouchState != null) {
let deltaX = this.scrollTouchState.updateTo(coord).deltaX;
this.currentTarget.scrollLeft -= window.devicePixelRatio * deltaX;
return;
}
// Get touch position
var y = coord.y;
// Move target key and highlighting
var key0 = this.pendingTarget,
key1 = this.findBestTarget(coord, true); // For the OSK, this ALSO gets subkeys.
// If option should not be selectable, how do we re-target?
// Do not move over keys if device popup visible
if(this.hasModalPopup()) {
if(key0) {
this.highlight(key0,false);
}
this.pendingTarget=null;
return;
}
// Use the popup duplicate of the base key if a phone with a visible popup array
key1 = this.dealiasSubTarget(key1);
// Identify current touch position (to manage off-key release)
this.currentTarget = key1;
// Clear previous key highlighting
if(key0 && key1 && key1 !== key0) {
this.highlight(key0,false);
}
// Code below directly related to subkeys should only be triggered within 'native' mode.
// The embedded version instead passes info to the apps to produce their own subkeys in-app.
// If popup is visible, need to move over popup, not over main keyboard
if(key1 && this.hasSubmenu(key1)) {
//this.highlightSubKeys(key1,x,y);
// Currently only used by the banner... which currently does not do submenus.
// // Native-mode: show popup keys immediately if touch moved up towards key array (KMEW-100, Build 353)
// if(!keyman.isEmbedded && (this.touchY-y > 5) && !this.isSubmenuActive()) {
// // Instantly show the submenu.
// this.displaySubmenuFor(key1);
// }
// Once a subkey array is displayed, do not allow changing the base key.
// Keep that array visible and accept no other options until the touch ends.
if(key1 && key1.id.indexOf('popup') < 0) { // TODO: reliant on 'popup' in .id
return;
}
// Highlight the base key on devices that do not append it to the subkey array.
if(key1 && key1.className.indexOf(this.selectedTargetMatch) < 0) {
this.highlight(key1,true);
}
// Cancel touch if moved up and off keyboard, unless popup keys visible
} else {
let base = this.baseElement;
let top = getAbsoluteY(base);
let height = base.offsetHeight;
let yMin = Math.max(5, top - 0.25 * height);
let yMax = (top + height) + 0.25 * height;
if(key0 && (coord.y < yMin || coord.y > yMax)) {
this.highlight(key0,false);
this.clearHolds();
this.pendingTarget = null;
}
}
// Replace the target key, if any, by the new target key
// Do not replace a null target, as that indicates the key has already been released
if(key1 && this.pendingTarget) {
this.pendingTarget = key1;
}
if(this.pendingTarget) {
if(key1 && (key0 != key1 || key1.className.indexOf(this.selectedTargetMatch) < 0)) {
this.highlight(key1,true);
}
}
if(key0 && key1 && (key1 != key0) && (key1.id != '')) {
// Display the touch-hold keys (after a pause)
this.hold(key1);
}
}
}

View file

@ -1,7 +1,7 @@
import { Codes, KeyEvent } from '@keymanapp/keyboard-processor';
import { type KeyElement } from '../../../keyElement.js';
import VisualKeyboard from '../../../visualKeyboard.js';
import PendingGesture from '../pendingGesture.interface.js';
// import PendingGesture from '../pendingGesture.interface.js';
export enum PendingMultiTapState { Waiting, Realized, Cancelled };
/**
@ -9,7 +9,7 @@ export enum PendingMultiTapState { Waiting, Realized, Cancelled };
* (based on key id substring in the case of the shift key), within a
* specified timeout period.
*/
export default class PendingMultiTap implements PendingGesture {
export default class PendingMultiTap {
public readonly vkbd: VisualKeyboard;
public readonly baseKey: KeyElement;
public readonly count: number;

View file

@ -1,46 +0,0 @@
import type RealizedGesture from './realizedGesture.interface.js';
import { type KeyElement } from '../../keyElement.js';
/**
* Used for evaluating potential gestures. Classes adhering to this interface
* should be instantiated whenever the (implied) state-machine allows a new
* touch event to mark the start of a potential new gesture.
*
* For example, whenever a user touches a base key and there are no "realized"
* (fully-completed, but as-of-yet unresolved) gestures, that state allows the
* start of a potential new longpress event.
*
* The role of the `PendingGesture` is complete whenever all touch-events and
* conditions necessary for a modeled gesture have been met. As this point,
* it should be `resolve`d, fulfilling its `promise`. This results in a
* `RealizedGesture` appropriate for the gesture type that is used to obtain
* the final `KeyEvent` for the overall gesture sequence.
*
* For example, a "longpress" is considered resolved once the user has maintained
* an active, stationary touch point on the same key for a sufficiently long
* period without releasing it.
* * Were it released earlier, that would result in selection of a base key.
*
* Alternatively, a "flick" might be considered resolved if:
* * a user has rapidly moved a touch point in a consistent direction
* * for a long enough distance
* * and _then_ releases that touch point within a short timeframe.
*
* The pending gesture should only `resolve` to a realized gesture once
* _all_ such conditions are met, confirming that this specific gesture,
* and _only_ this specific gesture, could have resulted from the active
* touch sequence.
*
* The `RealizedGesture` that results and is 'returned' via the Promise will
* be handled by the `VisualKeyboard` class, which will retrieve and forward
* any `KeyEvent` that results from the overall gesture input sequence.
*
* @see `RealizedGesture`
*/
export default interface PendingGesture {
readonly baseKey: KeyElement;
readonly promise?: Promise<RealizedGesture>;
cancel(): void;
resolve?(): void;
}

View file

@ -1,36 +0,0 @@
import InputEventCoordinate from '../inputEventCoordinate.js';
import { type KeyElement } from '../../keyElement.js';
import { type KeyEvent } from '@keymanapp/keyboard-processor';
/**
* Implementations of this interface allow individual types of gestures to
* specify any additional user interaction and functionality (which may
* include UI elements) appropriate for obtaining a key event that may be
* produced by the modeled gesture type. These should only be instantiated
* once the associated `PendingLongpress` is no longer 'pending' - once it
* has become clear that the input touch-event sequence could only correspond
* to the modeled gesture.
*
* For example, when a longpress gesture completes - and hence, the user has
* kept their finger stationary on the same key for a long enough period -
* we display a popup view presenting subkeys corresponding to the gesture's
* underlying element. This popup view accepts touch input and completes only
* upon release of the ongoing touch sequence.
*
* Gestures are events that occur over intervals of time, and since some of them
* will require time and user interaction after becoming 'realized', these cases
* will be inherently async. The simplest way to model this is with `Promise`s.
*
* If appropriate for the modeled gesture type, an implementation may supply an
* instantly-resolving `Promise`. This may be appropriate for modeling "flick"
* or "swipe" gestures in the future, which may require no additional input once
* such a gesture is fully realized.
*/
export default interface RealizedGesture {
readonly baseKey: KeyElement;
readonly promise: Promise<KeyEvent>;
clear(): void;
isVisible(): boolean;
updateTouch(input: InputEventCoordinate): void;
}

View file

@ -95,11 +95,11 @@ export function gestureSetForLayout(layerGroup: OSKLayerGroup, params: GesturePa
}
};
const simpleTapModel: GestureModel = deepCopy(layout.hasFlicks ? SimpleTapModel : SimpleTapModelWithReset);
const longpressModel: GestureModel = deepCopy(layout.hasFlicks ? basicLongpressModel(params) : longpressModelWithShortcut(params));
const simpleTapModel: GestureModel<KeyElement> = deepCopy(layout.hasFlicks ? SimpleTapModel : SimpleTapModelWithReset);
const longpressModel: GestureModel<KeyElement> = deepCopy(layout.hasFlicks ? basicLongpressModel(params) : longpressModelWithShortcut(params));
// #region Functions for implementing and/or extending path initial-state checks
function withKeySpecFiltering(model: GestureModel, contactIndices: number | number[]) {
function withKeySpecFiltering(model: GestureModel<KeyElement>, contactIndices: number | number[]) {
// Creates deep copies of the model specifications that are safe to customize to the
// keyboard layout.
model = deepCopy(model);
@ -125,7 +125,7 @@ export function gestureSetForLayout(layerGroup: OSKLayerGroup, params: GesturePa
return model;
}
function withLayerChangeItemFix(model: GestureModel, contactIndices: number | number[]) {
function withLayerChangeItemFix(model: GestureModel<KeyElement>, contactIndices: number | number[]) {
// Creates deep copies of the model specifications that are safe to customize to the
// keyboard layout.
model = deepCopy(model);
@ -206,7 +206,9 @@ export function gestureSetForLayout(layerGroup: OSKLayerGroup, params: GesturePa
// #region Definition of models for paths comprising gesture-stage models
type ContactModel = specs.ContactModel<KeyElement>;
// Note: as specified below, none of the raw specs actually need access to KeyElement typing.
type ContactModel = specs.ContactModel<any>;
export const InstantContactRejectionModel: ContactModel = {
itemPriority: 0,
@ -337,14 +339,18 @@ export const SubkeySelectContactModel: ContactModel = {
// #endregion
// #region Gesture-stage model definitions
type GestureModel = specs.GestureModel<KeyElement>;
// Note: as specified below, most of the raw specs actually need access to KeyElement typing.
// That only becomes relevant with some of the modifier functions in the `gestureSetForLayout`
// func at the top.
type GestureModel<Type> = specs.GestureModel<Type>;
// TODO: customization of the gesture models depending upon properties of the keyboard.
// - has flicks? no longpress shortcut, also no longpress reset(?)
// - modipress: keyboard-specific modifier keys - which may require inspection of a
// key's properties.
export const SpecialKeyStartModel: GestureModel = {
export const SpecialKeyStartModel: GestureModel<KeyElement> = {
id: 'special-key-start',
resolutionPriority: 0,
contacts : [
@ -376,7 +382,7 @@ export const SpecialKeyStartModel: GestureModel = {
}
}
export const SpecialKeyEndModel: GestureModel = {
export const SpecialKeyEndModel: GestureModel<any> = {
id: 'special-key-end',
resolutionPriority: 0,
contacts : [
@ -397,7 +403,7 @@ export const SpecialKeyEndModel: GestureModel = {
/**
* The flickless, roaming-touch-less version.
*/
export function basicLongpressModel(params: GestureParams): GestureModel {
export function basicLongpressModel(params: GestureParams): GestureModel<any> {
return {
id: 'longpress',
resolutionPriority: 0,
@ -427,7 +433,7 @@ export function basicLongpressModel(params: GestureParams): GestureModel {
* For use when a layout doesn't have flicks; has the up-flick shortcut
* and facilitates roaming-touch.
*/
export function longpressModelWithShortcut(params: GestureParams): GestureModel {
export function longpressModelWithShortcut(params: GestureParams): GestureModel<any> {
return {
...basicLongpressModel(params),
@ -473,7 +479,7 @@ export function longpressModelWithShortcut(params: GestureParams): GestureModel
* For use when a layout doesn't have flicks; has the up-flick shortcut
* and facilitates roaming-touch.
*/
export function longpressModelWithRoaming(params: GestureParams): GestureModel {
export function longpressModelWithRoaming(params: GestureParams): GestureModel<any> {
return {
...basicLongpressModel(params),
@ -517,7 +523,7 @@ export function longpressModelWithRoaming(params: GestureParams): GestureModel {
}
export const MultitapModel: GestureModel = {
export const MultitapModel: GestureModel<any> = {
id: 'multitap',
resolutionPriority: 2,
contacts: [
@ -547,7 +553,7 @@ export const MultitapModel: GestureModel = {
}
}
export const SimpleTapModel: GestureModel = {
export const SimpleTapModel: GestureModel<any> = {
id: 'simple-tap',
resolutionPriority: 1,
contacts: [
@ -570,7 +576,7 @@ export const SimpleTapModel: GestureModel = {
}
}
export const SimpleTapModelWithReset: GestureModel = {
export const SimpleTapModelWithReset: GestureModel<any> = {
...SimpleTapModel,
rejectionActions: {
item: {
@ -580,7 +586,7 @@ export const SimpleTapModelWithReset: GestureModel = {
}
}
export const SubkeySelectModel: GestureModel = {
export const SubkeySelectModel: GestureModel<any> = {
id: 'subkey-select',
resolutionPriority: 0,
contacts: [
@ -608,7 +614,7 @@ export const SubkeySelectModel: GestureModel = {
sustainWhenNested: true
}
export const ModipressStartModel: GestureModel = {
export const ModipressStartModel: GestureModel<KeyElement> = {
id: 'modipress-start',
resolutionPriority: 5,
contacts: [
@ -641,7 +647,7 @@ export const ModipressStartModel: GestureModel = {
}
}
export const ModipressEndModel: GestureModel = {
export const ModipressEndModel: GestureModel<any> = {
id: 'modipress-end',
resolutionPriority: 5,
contacts: [

View file

@ -1,72 +0,0 @@
/**
* Represents the current location of the current cursor / touchpoint during
* an ongoing OSK input event. This class standardizes to .pageX (document)
* coordinates, rather than .clientX (viewport) coordinates.
*/
export default class InputEventCoordinate {
public readonly x: number;
public readonly y: number;
private readonly source: MouseEvent | TouchEvent;
public constructor(x: number, y: number, source?: MouseEvent | TouchEvent) {
this.x = x;
this.y = y;
if(source) {
this.source = source;
}
}
// Converts a MouseEvent or TouchEvent into the base coordinates needed
// by the mouse-dragging operations.
public static fromEvent(e: MouseEvent | TouchEvent) {
let coordSource: MouseEvent | Touch;
// Desktop Safari versions as recent as 14.1 do not support TouchEvents.
// So, just in case, a two-fold conditional check to avoid issues with a direct
// 'instanceof' against the type.
if(window['TouchEvent'] && e instanceof TouchEvent) {
coordSource = e.changedTouches[0];
} else if(e['changedTouches']) {
coordSource = e['changedTouches'][0] as Touch;
} else {
coordSource = e as MouseEvent;
}
// For MouseEvents, .pageX is slightly less supported in older browsers when
// compared to .clientX. They're about equally supported for TouchEvents.
if (coordSource.pageX) {
return new InputEventCoordinate(coordSource.pageX, coordSource.pageY, e);
} else if (coordSource.clientX) {
const x = coordSource.clientX + document.body.scrollLeft;
const y = coordSource.clientY + document.body.scrollTop;
return new InputEventCoordinate(x, y, e);
} else {
return new InputEventCoordinate(null, null, e);
}
}
public get activeInputCount(): number {
// May not be an ACTUAL touch event during unit tests.
if(window['TouchEvent'] && this.source['touches'] !== undefined && this.source['touches'] !== null) {
return this.source['touches'].length;
} else {
const event = this.source as MouseEvent;
return event.buttons > 0 ? 1 : 0;
}
}
public get target() {
return this.source?.target;
}
public get isFromTouch(): boolean {
return !this.isFromMouse;
}
public get isFromMouse(): boolean {
return this.source instanceof MouseEvent;
}
}

View file

@ -1,7 +1,56 @@
import InputEventCoordinate from './inputEventCoordinate.js';
type MouseHandler = (this: GlobalEventHandlers, ev: MouseEvent) => any;
/**
* Represents the current location of the current cursor / touchpoint during
* an ongoing contact-point event series. This class standardizes to .pageX
* (document) coordinates, rather than .clientX (viewport) coordinates.
*/
class InputEventCoordinate {
public readonly x: number;
public readonly y: number;
private readonly source: MouseEvent | TouchEvent;
public constructor(x: number, y: number, source?: MouseEvent | TouchEvent) {
this.x = x;
this.y = y;
if(source) {
this.source = source;
}
}
// Converts a MouseEvent or TouchEvent into the base coordinates needed
// by the mouse-dragging operations.
public static fromEvent(e: MouseEvent | TouchEvent) {
let coordSource: MouseEvent | Touch;
// Desktop Safari versions as recent as 14.1 do not support TouchEvents.
// So, just in case, a two-fold conditional check to avoid issues with a direct
// 'instanceof' against the type.
if(window['TouchEvent'] && e instanceof TouchEvent) {
coordSource = e.changedTouches[0];
} else if(e['changedTouches']) {
coordSource = e['changedTouches'][0] as Touch;
} else {
coordSource = e as MouseEvent;
}
// For MouseEvents, .pageX is slightly less supported in older browsers when
// compared to .clientX. They're about equally supported for TouchEvents.
if (coordSource.pageX) {
return new InputEventCoordinate(coordSource.pageX, coordSource.pageY, e);
} else if (coordSource.clientX) {
const x = coordSource.clientX + document.body.scrollLeft;
const y = coordSource.clientY + document.body.scrollTop;
return new InputEventCoordinate(x, y, e);
} else {
return new InputEventCoordinate(null, null, e);
}
}
}
/**
* Used to store the page's original mouse handlers and properties
* when temporarily overridden by OSK moving or resizing handlers due

View file

@ -6,7 +6,6 @@ import TouchLayoutFlick = TouchLayout.TouchLayoutFlick;
import { getAbsoluteX, getAbsoluteY } from 'keyman/engine/dom-utils';
import { getFontSizeStyle } from '../fontSizeUtils.js';
import InputEventCoordinate from '../input/inputEventCoordinate.js';
import specialChars from '../specialCharacters.js';
import buttonClassNames from '../buttonClassNames.js';
@ -379,19 +378,6 @@ export default abstract class OSKKey {
return t;
}
public isUnderTouch(input: InputEventCoordinate): boolean {
let x = input.x;
let y = input.y;
let btn = this.btn;
let x0 = getAbsoluteX(btn);
let y0 = getAbsoluteY(btn);
let x1 = x0 + btn.offsetWidth;
let y1 = y0 + btn.offsetHeight;
return (x > x0 && x < x1 && y > y0 && y < y1);
}
public refreshLayout(vkbd: VisualKeyboard) {
// space bar may not define the text span!
if(this.label) {

View file

@ -29,7 +29,6 @@ import {
import { createStyleSheet, getAbsoluteX, getAbsoluteY, StylesheetManager } from 'keyman/engine/dom-utils';
import GlobeHint from './globehint.interface.js';
import InputEventCoordinate from './input/inputEventCoordinate.js';
import KeyboardView from './components/keyboardView.interface.js';
import { type KeyElement, getKeyFrom } from './keyElement.js';
import KeyTip from './keytip.interface.js';
@ -37,8 +36,6 @@ import OSKKey from './keyboard-layout/oskKey.js';
import OSKLayer from './keyboard-layout/oskLayer.js';
import OSKLayerGroup from './keyboard-layout/oskLayerGroup.js';
import { LengthStyle, ParsedLengthStyle } from './lengthStyle.js';
import PendingGesture from './input/gestures/pendingGesture.interface.js';
import RealizedGesture from './input/gestures/realizedGesture.interface.js';
import { defaultFontSize, getFontSizeStyle } from './fontSizeUtils.js';
import PendingMultiTap, { PendingMultiTapState } from './input/gestures/browser/pendingMultiTap.js';
import InternalKeyTip from './input/gestures/browser/keytip.js';
@ -180,7 +177,6 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
// State-related properties
keyPending: KeyElement;
touchPending: InputEventCoordinate;
deleteKey: KeyElement;
deleting: number; // Tracks a timer id for repeated deletions.
nextLayer: string;
@ -192,7 +188,6 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
};
// Touch-tracking properties
initTouchCoord: InputEventCoordinate;
touchCount: number;
currentTarget: KeyElement;
@ -205,9 +200,6 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
activeGestures: GestureHandler[] = [];
pendingSubkey: PendingGesture;
subkeyGesture: RealizedGesture;
// Multi-tap gesture management
pendingMultiTap: PendingMultiTap;
@ -773,108 +765,6 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
//#region Input handling start
/**
* Determines a "fuzzy boundary" area around the OSK within which active mouse and
* touch events will be maintained, even if their coordinates lie outside of the OSK's
* true visual bounds.
* @returns A `BoundingRect`, in `.pageX` / `.pageY` coordinates.
*/
private getInteractiveBoundingRect(): BoundingRect {
// Determine the important geometric values involved
let oskX = getAbsoluteX(this.element);
let oskY = getAbsoluteY(this.element);
// Determine the out-of-bounds threshold at which touch-cancellation should automatically occur.
// Assuming square key-squares, we'll use 1/3 the height of a row for bounds detection
// for both dimensions.
const rowCount = this.currentLayer.rows.length;
const buffer = (0.333 * this.height / rowCount);
// Determine the OSK's boundaries and the boundaries of the page / view.
// These values are needed in .pageX / .pageY coordinates for the final calcs.
let boundingRect: BoundingRect = {
left: oskX - buffer,
right: oskX + this.width + buffer,
top: oskY - buffer,
bottom: oskY + this.height + buffer
};
return boundingRect;
}
/**
* Adjusts a potential "interactive boundary" definition by enforcing an
* "event cancellation zone" near screen boundaries that are not directly adjacent
* to the ongoing input event's initial coordinate.
*
* This facilitates modeling of conventional cancellation gestures where a user would
* drag the mouse or touch point off the OSK, as mouse and touch event handlers receive
* no input beyond screen boundaries.
*
* @param baseBounds The baseline interactive bounding area to be adjusted
* @param startCoord The initial coordinate of a currently-ongoing input event
* @returns
*/
private applyScreenMarginBoundsThresholding(baseBounds: BoundingRect,
startCoord: InputEventCoordinate): BoundingRect {
// Determine the needed linear translation to screen coordinates.
const xDelta = window.screenLeft - window.pageXOffset;
const yDelta = window.screenTop - window.pageYOffset;
let adjustedBounds: BoundingRect = { ...baseBounds };
// Also translate the initial touch's screen coord, as it affects our bounding box logic.
const initScreenCoord = new InputEventCoordinate(startCoord.x + xDelta, startCoord.y + yDelta);
// Detection: is the OSK aligned with any screen boundaries?
// If so, create a 'fuzzy' zone around the edges not near the initial touch point that allow
// move-based cancellation.
// If the initial input screen-coord is at least 5 pixels from the screen's left AND
// the OSK's left boundary is within 2 pixels from the screen's left...
if (initScreenCoord.x >= 5 && baseBounds.left + xDelta <= 2) {
adjustedBounds.left = 2 - xDelta; // new `leftBound` is set to 2 pixels from the screen's left.
}
if (initScreenCoord.x <= screen.width - 5 && baseBounds.right + xDelta >= screen.width - 2) {
adjustedBounds.right = (screen.width - 2) - xDelta; // new `rightBound` 2px from screen's right.
}
if (initScreenCoord.y >= 5 && baseBounds.top + yDelta <= 2) {
adjustedBounds.top = 2 - yDelta;
}
if (initScreenCoord.y <= screen.height - 5 && baseBounds.bottom + yDelta >= screen.height - 2) {
adjustedBounds.bottom = (screen.height - 2) - yDelta;
}
return adjustedBounds;
}
detectWithinInteractiveBounds(coord: InputEventCoordinate): boolean {
// Shortcuts the method during unit testing, as we don't currently
// provide coordinate values in its synthetic events.
if (coord.x === null && coord.y === null) {
return true;
}
const baseBoundingRect = this.getInteractiveBoundingRect();
let adjustedBoundingRect = baseBoundingRect;
if(this.initTouchCoord) {
this.applyScreenMarginBoundsThresholding(baseBoundingRect, this.initTouchCoord);
}
// Now to check where the input coordinate lies in relation to the final bounding box!
if (coord.x < adjustedBoundingRect.left || coord.x > adjustedBoundingRect.right) {
return false;
} else if (coord.y < adjustedBoundingRect.top || coord.y > adjustedBoundingRect.bottom) {
return false;
} else {
return true;
}
}
// /**
// * The main OSK touch start event handler
// *
@ -1571,88 +1461,78 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
}
}
/**
* Initializes all supported gestures given a base key and the triggering touch coordinates.
* @param key The gesture's base key
* @param touch The starting touch coordinates for the gesture
* @returns
*/
initGestures(key: KeyElement, input: InputEventCoordinate) {
// /**
// * Initializes all supported gestures given a base key and the triggering touch coordinates.
// * @param key The gesture's base key
// * @param touch The starting touch coordinates for the gesture
// * @returns
// */
// initGestures(key: KeyElement, input: InputEventCoordinate) {
if (this.pendingMultiTap) {
switch (this.pendingMultiTap.incrementTouch(key)) {
case PendingMultiTapState.Cancelled:
this.pendingMultiTap = null;
break;
case PendingMultiTapState.Realized:
// Don't initialize any other gestures if the
// multi tap is realized; we cleanup on touch
// release because we need to cancel the base
// key action
return;
}
}
// if (this.pendingMultiTap) {
// switch (this.pendingMultiTap.incrementTouch(key)) {
// case PendingMultiTapState.Cancelled:
// this.pendingMultiTap = null;
// break;
// case PendingMultiTapState.Realized:
// // Don't initialize any other gestures if the
// // multi tap is realized; we cleanup on touch
// // release because we need to cancel the base
// // key action
// return;
// }
// }
if (!this.pendingMultiTap && PendingMultiTap.isValidTarget(this, key)) {
// We are only going to support double-tap on Shift
// in Keyman 15, so we pass in the constant count = 2
this.pendingMultiTap = new PendingMultiTap(this, key, 2);
this.pendingMultiTap.timeout.then(() => {
this.pendingMultiTap = null;
});
}
}
// if (!this.pendingMultiTap && PendingMultiTap.isValidTarget(this, key)) {
// // We are only going to support double-tap on Shift
// // in Keyman 15, so we pass in the constant count = 2
// this.pendingMultiTap = new PendingMultiTap(this, key, 2);
// this.pendingMultiTap.timeout.then(() => {
// this.pendingMultiTap = null;
// });
// }
// }
/**
* Updates all currently-pending and activated gestures.
*
* @param currentKey The key currently underneath the most recent touch coordinate
* @param previousKey The previously-selected key
* @param input The current mouse or touch coordinate for the gesture
* @returns true if should fully capture input, false if input should 'fall through'.
*/
updateGestures(currentKey: KeyElement, previousKey: KeyElement, input: InputEventCoordinate): boolean {
let key0 = previousKey;
let key1 = currentKey;
// /**
// * Updates all currently-pending and activated gestures.
// *
// * @param currentKey The key currently underneath the most recent touch coordinate
// * @param previousKey The previously-selected key
// * @param input The current mouse or touch coordinate for the gesture
// * @returns true if should fully capture input, false if input should 'fall through'.
// */
// updateGestures(currentKey: KeyElement, previousKey: KeyElement, input: InputEventCoordinate): boolean {
// let key0 = previousKey;
// let key1 = currentKey;
if(!currentKey && this.pendingMultiTap) {
this.pendingMultiTap.cancel();
this.pendingMultiTap = null;
}
// if(!currentKey && this.pendingMultiTap) {
// this.pendingMultiTap.cancel();
// this.pendingMultiTap = null;
// }
// Clear previous key highlighting, allow subkey controller to highlight as appropriate.
if (this.subkeyGesture) {
if (key0) {
key0.key.highlight(false);
}
this.subkeyGesture.updateTouch(input);
// // Clear previous key highlighting, allow subkey controller to highlight as appropriate.
// if (this.subkeyGesture) {
// if (key0) {
// key0.key.highlight(false);
// }
// this.subkeyGesture.updateTouch(input);
this.keyPending = null;
this.touchPending = null;
// this.keyPending = null;
// this.touchPending = null;
return true;
}
// return true;
// }
this.currentTarget = null;
// this.currentTarget = null;
// If there is an active popup menu (which can occur from the previous block),
// a subkey popup exists; do not allow base key output.
if (this.subkeyGesture || this.pendingSubkey) {
return true;
}
// // If there is an active popup menu (which can occur from the previous block),
// // a subkey popup exists; do not allow base key output.
// if (this.subkeyGesture || this.pendingSubkey) {
// return true;
// }
return false;
}
private getLongpressFlickThreshold(): number {
const rowHeight = this.currentLayer.rowHeight;
// If larger than 5 (and it likely is), new threshold = 1/4 the std. key height.
const proportionalThreshold = rowHeight / 4;
// 5 - the longpress-flick triggering threshold before 15.0.
return Math.max(proportionalThreshold, 5);
}
// return false;
// }
optionKey(e: KeyElement, keyName: string, keyDown: boolean) {
if (keyName.indexOf('K_LOPT') >= 0) {
@ -1729,7 +1609,6 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
}
this.keyPending = null;
this.touchPending = null;
this.keytip?.show(null, false, this);
}