diff --git a/common/core/web/keyboard-processor/src/keyboards/activeLayout.ts b/common/core/web/keyboard-processor/src/keyboards/activeLayout.ts
index 6cdddf7d83..442d23de64 100644
--- a/common/core/web/keyboard-processor/src/keyboards/activeLayout.ts
+++ b/common/core/web/keyboard-processor/src/keyboards/activeLayout.ts
@@ -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;
}
}
diff --git a/web/source/kmwembedded.ts b/web/source/kmwembedded.ts
index 15a32020da..d548909314 100644
--- a/web/source/kmwembedded.ts
+++ b/web/source/kmwembedded.ts
@@ -2,6 +2,7 @@
// References the base Keyman object (and consequently, the rest of the core objects).
///
///
+///
// 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 = ( 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;
};
/**
diff --git a/web/source/osk/banner.ts b/web/source/osk/banner.ts
index 7d69399876..0ba00a3920 100644
--- a/web/source/osk/banner.ts
+++ b/web/source/osk/banner.ts
@@ -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 {
diff --git a/web/source/osk/browser/pendingLongpress.ts b/web/source/osk/browser/pendingLongpress.ts
new file mode 100644
index 0000000000..84b2364dc0
--- /dev/null
+++ b/web/source/osk/browser/pendingLongpress.ts
@@ -0,0 +1,65 @@
+///
+///
+
+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;
+
+ 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(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));
+ }
+ }
+ }
+}
diff --git a/web/source/osk/browser/subkeyPopup.ts b/web/source/osk/browser/subkeyPopup.ts
index 4879140c13..23c7e24bee 100644
--- a/web/source/osk/browser/subkeyPopup.ts
+++ b/web/source/osk/browser/subkeyPopup.ts
@@ -1,16 +1,54 @@
///
+///
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;
+
+ // 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(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;
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/web/source/osk/embedded/pendingLongpress.ts b/web/source/osk/embedded/pendingLongpress.ts
new file mode 100644
index 0000000000..504c60f1e3
--- /dev/null
+++ b/web/source/osk/embedded/pendingLongpress.ts
@@ -0,0 +1,48 @@
+///
+///
+
+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;
+
+ constructor(vkbd: VisualKeyboard, e: KeyElement) {
+ this.vkbd = vkbd;
+ let _this = this;
+
+ this.promise = new Promise(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;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/source/osk/embedded/subkeyDelegator.ts b/web/source/osk/embedded/subkeyDelegator.ts
new file mode 100644
index 0000000000..645bb8c6a7
--- /dev/null
+++ b/web/source/osk/embedded/subkeyDelegator.ts
@@ -0,0 +1,110 @@
+///
+
+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;
+
+ private movedFromBaseKey: boolean = false;
+ private baseKeySelected: boolean = false;
+
+ constructor(vkbd: VisualKeyboard, e: KeyElement) {
+ this.vkbd = vkbd;
+
+ let _this = this;
+ this.promise = new Promise(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;
+ }
+ }
+ }
+}
diff --git a/web/source/osk/oskKey.ts b/web/source/osk/oskKey.ts
index f9e4509992..7a0bd2dd32 100644
--- a/web/source/osk/oskKey.ts
+++ b/web/source/osk/oskKey.ts
@@ -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);
+ }
}
-}
\ No newline at end of file
+}
diff --git a/web/source/osk/pendingGesture.interface.ts b/web/source/osk/pendingGesture.interface.ts
new file mode 100644
index 0000000000..637b108f58
--- /dev/null
+++ b/web/source/osk/pendingGesture.interface.ts
@@ -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;
+
+ cancel(): void;
+ resolve?(): void;
+ }
+}
\ No newline at end of file
diff --git a/web/source/osk/realizedGesture.interface.ts b/web/source/osk/realizedGesture.interface.ts
new file mode 100644
index 0000000000..faabf0a367
--- /dev/null
+++ b/web/source/osk/realizedGesture.interface.ts
@@ -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;
+
+ clear(): void;
+ isVisible(): boolean;
+ updateTouch(touch: Touch): void;
+ }
+}
\ No newline at end of file
diff --git a/web/source/osk/visualKeyboard.ts b/web/source/osk/visualKeyboard.ts
index 7906d61db7..93c967b7de 100644
--- a/web/source/osk/visualKeyboard.ts
+++ b/web/source/osk/visualKeyboard.ts
@@ -3,7 +3,7 @@
///
///
///
-///
+///
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 = 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;