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;