Merge pull request #5387 from keymanapp/refactor/web/osk-centralized-gestures

refactor(web): longpresses, groundwork for additional gestures 🍕
This commit is contained in:
Joshua Horton 2021-07-08 08:18:05 +07:00 committed by GitHub
commit 75e47c1ccc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 636 additions and 306 deletions

View file

@ -221,10 +221,10 @@ namespace com.keyman.keyboards {
return Lkc;
}
public getSubkey(id: string): ActiveKey {
public getSubkey(coreID: string): ActiveKey {
if(this.sk) {
for(let key of this.sk) {
if(key.id == id) {
if(key.coreID == coreID) {
return key;
}
}

View file

@ -2,6 +2,7 @@
// References the base Keyman object (and consequently, the rest of the core objects).
/// <reference path="kmwbase.ts" />
/// <reference path="osk/embedded/keytip.ts" />
/// <reference path="osk/embedded/pendingLongpress.ts" />
// KeymanWeb 11.0
// Copyright 2019 SIL International
@ -52,17 +53,22 @@ namespace com.keyman.osk {
*
* @param {Object} key base key element
*/
VisualKeyboard.prototype.touchHold = function(this: VisualKeyboard, key: KeyElement) {
let util = com.keyman.singleton.util;
if(key['subKeys'] && (typeof(window['oskCreatePopup']) == 'function')) {
VisualKeyboard.prototype.startLongpress = function(this: VisualKeyboard, key: KeyElement): PendingGesture {
if(typeof(window['oskCreatePopup']) == 'function') {
var xBase = dom.Utils.getAbsoluteX(key) - dom.Utils.getAbsoluteX(this.kbdDiv) + key.offsetWidth/2,
yBase = dom.Utils.getAbsoluteY(key);
// #3718: No longer prepend base key to subkey array
this.popupBaseKey = key;
this.popupPending=true;
window['oskCreatePopup'](key['subKeys'], xBase, yBase, key.offsetWidth, key.offsetHeight);
return new embedded.PendingLongpress(this, key);
} else {
// When embedded within our Android app, we expect the `oskCreatePopup` function to
// exist; all subkey control is delegated to the app.
//
// No function = big problem.
console.error("Missing `oskCreatePopup` function for engine integration.");
return null;
}
};
@ -73,10 +79,6 @@ namespace com.keyman.osk {
}
};
VisualKeyboard.prototype.highlightSubKeys = function(this: VisualKeyboard, k, x, y) {
// a dummy function; it's only really used for 'native' KMW.
}
VisualKeyboard.prototype.waitForFonts = function(this: VisualKeyboard, kfd, ofd) {
// a dummy function; it's only really used for 'native' KMW.
return true;
@ -134,8 +136,6 @@ namespace com.keyman.text {
// Skip full page initialization - skips native-mode only code
keymanweb.isEmbedded = true;
com.keyman.osk.VisualKeyboard.prototype.popupDelay = 400; // Delay must be less than native touch-hold delay
// Set default device options
keymanweb.setDefaultDeviceOptions = function(opt: com.keyman.OptionType) {
opt['attachType'] = 'manual';
@ -270,14 +270,45 @@ namespace com.keyman.text {
};
/**
* Function called by Android and iOS when a device-implemented keyboard popup is displayed or hidden
* Function called by Android and iOS when a device-implemented keyboard popup
* is displayed or hidden. As this is controlled by the app, we use it as a
* trigger for 'embedded'-mode gesture state management.
*
* @param {boolean} isVisible
*
**/
keymanweb['popupVisible'] = function(isVisible)
{
osk.vkbd.popupVisible = isVisible;
keymanweb['popupVisible'] = function(isVisible) {
let gesture = osk.vkbd.subkeyGesture as com.keyman.osk.embedded.SubkeyDelegator;
let pendingLongpress = osk.vkbd.pendingSubkey;
/*
* If a longpress popup was visible, but is no longer, this means that the
* associated longpress gesture was cancelled. It is possible for the base
* key to emit if selected at this time; detecton of this is managed by
* the `SubkeyDelegator` class.
*/
if(!isVisible) {
if(gesture) {
gesture.resolve(null);
osk.vkbd.subkeyGesture = null;
} else if(pendingLongpress) {
pendingLongpress.cancel();
osk.vkbd.pendingSubkey = null;
}
}
/*
* If the popup was not visible, but now is, that means our previously-pending
* longpress is now 'realized' (complete). The OSK relies upon this state
* information, which will be properly updated by `resolve`.
*
* Prominent uses of such state info helps prevent change of base key, key
* previews, and key output from occurring while a subkey popup remains active.
*/
if(isVisible && pendingLongpress) {
// Fulfills the first-stage promise.
pendingLongpress.resolve();
}
};
/**
@ -318,8 +349,6 @@ namespace com.keyman.text {
* @param {string} keyName key identifier
**/
keymanweb['executePopupKey'] = function(keyName: string) {
let core = (<KeymanBase> keymanweb).core;
var origArg = keyName;
if(!keymanweb.core.activeKeyboard || !osk.vkbd) {
return false;
@ -333,99 +362,24 @@ namespace com.keyman.text {
// Can't just split on '-' because some layers like ctrl-shift contain it.
let separatorIndex = keyName.lastIndexOf('-');
var layer = core.keyboardProcessor.layerId;
if (separatorIndex > 0) {
layer = keyName.substring(0, separatorIndex);
keyName = keyName.substring(separatorIndex+1);
}
if(layer == 'undefined') {
layer=core.keyboardProcessor.layerId;
}
// Note: this assumes Lelem is properly attached and has an element interface.
// Currently true in the Android and iOS apps.
var Lelem=keymanweb.domManager.getLastActiveElement(),keyShiftState=com.keyman.text.KeyboardProcessor.getModifierState(layer);
var Lelem=keymanweb.domManager.getLastActiveElement();
keymanweb.domManager.initActiveElement(Lelem);
var nextLayer: string;
// This should be set if we're within this method... but it's best to guard against nulls here, just in case.
if(osk.vkbd.popupBaseKey && osk.vkbd.popupBaseKey['key']) {
// This is set with the base key of our current subkey elsewhere within the engine.
var baseKey: com.keyman.osk.OSKKeySpec = osk.vkbd.popupBaseKey['key'].spec;
var found = false;
if(baseKey.coreID == keyName) {
nextLayer = baseKey.nextlayer;
found = true;
} else {
// Search for the specified subkey so we can retrieve its useful properties.
// It should be within the popupBaseKey's subkey list.
for(let subKey of baseKey.sk) {
if(subKey.coreID == keyName) {
// ... to consider: why are we not just taking the keyspec wholesale right here?
nextLayer = subKey.nextlayer;
found = true;
break;
}
}
}
if(!found) {
console.warn("Could not find subkey '" + origArg + "' under the current base key '" + baseKey.coreID + "'!");
}
if(osk.vkbd.subkeyGesture) {
let gesture = osk.vkbd.subkeyGesture as com.keyman.osk.embedded.SubkeyDelegator;
gesture.resolve(keyName);
osk.vkbd.subkeyGesture = null;
} else {
console.warn("No base key exists for the subkey being executed: '" + origArg + "'");
}
let Codes = com.keyman.text.Codes;
// Check the virtual key
let Lkc: com.keyman.text.KeyEvent = {
Lmodifiers: keyShiftState,
Lstates: 0,
Lcode: Codes.keyCodes[keyName],
LisVirtualKey: true,
kName: keyName,
kNextLayer: nextLayer,
vkCode: null, // was originally undefined
isSynthetic: true,
device: keymanweb.util.device.coreSpec
};
// Process modifier key action
if(core.keyboardProcessor.selectLayer(Lkc, true)) { // ignores key's 'nextLayer' property for this check
return true;
}
// While we can't source the base KeyEvent properties for embedded subkeys the same way as native,
// we can handle many other pre-processing steps the same way with this common method.
core.keyboardProcessor.setSyntheticEventDefaults(Lkc);
//if(!Lkc.Lcode) return false; // Value is now zero if not known (Build 347)
//Build 353: revert to prior test to try to fix lack of KMEI output, May 1, 2014
if(isNaN(Lkc.Lcode) || !Lkc.Lcode) {
// Addresses modifier SHIFT keys.
if(nextLayer) {
core.keyboardProcessor.selectLayer(Lkc);
}
return false;
}
Lkc.vkCode=Lkc.Lcode;
// Now that we have a valid key event, hand it off to the Processor for execution.
// This allows the Processor to also handle any predictive-text tasks necessary.
let retVal = com.keyman.osk.PreProcessor.handleClick(Lkc, com.keyman.dom.Utils.getOutputTarget(Lelem), null);
// Special case for embedded to pass K_TAB back to device to process
if(Lkc.Lcode == Codes.keyCodes["K_TAB"] || Lkc.Lcode == Codes.keyCodes["K_TABBACK"]
|| Lkc.Lcode == Codes.keyCodes["K_TABFWD"]) {
return false;
}
return retVal;
};
/**

View file

@ -493,7 +493,7 @@ namespace com.keyman.osk {
// Utilized by the mobile apps; allows them to 'take over' touch handling,
// blocking it within KMW when the apps are already managing an ongoing touch-hold.
let keyman = com.keyman.singleton;
return keyman['osk'].vkbd.popupVisible;
return keyman['osk'].vkbd.subkeyGesture && keyman.isEmbedded;
}
protected dealiasSubTarget(target: HTMLDivElement): HTMLDivElement {

View file

@ -0,0 +1,65 @@
/// <reference path="subkeyPopup.ts" />
/// <reference path="../pendingGesture.interface.ts" />
namespace com.keyman.osk.browser {
/**
* (Conceptually) represents a finite-state-machine that determines
* whether or not a series of touch events corresponds to a longpress
* touch input. The `resolve` method may be used to trigger the
* subkey menu early, as with the upward quick-display shortcut.
*
* This is the default implementation of longpress behavior for KMW.
* Alterate implementations are modeled through the `embedded`
* namespace's equivalent, which is designed to facilitate custom
* modeling for such gestures.
*
* Once the conditions to recognize a longpress gesture have been
* fulfilled, this class's `promise` will resolve with a `SubkeyPopup`
* matching the gesture's 'base' key, which itself provides a
* `promise` field that will resolve to a `KeyEvent` once the touch
* sequence is completed.
*/
export class PendingLongpress implements PendingGesture {
public readonly baseKey: KeyElement;
public readonly promise: Promise<SubkeyPopup>;
public readonly subkeyUI: SubkeyPopup;
private readonly vkbd: VisualKeyboard;
private resolver: (subkeyPopup: SubkeyPopup) => void;
private timerId: number;
private popupDelay: number = 500;
constructor(vkbd: VisualKeyboard, baseKey: KeyElement) {
this.vkbd = vkbd;
this.baseKey = baseKey;
let _this = this;
this.promise = new Promise<SubkeyPopup>(function(resolve, reject) {
_this.resolver = resolve;
// After the timeout, it's no longer deferred; it's being fulfilled.
// Even if the actual subkey itself is still async.
_this.timerId = window.setTimeout(_this.resolve.bind(_this), _this.popupDelay);
});
}
public cancel() {
if(this.timerId) {
window.clearTimeout(this.timerId);
this.timerId = null;
}
if(this.resolver) {
this.resolver(null);
this.resolver = null;
}
}
public resolve() {
if(this.resolver) {
this.resolver(new SubkeyPopup(this.vkbd, this.baseKey));
}
}
}
}

View file

@ -1,16 +1,54 @@
/// <reference path="oskSubKey.ts" />
/// <reference path="../realizedGesture.interface.ts" />
namespace com.keyman.osk.browser {
export class SubkeyPopup {
/**
* Represents a 'realized' longpress gesture's default implementation
* within KeymanWeb. Once a touch sequence has been confirmed to
* correspond to a longpress gesture, implementations of this class
* provide the following:
* * The UI needed to present a subkey menu
* * The state management needed to present feedback about the
* currently-selected subkey to the user
* * A `Promise` that will resolve to the user's selected subkey
* once the longpress operation is complete.
*
* As selection of the subkey occurs after the subkey popup is
* displayed, selection of the subkey is inherently asynchronous.
* The `Promise` may also resolve to `null` if the user indicates
* the desire to cancel subkey selection.
*/
export class SubkeyPopup implements RealizedGesture {
public readonly element: HTMLDivElement;
public readonly shim: HTMLDivElement;
private baseKey: KeyElement;
private vkbd: VisualKeyboard;
private currentSelection: KeyElement;
private callout: HTMLDivElement;
public readonly baseKey: KeyElement;
public readonly promise: Promise<text.KeyEvent>;
// Resolves the promise that generated this SubkeyPopup.
private resolver: (keyEvent: text.KeyEvent) => void;
constructor(vkbd: VisualKeyboard, e: KeyElement) {
let keyman = com.keyman.singleton;
let _this = this;
this.promise = new Promise<text.KeyEvent>(function(resolve) {
_this.resolver = resolve;
})
this.vkbd = vkbd;
this.baseKey = e;
// If the user doesn't move their finger and releases, we'll output the base key
// by default.
this.currentSelection = e;
e.key.highlight(true);
// A tag we directly set on a key element during its construction.
let subKeySpec: OSKKeySpec[] = e['subKeys'];
@ -18,7 +56,7 @@ namespace com.keyman.osk.browser {
// is possible while the array is visible. So it is simplest to let the keys have
// position:static and display:inline-block
var subKeys = this.element = document.createElement('div');
this.baseKey = e;
var i;
subKeys.id='kmw-popup-keys';
@ -72,6 +110,18 @@ namespace com.keyman.osk.browser {
}
}
finalize(touch: Touch) {
if(this.resolver) {
let keyEvent: text.KeyEvent = null;
if(this.currentSelection) {
keyEvent = this.vkbd.initKeyEvent(this.currentSelection, touch);
this.currentSelection.key.highlight(false);
}
this.resolver(keyEvent);
}
this.resolver = null;
}
reposition(vkbd: VisualKeyboard) {
let keyman = com.keyman.singleton;
@ -181,7 +231,16 @@ namespace com.keyman.osk.browser {
}
}
isVisible(): boolean {
return this.element.style.visibility == 'visible';
}
clear() {
// Discard the reference to the Promise's resolve method, allowing
// GC to clean it up. The corresponding Promise's contract allows
// passive cancellation.
this.resolver = null;
// Remove the displayed subkey array, if any
if(this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
@ -195,5 +254,34 @@ namespace com.keyman.osk.browser {
this.callout.parentNode.removeChild(this.callout);
}
}
updateTouch(touch: Touch) {
this.currentSelection = null;
this.baseKey.key.highlight(false);
for(let i=0; i < this.baseKey['subKeys'].length; i++) {
try {
let sk = this.element.childNodes[i].firstChild as KeyElement;
let onKey = sk.key.isUnderTouch(touch);
if(onKey) {
this.currentSelection = sk;
}
sk.key.highlight(onKey);
} catch(ex) {
if(ex.message) {
console.error("Unexpected error when attempting to update selected subkey:" + ex.message);
} else {
console.error("Unexpected error (and error type) when attempting to update selected subkey.");
}
}
}
// Use the popup duplicate of the base key if a phone with a visible popup array
if(!this.currentSelection && this.baseKey.key.isUnderTouch(touch)) {
this.baseKey.key.highlight(true);
this.currentSelection = this.baseKey;
}
}
}
}
}

View file

@ -0,0 +1,48 @@
/// <reference path="subkeyDelegator.ts" />
/// <reference path="../pendingGesture.interface.ts" />
namespace com.keyman.osk.embedded {
/**
* As control over the subkey display timer and the subkey popup are
* both handled by the host app within the Android app, this class
* serves mostly to communicate longpress state management from the
* app to the VisualKeyboard.
*
* The `resolve()` function should be triggered by the host app
* whenever it has recognized a completed longpress and has thus
* begun displaying its subkey popup. (Should these two events
* ever become separated in time, the latter is the more critical
* aspect.)
*/
export class PendingLongpress implements PendingGesture {
private resolver: (delegator: SubkeyDelegator) => void;
private readonly vkbd: VisualKeyboard;
public readonly baseKey: KeyElement;
public readonly promise: Promise<SubkeyDelegator>;
constructor(vkbd: VisualKeyboard, e: KeyElement) {
this.vkbd = vkbd;
let _this = this;
this.promise = new Promise<SubkeyDelegator>(function(resolve) {
_this.resolver = resolve;
});
this.baseKey = e;
}
public resolve() {
if(this.resolver) {
this.resolver(new SubkeyDelegator(this.vkbd, this.baseKey));
}
this.resolver = null;
}
public cancel() {
if(this.resolver) {
this.resolver(null);
this.resolver = null;
}
}
}
}

View file

@ -0,0 +1,110 @@
/// <reference path="../realizedGesture.interface.ts" />
namespace com.keyman.osk.embedded {
/**
* As the subkey popup view is handled by the host app when in embedded mode
* within our Android app, this class represents the fact that KMW has
* "delegated" subkey UI and selection to the host app. Hence, "Delegator",
* rather than "Popup".
*
* The `resolve` method should be triggered, in some fashion, by the host app
* whenever the user has completed their longpress, potentially selecting
* a subkey.
*
* This class will also track the ongoing touch event in case the base key is
* reselected, which _is_ managed by this class, not the host app.
*/
export class SubkeyDelegator implements RealizedGesture {
private resolver: (keyEvent: text.KeyEvent) => void;
private readonly vkbd: VisualKeyboard;
public readonly baseKey: KeyElement;
public readonly promise: Promise<text.KeyEvent>;
private movedFromBaseKey: boolean = false;
private baseKeySelected: boolean = false;
constructor(vkbd: VisualKeyboard, e: KeyElement) {
this.vkbd = vkbd;
let _this = this;
this.promise = new Promise<text.KeyEvent>(function(resolve) {
_this.resolver = resolve;
});
this.baseKey = e;
}
/**
* Resolves the ongoing longpress -> subkey gesture, fulfilling this
* `SubkeyDelegator`'s `promise` of a `KeyEvent`.
*
* If no subkey is selected but the original base key is, `resolve(null)`
* will return a key event corresponding to the base key.
*
* @param keyCoreID {string} The 'core ID' (id + modifier layer) of
* a selected subkey. May be `null`.
*/
public resolve(keyCoreID: string) {
if(this.resolver) {
let keyEvent: text.KeyEvent = null;
if(keyCoreID == null && this.baseKeySelected) {
// Handle selection of base key underneath the subkey array.
keyEvent = this.vkbd.keyEventFromSpec(this.baseKey.key.spec as keyboards.ActiveKey, null);
this.baseKey.key.highlight(false);
} else if(keyCoreID != null) {
// This is set with the base key of our current subkey elsewhere within the engine.
let baseKey: OSKKeySpec = this.baseKey.key.spec;
let selectedKey: OSKKeySpec;
if(baseKey.coreID == keyCoreID) {
selectedKey = baseKey;
} else {
// ... yeah, there are some funky type shenanigans between the two.
// OSKKeySpec is the OSK's... reinterpretation of the ActiveKey type.
selectedKey = (baseKey as keyboards.ActiveKey).getSubkey(keyCoreID) as OSKKeySpec;
}
if(!selectedKey) {
// While we can't complete successfully, the subkey operation is done; we
// should still signal that and update related gesture state management.
this.resolver(null);
console.error("Could not find subkey '" + keyCoreID + "' under base key '" + baseKey.coreID + "'!");
return;
}
keyEvent = this.vkbd.keyEventFromSpec(selectedKey as keyboards.ActiveKey, null);
keyEvent.vkCode=keyEvent.Lcode;
} // else /* if(keyCoreID == null) */ keyEvent = null; // As initialized at the top.
this.resolver(keyEvent);
}
this.resolver = null;
}
public isVisible(): boolean {
return true;
}
public clear() {
// no-op; it's fully controlled on the app side.
}
/**
* Allows this class to detect if the user may have changed their mind and
* re-selected the base key.
* @param touch
*/
updateTouch(touch: Touch) {
this.baseKeySelected = this.baseKey.key.isUnderTouch(touch);
// Prevent highlighting & selection before the touch has moved from the base key.
if(this.movedFromBaseKey) {
this.baseKey.key.highlight(this.baseKeySelected);
} else {
this.movedFromBaseKey = !this.baseKeySelected;
}
}
}
}

View file

@ -430,5 +430,19 @@ namespace com.keyman.osk {
return t;
}
public isUnderTouch(touch: Touch): boolean {
let x = touch.clientX;
let y = touch.clientY;
let btn = this.btn;
let x0 = dom.Utils.getAbsoluteX(btn);
let y0 = dom.Utils.getAbsoluteY(btn);//-document.body.scrollTop;
let x1 = x0 + btn.offsetWidth;
let y1 = y0 + btn.offsetHeight;
return (x > x0 && x < x1 && y > y0 && y < y1);
}
}
}
}

View file

@ -0,0 +1,45 @@
namespace com.keyman.osk {
/**
* 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 interface PendingGesture {
readonly baseKey: KeyElement;
readonly promise: Promise<RealizedGesture>;
cancel(): void;
resolve?(): void;
}
}

View file

@ -0,0 +1,34 @@
namespace com.keyman.osk {
/**
* 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 interface RealizedGesture {
readonly baseKey: KeyElement;
readonly promise: Promise<text.KeyEvent>;
clear(): void;
isVisible(): boolean;
updateTouch(touch: Touch): void;
}
}

View file

@ -3,7 +3,7 @@
/// <reference path="oskBaseKey.ts" />
/// <reference path="keytip.interface.ts" />
/// <reference path="browser/keytip.ts" />
/// <reference path="browser/subkeyPopup.ts" />
/// <reference path="browser/pendingLongpress.ts" />
namespace com.keyman.osk {
@ -41,7 +41,6 @@ namespace com.keyman.osk {
// State-related properties
ddOSK: boolean = false;
popupVisible: boolean;
keyPending: KeyElement;
touchPending: Touch;
deleteKey: KeyElement;
@ -60,14 +59,13 @@ namespace com.keyman.osk {
touchCount: number;
currentTarget: KeyElement;
// Popup key management
popupBaseKey: KeyElement;
popupPending: boolean = false;
subkeyDelayTimer: number;
popupDelay: number = 500;
// Used by embedded-mode's globe key
menuEvent: KeyElement; // Used by embedded-mode.
// Popup key management
keytip: KeyTip;
subkeyPopup: browser.SubkeyPopup;
pendingSubkey: PendingGesture;
subkeyGesture: RealizedGesture;
get layerId(): string {
return this._layerId;
@ -426,7 +424,7 @@ namespace com.keyman.osk {
let kbdAspectRatio = layerGroup.offsetWidth / this.kbdDiv.offsetHeight;
let baseKeyProbabilities = this.layout.getLayer(this.layerId).getTouchProbabilities(touchKbdPos, kbdAspectRatio);
if(!this.popupBaseKey || !this.popupBaseKey.key) {
if(!this.subkeyGesture || !this.subkeyGesture.baseKey.key) {
return baseKeyProbabilities;
} else {
// A temp-hack, as this was noted just before 14.0's release.
@ -438,7 +436,7 @@ namespace com.keyman.osk {
let baseMass = 1.0;
let baseKeyMass = 1.0;
let baseKeyID = this.popupBaseKey.key.spec.coreID;
let baseKeyID = this.subkeyGesture.baseKey.key.spec.coreID;
let popupKeyMass = 0.0;
let popupKeyID: string = null;
@ -514,8 +512,7 @@ namespace com.keyman.osk {
this.cancelDelete();
// Prevent multi-touch if popup displayed
var sk = document.getElementById('kmw-popup-keys');
if((sk && sk.style.visibility == 'visible') || this.popupVisible) {
if(this.subkeyGesture && this.subkeyGesture.isVisible()) {
return;
}
@ -561,14 +558,20 @@ namespace com.keyman.osk {
} else {
if(this.keyPending) {
this.highlightKey(this.keyPending, false);
this.modelKeyClick(this.keyPending, this.touchPending);
this.clearPopup();
if(this.subkeyGesture && this.subkeyGesture instanceof browser.SubkeyPopup) {
let subkeyPopup = this.subkeyGesture as browser.SubkeyPopup;
subkeyPopup.updateTouch(e.changedTouches[0]);
subkeyPopup.finalize(e.changedTouches[0]);
} else {
this.modelKeyClick(this.keyPending, this.touchPending);
}
// 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.touchHold(key);
this.initGestures(key, e.changedTouches[0]);
}
this.keyPending = key;
this.touchPending = e.changedTouches[0];
@ -583,32 +586,25 @@ namespace com.keyman.osk {
**/
release: (e: TouchEvent) => void = function(this: VisualKeyboard, e: TouchEvent) {
// Prevent incorrect multi-touch behaviour if native or device popup visible
var sk = document.getElementById('kmw-popup-keys'), t = this.currentTarget;
var t = this.currentTarget;
// Clear repeated backspace if active, preventing 'sticky' behavior.
this.cancelDelete();
if((sk && sk.style.visibility == 'visible')) {
if((this.subkeyGesture && this.subkeyGesture.isVisible())) {
// Ignore release if a multiple touch
if(e.touches.length > 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) && (t.id != this.popupBaseKey.id))) {
this.highlightKey(this.keyPending,false);
this.clearPopup();
this.keyPending = null;
this.touchPending = null;
if(this.subkeyGesture instanceof browser.SubkeyPopup) {
let subkeyPopup = this.subkeyGesture as browser.SubkeyPopup;
subkeyPopup.finalize(e.changedTouches[0]);
}
}
this.highlightKey(this.keyPending,false);
this.keyPending = null;
this.touchPending = null;
// Only set when embedded in our Android/iOS app. Signals that the device is handling
// subkeys, so we shouldn't allow output for the base key.
//
// Note that on iOS (at least), this.release() will trigger before kmwembedded.ts's
// executePopupKey() function.
if(this.popupVisible) {
return;
}
@ -631,7 +627,6 @@ namespace com.keyman.osk {
// Process and clear highlighting of pending target
if(this.keyPending) {
this.highlightKey(this.keyPending,false);
// Output character unless moved off key
if(this.keyPending.className.indexOf('hidden') < 0 && tc > 0 && !beyondEdge) {
this.modelKeyClick(this.keyPending, e.changedTouches[0]);
@ -702,77 +697,29 @@ namespace com.keyman.osk {
return;
}
// Do not move over keys if device popup visible
if(this.popupVisible) {
if(key1 == null) {
if(key0) {
this.highlightKey(key0,false);
}
this.keyPending=null;
this.touchPending=null;
} else {
if(key1 == this.popupBaseKey) {
if(!key1.classList.contains('kmw-key-touched')) {
this.highlightKey(key1,true);
}
this.keyPending = key1;
this.touchPending = e.touches[0];
} else {
if(key0) {
this.highlightKey(key0,false);
}
this.keyPending = null;
this.touchPending = null;
}
}
// Update all gesture tracking. The function returns true if further input processing
// should be blocked.
if(this.updateGestures(key1, key0, e.changedTouches[0])) {
return;
}
var sk=document.getElementById('kmw-popup-keys');
// Use the popup duplicate of the base key if a phone with a visible popup array
if(sk && sk.style.visibility == 'visible' && this.device.formFactor == 'phone' && key1 == this.popupBaseKey) {
key1 = <KeyElement> sk.childNodes[0].firstChild;
}
// Identify current touch position (to manage off-key release)
this.currentTarget = key1;
// Clear previous key highlighting
if(key0 && key1 && key1 !== key0) {
// _Box has (most of) the useful client values.
let _Box = this.kbdDiv.parentElement ? this.kbdDiv.parentElement : keyman.osk._Box;
let height = this.kbdDiv.offsetHeight;
// We need to adjust the offset properties by any offsets related to the active banner.
// Determine the y-threshold at which touch-cancellation should automatically occur.
let rowCount = this.layers[this.layerIndex].row.length;
let yBufferThreshold = (0.333 * height / rowCount); // Allows vertical movement by 1/3 the height of a row.
var yMin = (this.kbdDiv && _Box) ? Math.max(5, this.kbdDiv.offsetTop - yBufferThreshold) : 5;
if(key0 && e.touches[0].pageY < yMin) {
this.highlightKey(key0,false);
}
// If popup is visible, need to move over popup, not over main keyboard
this.highlightSubKeys(key1,x,y);
if(sk && sk.style.visibility == 'visible') {
// 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 && key1 != this.popupBaseKey) {
return;
}
// Highlight the base key on devices that do not append it to the subkey array.
if(key1 && key1 == this.popupBaseKey && key1.className.indexOf('kmw-key-touched') < 0) {
this.highlightKey(key1,true);
}
// Cancel touch if moved up and off keyboard, unless popup keys visible
} else {
// _Box has (most of) the useful client values.
let _Box = this.kbdDiv.parentElement ? this.kbdDiv.parentElement : keyman.osk._Box;
let height = this.kbdDiv.offsetHeight;
// Determine the y-threshold at which touch-cancellation should automatically occur.
let rowCount = this.layers[this.layerIndex].row.length;
let yBufferThreshold = (0.333 * height / rowCount); // Allows vertical movement by 1/3 the height of a row.
var yMin = (this.kbdDiv && _Box) ? Math.max(5, this.kbdDiv.offsetTop - yBufferThreshold) : 5;
if(key0 && e.touches[0].pageY < yMin) {
this.highlightKey(key0,false);
this.showKeyTip(null,false);
this.keyPending = null;
this.touchPending = null;
}
this.showKeyTip(null,false);
this.keyPending = null;
this.touchPending = null;
}
// Replace the target key, if any, by the new target key
@ -787,29 +734,6 @@ namespace com.keyman.osk {
this.highlightKey(key1,true);
}
}
if(key0 && key1 && (key1 != key0) && (key1.id != '')) {
// Display the touch-hold keys (after a pause)
this.touchHold(key1);
/*
// Clear and restart the popup timer
if(this.subkeyDelayTimer)
{
window.clearTimeout(this.subkeyDelayTimer);
this.subkeyDelayTimer = null;
}
if(key1.subKeys != null)
{
this.subkeyDelayTimer = window.setTimeout(
function()
{
this.clearPopup();
this.showSubKeys(key1);
}.bind(this),
this.popupDelay);
}
*/
}
}.bind(this);
/**
@ -945,8 +869,6 @@ namespace com.keyman.osk {
// Turn off key highlighting (or preview)
this.highlightKey(e,false);
let core = com.keyman.singleton.core; // only singleton-based ref currently needed here.
// Future note: we need to refactor osk.OSKKeySpec to instead be a 'tag field' for
// keyboards.ActiveKey. (Prob with generics, allowing the Web-only parts to
// be fully specified within the tag.)
@ -958,7 +880,13 @@ namespace com.keyman.osk {
console.error("OSK key with ID '" + e.id + "', keyID '" + e.keyId + "' missing needed specification");
return null;
}
// Return the event object.
return this.keyEventFromSpec(keySpec, touch);
}
keyEventFromSpec(keySpec: keyboards.ActiveKey, touch?: Touch) {
let core = com.keyman.singleton.core; // only singleton-based ref currently needed here.
// Start: mirrors _GetKeyEventProperties
@ -1074,42 +1002,15 @@ namespace com.keyman.osk {
clearPopup() {
// Remove the displayed subkey array, if any, and cancel popup request
if(this.subkeyPopup) {
this.subkeyPopup.clear();
this.subkeyPopup = null;
if(this.subkeyGesture) {
this.subkeyGesture.clear();
this.subkeyGesture = null;
}
if(this.subkeyDelayTimer) {
window.clearTimeout(this.subkeyDelayTimer);
this.subkeyDelayTimer = null;
if(this.pendingSubkey) {
this.pendingSubkey.cancel();
this.pendingSubkey = null;
}
this.popupBaseKey = null;
}
//#region 'native'-mode subkey handling
/**
* Display touch-hold array of 'sub-keys' above the currently touched key
* @param {Object} e primary key element
*/
showSubKeys(e: KeyElement) {
// Do not show subkeys if key already released
if(this.keyPending == null) {
return;
}
// Clear key preview if any
this.showKeyTip(null,false);
this.popupBaseKey = e;
let subKeys = this.subkeyPopup = new browser.SubkeyPopup(this, e);
// Otherwise append the touch-hold (subkey) array to the OSK
let keyman = com.keyman.singleton;
keyman.osk._Box.appendChild(subKeys.element);
keyman.osk._Box.appendChild(subKeys.shim);
// Must be placed after its `.element` has been inserted into the DOM.
subKeys.reposition(this);
}
//#endregion
@ -1692,26 +1593,123 @@ namespace com.keyman.osk {
}
}
/**
* Touch hold key display management
*
* @param {Object} key base key object
*/
touchHold(key: KeyElement) {
// Clear and restart the popup timer
if(this.subkeyDelayTimer) {
window.clearTimeout(this.subkeyDelayTimer);
this.subkeyDelayTimer = null;
/**
* Starts an implementation-specific longpress gesture. Separately implemented for
* in-browser and embedded modes.
* @param key The base key of the longpress.
* @returns
*/
startLongpress(key: KeyElement): PendingGesture {
let _this = this;
// First-level object/Promise: will produce a subkey popup when the longpress gesture completes.
// 'Returns' a second-level object/Promise: resolves when a subkey is selected or is cancelled.
let pendingLongpress = new browser.PendingLongpress(this, key);
pendingLongpress.promise.then(function(subkeyPopup) {
// In-browser-specific handling.
if(subkeyPopup) {
// Append the touch-hold (subkey) array to the OSK
let keyman = com.keyman.singleton;
keyman.osk._Box.appendChild(subkeyPopup.element);
keyman.osk._Box.appendChild(subkeyPopup.shim);
// Must be placed after its `.element` has been inserted into the DOM.
subkeyPopup.reposition(_this);
}
});
return pendingLongpress;
}
if(typeof key['subKeys'] != 'undefined' && key['subKeys'] != null) {
this.subkeyDelayTimer = window.setTimeout(
function(this: VisualKeyboard) {
this.clearPopup();
this.showSubKeys(key);
}.bind(this), this.popupDelay);
/**
* 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, touch: Touch) {
if(key['subKeys']) {
let _this = this;
let pendingLongpress = this.startLongpress(key);
if(pendingLongpress == null) {
return;
}
this.pendingSubkey = pendingLongpress;
pendingLongpress.promise.then(function(subkeyPopup) {
if(_this.pendingSubkey == pendingLongpress) {
_this.pendingSubkey = null;
}
if(subkeyPopup) {
// Clear key preview if any
_this.showKeyTip(null,false);
_this.subkeyGesture = subkeyPopup;
subkeyPopup.promise.then(function(keyEvent: text.KeyEvent) {
// Allow active cancellation, even if the source should allow passive.
// It's an easy and cheap null guard.
if(keyEvent) {
PreProcessor.raiseKeyEvent(keyEvent);
}
_this.clearPopup();
});
}
});
}
}
/**
* 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 touch The current touch-coordinate for the gesture
* @returns true if should fully capture input, false if input should 'fall through'.
*/
updateGestures(currentKey: KeyElement, previousKey: KeyElement, touch: Touch): boolean {
let key0 = previousKey;
let key1 = currentKey;
// Clear previous key highlighting, allow subkey controller to highlight as appropriate.
if(this.subkeyGesture) {
if(key0) {
key0.key.highlight(false);
}
this.subkeyGesture.updateTouch(touch);
this.keyPending = null;
this.touchPending = null;
return true;
}
this.currentTarget = null;
// If popup is visible, need to move over popup, not over main keyboard
// Could be turned into a browser-longpress specific implementation within browser.PendingLongpress?
if(key1 && key1['subKeys'] != null) {
// Show popup keys immediately if touch moved up towards key array (KMEW-100, Build 353)
if((this.touchY - touch.pageY > 5) && this.pendingSubkey && this.pendingSubkey instanceof browser.PendingLongpress) {
this.pendingSubkey.resolve();
}
}
// 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) {
return true;
}
if(key0 && key1 && (key1 != key0) && (key1.id != '')) {
// While there may not be an active subkey menu, we should probably update which base key
// is being highlighted by the current touch & start a pending longpress for it.
this.clearPopup();
this.initGestures(key1, touch);
}
return false;
}
};
optionKey(e: KeyElement, keyName: string, keyDown: boolean) {
let keyman = com.keyman.singleton;
@ -1731,32 +1729,6 @@ namespace com.keyman.osk {
}
};
// Manage popup key highlighting
highlightSubKeys(k: KeyElement, x: number, y: number) {
// Test for subkey array, return if none
// Issue: if `k` is itself a subkey, this won't do subkey highlighting correctly.
// That "common case" is actually handled through _standard_ key highlighting.
if(k == null || k['subKeys'] == null) {
return;
}
// Highlight key at touch position (and clear other highlighting)
var skBox=document.getElementById('kmw-popup-keys');
//#region This section fills a different role than the method name would suggest.
// Might correspond better to a 'checkInstantSubkeys' or something.
// Show popup keys immediately if touch moved up towards key array (KMEW-100, Build 353)
if((this.touchY-y > 5) && skBox == null) {
if(this.subkeyDelayTimer) {
window.clearTimeout(this.subkeyDelayTimer);
}
this.showSubKeys(k);
}
//#endregion
};
/**
* Add (or remove) the keytip preview (if KeymanWeb on a phone device)
*
@ -1771,8 +1743,8 @@ namespace com.keyman.osk {
return;
}
var sk=this.subkeyPopup,
popup = (sk && sk.element.style.visibility == 'visible')
let sk = this.subkeyGesture;
let popup = (sk && sk.isVisible());
// If popup keys are active, do not show the key tip.
on = popup ? false : on;