diff --git a/web/source/dom/domEventHandlers.ts b/web/source/dom/domEventHandlers.ts index 52b741c60d..aa362e3a3f 100644 --- a/web/source/dom/domEventHandlers.ts +++ b/web/source/dom/domEventHandlers.ts @@ -596,7 +596,7 @@ namespace com.keyman.dom { prevTarget.hideCaret(); } - this.keyman.domManager.activeElement=target; + this.keyman.domManager.activeElement = target; // The issue here is that touching a DIV does not actually set the focus for iOS, even when enabled to accept focus (by setting tabIndex=0) // We must explicitly set the focus in order to remove focus from any non-KMW input target.focus(); //Android native browsers may not like this, but it is needed for Chrome, Safari @@ -718,7 +718,7 @@ namespace com.keyman.dom { * Close OSK and remove simulated caret on losing focus */ cancelInput(): void { - this.keyman.domManager.activeElement=null; + this.keyman.domManager.activeElement = null; this.keyman.domManager.lastActiveElement = null; this.keyman.osk.hideNow(); }; diff --git a/web/source/dom/domManager.ts b/web/source/dom/domManager.ts index f0d5dc9feb..ea0053d9da 100644 --- a/web/source/dom/domManager.ts +++ b/web/source/dom/domManager.ts @@ -458,7 +458,7 @@ namespace com.keyman.dom { this.activeElement = null; } this.lastActiveElement = null; - this.keyman.osk._Hide(false); + this.keyman.osk.startHide(false); } return; @@ -1257,8 +1257,14 @@ namespace com.keyman.dom { set lastActiveElement(Pelem: HTMLElement) { DOMEventHandlers.states._lastActiveElement = Pelem; - if(this.lastActiveElement == null && this.activeElement == null) { - this.keyman.osk.hideNow(); // originally from a different one, seemed to serve the same role? + const osk = this.keyman.osk; + if(osk) { + if(this.lastActiveElement == null && this.activeElement == null) { + // Assigning to the property does have side-effects. + // If the property is already unset, it's best to not unset it again. + osk.activeTarget = null; + this.keyman.osk.hideNow(); // originally from a different one, seemed to serve the same role? + } } } @@ -1280,28 +1286,13 @@ namespace com.keyman.dom { // Hide the OSK when the control is blurred, unless the UI is being temporarily selected const osk = this.keyman.osk; - const device = this.keyman.util.device; - if(this.keyman.osk) { - if(!Pelem) { - if(this.keyman.osk && !isActivating) { - this.keyman.osk._Hide(false); - } - } else { - // Force display of OSK for touch input device, or if a CJK keyboard, to ensure visibility of pick list - if(device.touchable) { - osk._Enabled = true; - osk._Show(); - } else { - // Conditionally show the OSK when control receives the focus - if(this.keyman.isCJK()) { - osk._Enabled = true; - } - if(osk._Enabled) { - osk._Show(); - } else { - osk._Hide(false); - } - } + // const device = this.keyman.util.device; + + if(osk) { + const target = Pelem?._kmwAttachment?.interface || null; + if(osk && (target || !isActivating)) { + // Do not unset the field if the UI is activated. + osk.activeTarget = target; } } } @@ -1829,7 +1820,7 @@ namespace com.keyman.dom { this.keyman.ui['initialize'](); // Display the OSK (again) if enabled, in order to set its position correctly after // adding the UI to the page - this.keyman.osk._Show(); + this.keyman.osk.present(); } else if(this.keyman.isEmbedded) { // UI modules aren't utilized in embedded mode. There's nothing to init, so we simply // return instead of waiting for a UI module that will never come. diff --git a/web/source/keyboards/kmwkeyboards.ts b/web/source/keyboards/kmwkeyboards.ts index 72fff7c716..d23769df25 100644 --- a/web/source/keyboards/kmwkeyboards.ts +++ b/web/source/keyboards/kmwkeyboards.ts @@ -577,7 +577,7 @@ namespace com.keyman.keyboards { // Hide OSK and do not update keyboard list if using internal keyboard (desktops) if(PInternalName == '') { - osk._Hide(false); + osk.startHide(false); if(!this.keymanweb.isEmbedded) { util.wait(false); @@ -614,7 +614,7 @@ namespace com.keyman.keyboards { && ((this.keyboardStubs[Ln]['KLC'] == PLgCode) || (PLgCode == '---'))) { // Force OSK display for CJK keyboards (keyboards using a pick list) if(this.isCJK(this.keyboardStubs[Ln]) || util.device.touchable) { - osk._Enabled = true; + osk.displayIfActive = true; } // Create a script to load from the server - when it finishes loading, it will register itself, @@ -624,7 +624,7 @@ namespace com.keyman.keyboards { if(!this.keyboardStubs[Ln].asyncLoader) { // Always (temporarily) hide the OSK when loading a new keyboard, to ensure that a failure to load doesn't leave the current OSK displayed if(osk) { - osk._Hide(false); + osk.startHide(false); } var loadingStub = this.keyboardStubs[Ln]; diff --git a/web/source/osk/anchoredOskView.ts b/web/source/osk/anchoredOskView.ts index 917dea2153..64d61e4d81 100644 --- a/web/source/osk/anchoredOskView.ts +++ b/web/source/osk/anchoredOskView.ts @@ -32,6 +32,11 @@ namespace com.keyman.osk { super(modeledDevice); document.body.appendChild(this._Box); + + let keymanweb = com.keyman.singleton; + if(keymanweb.isEmbedded) { + this.activationMode == ActivationMode.manual; + } } /** @@ -66,8 +71,8 @@ namespace com.keyman.osk { this._Box.onmouseover = this._VKbdMouseOver; this._Box.onmouseout = this._VKbdMouseOut; - if(this._Enabled) { - this._Show(); + if(this.displayIfActive) { + this.present(); } } @@ -202,30 +207,8 @@ namespace com.keyman.osk { return; // I3363 (Build 301) } - /** - * Display KMW OSK at specified position (returns nothing) - * - * @param {number=} Px x-coordinate for OSK rectangle - * @param {number=} Py y-coordinate for OSK rectangle - */ - _Show(Px?: number, Py?: number) { - // Do not try to display OSK if no active element - if(!this.activeTarget) { - return; - } - - this.makeVisible(); - - var Ls = this._Box.style; - - /* In case it's still '0' from a hide() operation. - * Happens when _Show is called before the transitionend events are processed, - * which can happen in bulk-rendering contexts. - * - * (Opacity is only modified when device.touchable = true, though a couple of extra - * conditions may apply.) - */ - Ls.opacity='1'; + protected setDisplayPositioning() { + let Ls = this._Box.style; // The following code will always be executed except for externally created OSK such as EuroLatin if(this.vkbd) { @@ -233,86 +216,6 @@ namespace com.keyman.osk { Ls.left=Ls.bottom='0px'; Ls.border='none'; Ls.borderTop='1px solid gray'; - - this._Enabled=true; - this._Visible=true; // I3363 (Build 301) - } - } - - /** - * Hide Keymanweb On Screen Keyboard - * - * @param {boolean} hiddenByUser Distinguish between hiding on loss of focus and explicit hiding by user - */ - _Hide(hiddenByUser: boolean) { - let keymanweb = com.keyman.singleton; - let device = keymanweb.util.device; - // The test for CJK languages is necessary to prevent a picklist (displayed in the OSK) from being hidden by the user - // Once picklist functionality is separated out, this will no longer be needed. - // Logic is: execute always if hidden on lost focus, but if requested by user, only if not CJK - - if(keymanweb.isEmbedded) { - // We never hide the keyboard in embedded mode - return; - } - - // Save current size if visible - const priorDisplayStyle = this._Box.style.display; - this.makeHidden(hiddenByUser); - - if(hiddenByUser) { - //osk.loadCookie(); // preserve current offset and userlocated state - this._Enabled = ((keymanweb.isCJK() || device.touchable)? true : false); // I3363 (Build 301) - } else if(device.formFactor == 'desktop') { - //Allow desktop OSK to remain visible on blur if body class set - if(document.body.className.indexOf('osk-always-visible') >= 0) { - return; - } - } - - this._Visible = false; - if(this._Box && device.touchable && this._Box.offsetHeight > 0) { // I3363 (Build 301) - var os=this._Box.style; - // Prevent insta-hide behavior; we want an animated fadeout here. - os.display = priorDisplayStyle; - - //Firefox doesn't transition opacity if start delay is explicitly set to 0! - if(typeof(os.MozBoxSizing) == 'string') { - os.transition='opacity 0.8s linear'; - } else { - os.transition=os.msTransition=os.WebkitTransition='opacity 0.5s linear 0'; - } - - // Cannot hide the OSK smoothly using a transitioned drop, since for - // position:fixed elements transitioning is incompatible with translate3d(), - // and also does not work with top, bottom or height styles. - // Opacity can be transitioned and is probably the simplest alternative. - // We must condition on osk._Visible in case focus has since been moved to another - // input (in which case osk._Visible will be non-zero) - window.setTimeout(function(this: AnchoredOSKView) { - var os=this._Box.style; - if(this._Visible) { - // Leave opacity alone and clear transition if another element activated - os.transition=os.msTransition=os.MozTransition=os.WebkitTransition=''; - } else { - // Set opacity to zero, should decrease smoothly - os.opacity='0'; - - // Actually hide the OSK at the end of the transition - this._Box.addEventListener('transitionend', this.hideNow, false); - this._Box.addEventListener('webkitTransitionEnd', this.hideNow, false); - } - }.bind(this), 200); // Wait a bit before starting, to allow for moving to another element - } - - // Allow UI to execute code when hiding the OSK - var p={}; - p['HiddenByUser']=hiddenByUser; - this.doHide(p); - - // If hidden by the UI, be sure to restore the focus - if(hiddenByUser) { - this.lastActiveTarget.focus(); } } diff --git a/web/source/osk/floatingOskView.ts b/web/source/osk/floatingOskView.ts index e2d11ed61b..b5cf1acaa4 100644 --- a/web/source/osk/floatingOskView.ts +++ b/web/source/osk/floatingOskView.ts @@ -22,6 +22,7 @@ namespace com.keyman.osk { // OSK positioning fields userPositioned: boolean = false; + specifiedPosition: boolean = false; x: number; y: number; noDrag: boolean = false; @@ -88,10 +89,11 @@ namespace com.keyman.osk { this.footerView = null; } + this.loadCookie(); this.setNeedsLayout(); - if(this._Enabled) { - this._Show(); + if(this.displayIfActive) { + this.present(); } } @@ -104,8 +106,8 @@ namespace com.keyman.osk { */ ['restorePosition']: (keepDefaultPosition?: boolean) => void = function(this: FloatingOSKView, keepDefaultPosition?: boolean) { let isVisible = this._Visible; - if(isVisible) { - this.lastActiveTarget.focus(); // I2036 - OSK does not unpin to correct location + if(isVisible && this.activeTarget instanceof dom.targets.OutputTarget) { + this.activeTarget?.focus(); // I2036 - OSK does not unpin to correct location } this.loadCookie(); @@ -117,7 +119,7 @@ namespace com.keyman.osk { this.saveCookie(); if(isVisible) { - this._Show(); + this.present(); } this.doResizeMove(); //allow the UI to respond to OSK movements @@ -131,7 +133,7 @@ namespace com.keyman.osk { * Description Test if KMW OSK is enabled */ ['isEnabled'](): boolean { - return this._Enabled; + return this.displayIfActive; } /** @@ -174,7 +176,7 @@ namespace com.keyman.osk { var c = util.loadCookie('KeymanWeb_OnScreenKeyboard'); var p = this.getPos(); - c['visible'] = this._Enabled ? 1 : 0; + c['visible'] = this.displayIfActive ? 1 : 0; c['userSet'] = this.userPositioned ? 1 : 0; c['left'] = p.left; c['top'] = p.top; @@ -198,7 +200,7 @@ namespace com.keyman.osk { var c = util.loadCookie('KeymanWeb_OnScreenKeyboard'); - this._Enabled = util.toNumber(c['visible'], 1) == 1; + this.displayIfActive = util.toNumber(c['visible'], 1) == 1; this.userPositioned = util.toNumber(c['userSet'], 0) == 1; this.x = util.toNumber(c['left'],-1); this.y = util.toNumber(c['top'],-1); @@ -471,63 +473,70 @@ namespace com.keyman.osk { } } + public setDisplayPositioning() { + var Ls = this._Box.style; + + Ls.position='absolute'; Ls.display='block'; //Ls.visibility='visible'; + Ls.left='0px'; + if(this.specifiedPosition || this.userPositioned) { + Ls.left = this.x+'px'; + Ls.top = this.y+'px'; + } else { + let el: HTMLElement = null; + if(this.activeTarget instanceof dom.targets.OutputTarget) { + el = this.activeTarget?.getElement(); + } + + if(this.dfltX) { + Ls.left=this.dfltX; + } else if(typeof el != 'undefined' && el != null) { + Ls.left=dom.Utils.getAbsoluteX(el) + 'px'; + } + + if(this.dfltY) { + Ls.top=this.dfltY; + } else if(typeof el != 'undefined' && el != null) { + Ls.top=(dom.Utils.getAbsoluteY(el) + el.offsetHeight)+'px'; + } + } + + // Unset the flag, keeping 'specified position' specific to single + // presentAtPosition calls. + this.specifiedPosition = false; + } + /** * Display KMW OSK at specified position (returns nothing) * * @param {number=} Px x-coordinate for OSK rectangle * @param {number=} Py y-coordinate for OSK rectangle */ - _Show(Px?: number, Py?: number) { - // Do not try to display OSK if no active element - if(!this.activeTarget) { + presentAtPosition(Px?: number, Py?: number) { + if(!this.mayShow()) { return; } - // Never display the OSK for desktop browsers unless KMW element is focused, and a keyboard selected - if(this.activeKeyboard == null || !this._Enabled) { + this.specifiedPosition = Px >= 0 || Py >= 0; //probably never happens, legacy support only + if(this.specifiedPosition) { + this.x = Px; + this.y = Py; + } + + // Combines the two paths with set positioning. + this.specifiedPosition = this.specifiedPosition || this.userPositioned; + + this.present(); + } + + present() { + if(!this.mayShow()) { return; } - this.makeVisible(); - - var Ls = this._Box.style; - - Ls.position='absolute'; Ls.display='block'; //Ls.visibility='visible'; - Ls.left='0px'; - this.loadCookie(); - if(Px >= 0) { //probably never happens, legacy support only - Ls.left = Px + 'px'; Ls.top = Py + 'px'; - } else { - if(this.userPositioned) { - Ls.left=this.x+'px'; - Ls.top=this.y+'px'; - } else { - var el = this.activeTarget?.getElement(); - - if(this.dfltX) { - Ls.left=this.dfltX; - } else if(typeof el != 'undefined' && el != null) { - Ls.left=dom.Utils.getAbsoluteX(el) + 'px'; - } - - if(this.dfltY) { - Ls.top=this.dfltY; - } else if(typeof el != 'undefined' && el != null) { - Ls.top=(dom.Utils.getAbsoluteY(el) + el.offsetHeight)+'px'; - } - } - } - this._Enabled=true; - this._Visible=true; - - if(this.vkbd) { - this.vkbd.refit(); - } - - this.saveCookie(); - this.desktopLayout.titleBar.showPin(this.userPositioned); + super.present(); + // Allow desktop UI to execute code when showing the OSK var Lpos={}; Lpos['x']=this._Box.offsetLeft; @@ -536,99 +545,12 @@ namespace com.keyman.osk { this.doShow(Lpos); } - /** - * Hide Keymanweb On Screen Keyboard - * - * @param {boolean} hiddenByUser Distinguish between hiding on loss of focus and explicit hiding by user - */ - _Hide(hiddenByUser: boolean) { - let keymanweb = com.keyman.singleton; - let device = keymanweb.util.device; - // The test for CJK languages is necessary to prevent a picklist (displayed in the OSK) from being hidden by the user - // Once picklist functionality is separated out, this will no longer be needed. - // Logic is: execute always if hidden on lost focus, but if requested by user, only if not CJK - - // Save current size if visible - const priorDisplayStyle = this._Box.style.display; - this.makeHidden(hiddenByUser); + public startHide(hiddenByUser: boolean) { + super.startHide(hiddenByUser); if(hiddenByUser) { - //osk.loadCookie(); // preserve current offset and userlocated state - this._Enabled = ((keymanweb.isCJK() || device.touchable)? true : false); // I3363 (Build 301) this.saveCookie(); // Save current OSK state, size and position (desktop only) - } else if(device.formFactor == 'desktop') { - //Allow desktop OSK to remain visible on blur if body class set - if(document.body.className.indexOf('osk-always-visible') >= 0) { - return; - } } - - this._Visible = false; - if(this._Box && device.touchable && this._Box.offsetHeight > 0) { // I3363 (Build 301) - var os=this._Box.style; - // Prevent insta-hide behavior; we want an animated fadeout here. - os.display = priorDisplayStyle; - - //Firefox doesn't transition opacity if start delay is explicitly set to 0! - if(typeof(os.MozBoxSizing) == 'string') { - os.transition='opacity 0.8s linear'; - } else { - os.transition=os.msTransition=os.WebkitTransition='opacity 0.5s linear 0'; - } - - // Cannot hide the OSK smoothly using a transitioned drop, since for - // position:fixed elements transitioning is incompatible with translate3d(), - // and also does not work with top, bottom or height styles. - // Opacity can be transitioned and is probably the simplest alternative. - // We must condition on osk._Visible in case focus has since been moved to another - // input (in which case osk._Visible will be non-zero) - window.setTimeout(function(this: AnchoredOSKView) { - var os=this._Box.style; - if(this._Visible) { - // Leave opacity alone and clear transition if another element activated - os.transition=os.msTransition=os.MozTransition=os.WebkitTransition=''; - } else { - // Set opacity to zero, should decrease smoothly - os.opacity='0'; - - // Actually hide the OSK at the end of the transition - this._Box.addEventListener('transitionend', this.hideNow, false); - this._Box.addEventListener('webkitTransitionEnd', this.hideNow, false); - } - }.bind(this), 200); // Wait a bit before starting, to allow for moving to another element - } - - // Allow UI to execute code when hiding the OSK - var p={}; - p['HiddenByUser']=hiddenByUser; - this.doHide(p); - - // If hidden by the UI, be sure to restore the focus - if(hiddenByUser) { - this.lastActiveTarget.focus(); - } - } - - /** - * Allow UI to respond to OSK being shown (passing position and properties) - * - * @param {Object=} p object with coordinates and userdefined flag - * @return {boolean} - * - */ - doShow(p) { - return com.keyman.singleton.util.callEvent('osk.show',p); - } - - /** - * Allow UI to update respond to OSK being hidden - * - * @param {Object=} p object with coordinates and userdefined flag - * @return {boolean} - * - */ - doHide(p) { - return com.keyman.singleton.util.callEvent('osk.hide',p); } /** diff --git a/web/source/osk/layouts/titleBar.ts b/web/source/osk/layouts/titleBar.ts index 411fcf2a11..d3d060e640 100644 --- a/web/source/osk/layouts/titleBar.ts +++ b/web/source/osk/layouts/titleBar.ts @@ -73,7 +73,7 @@ namespace com.keyman.osk.layouts { } this._closeButton.onclick = function () { - osk._Hide(true); + osk.startHide(true); return false; }; diff --git a/web/source/osk/oskView.ts b/web/source/osk/oskView.ts index 5dea81af7e..9399ffbe00 100644 --- a/web/source/osk/oskView.ts +++ b/web/source/osk/oskView.ts @@ -17,6 +17,12 @@ namespace com.keyman.osk { 'nomove'?: boolean }; + export enum ActivationMode { + static = "static", // For use by documentation keyboards, eventually. + manual = "manual", + automatic = "automatic" + } + export abstract class OSKView { _Box: HTMLDivElement; @@ -25,13 +31,16 @@ namespace com.keyman.osk { keyboardView: KeyboardView; // Which implements OSKViewComponent footerView: OSKViewComponent; - protected device: com.keyman.utils.DeviceSpec; + protected readonly device: com.keyman.utils.DeviceSpec; + protected readonly hostDevice: com.keyman.utils.DeviceSpec; private _boxBaseMouseDown: (e: MouseEvent) => boolean; private _boxBaseTouchStart: (e: TouchEvent) => boolean; private _boxBaseTouchEventCancel: (e: TouchEvent) => boolean; private keyboard: keyboards.Keyboard; + + private _target: text.OutputTarget; /** * The configured width for this OSKManager. May be `undefined` or `null` @@ -65,9 +74,21 @@ namespace com.keyman.osk { private needsLayout: boolean = true; - constructor(deviceSpec: com.keyman.utils.DeviceSpec) { + // + private _activationMode: ActivationMode = ActivationMode.automatic; + private _displayIfActive: boolean = true; + + private _animatedHideTimeout: number; + private _animatedHideResolver: () => void; + + constructor(deviceSpec: com.keyman.utils.DeviceSpec, hostDevice?: com.keyman.utils.DeviceSpec) { this.device = deviceSpec; + if(!hostDevice) { + hostDevice = deviceSpec; + } + this.hostDevice = hostDevice; + // OSK initialization - create DIV and set default styles this._Box = document.createElement('div'); // Container for OSK (Help DIV, displayed when user clicks Help icon) this._Box.style.userSelect = 'none'; @@ -158,14 +179,111 @@ namespace com.keyman.osk { this._boxBaseTouchStart = null; } - public get activeTarget(): dom.targets.OutputTarget { - const el=com.keyman.singleton.domManager.activeElement; - return dom.Utils.getOutputTarget(el); + /** + * Gets and sets the IME-like interface (`OutputTarget`) to be affected by events from + * the OSK. + * + * If `activationMode` is `'conditional'`, this property's state controls the visibility + * of the OSKView. + */ + public get activeTarget(): text.OutputTarget { + return this._target; } - public get lastActiveTarget(): dom.targets.OutputTarget { - const el = com.keyman.singleton.domManager.lastActiveElement; - return dom.Utils.getOutputTarget(el); + public set activeTarget(targ: text.OutputTarget) { + // If already null & set to null again, take no action. + if(this._target == null && targ == null) { + return; + } + + this._target = targ; + this.commonCheckAndDisplay(); + } + + /** + * Determines the activation state model used to control presentation of the OSK. + * - `'conditional'`: Only displays if `activeTarget` is non-null - if there is an active + * target that can receive the OSK's context-manipulation events. + * - `'manual'`: Display is directly controlled by manipulating the value of `displayIfActive`. + * It may be displayed while `activeTarget` is `null`. + * - `'static'`: The OSK should be permanently displayed and may never be hidden. + */ + get activationMode(): ActivationMode { + if(!this._activationMode) { + this._activationMode = ActivationMode.automatic; + } + + return this._activationMode; + } + + set activationMode(mode: ActivationMode) { + this._activationMode = mode; + this.commonCheckAndDisplay(); + } + + /** + * Implementation of the activation modeling described in the documentation for + * `activationMode`. + */ + protected get activationConditionsMet(): boolean { + switch(this.activationMode) { + case 'manual': + return true; + case 'static': + return true; + case 'automatic': + return !!this.activeTarget; + default: + console.error("Unexpected activation mode set for the OSK."); + return false; + } + } + + /** + * A property denoting whether or not the OSK should be presented if it meets its + * activation conditions. + * + * When `activationMode == 'manual'`, `displayIfActive == true` is the lone + * activation condition. + * + * Note: cannot be set to `false` if `activationMode == 'static'`. + */ + get displayIfActive(): boolean { + return this._displayIfActive; + } + + set displayIfActive(flag: boolean) { + if(this.displayIfActive == flag) { + return; + } + + // if is touch device or is CJK keyboard, this.displayIfActive must remain true. + if(this.keyboard?.isCJK && !flag) { + console.warn("Cannot hide display of OSK for CJK keyboards."); + flag = true; + } else if(this.hostDevice.touchable && !flag) { + console.warn("Cannot hide display of OSK when hosted on touch-based devices."); + flag = true; + } else if(this.activationMode == 'static') { + // Silently fail; it's a documentation keyboard. + // This is the primary difference between 'manual' and 'static'. + flag = true; + } + + this._displayIfActive = flag; + this.commonCheckAndDisplay(); + } + + /** + * Used by the activation & visibility properties as a common helper; all of their + * setters rely on this function to manage presentation (showing / hiding) of the OSK. + */ + private commonCheckAndDisplay() { + if(this.activationConditionsMet && this.displayIfActive) { + this.present(); + } else { + this.startHide(false); + } } public get vkbd(): VisualKeyboard { @@ -271,6 +389,10 @@ namespace com.keyman.osk { public set activeKeyboard(keyboard: keyboards.Keyboard) { this.keyboard = keyboard; this.loadActiveKeyboard(); + + if(this.keyboard?.isCJK) { + this.displayIfActive = true; + } } /* private */ computeFrameHeight(): number { @@ -327,6 +449,13 @@ namespace com.keyman.osk { this.needsLayout = false; // Step 3: perform layout operations. + if(!this._baseFontSize && this.parsedBaseFontSize) { + // Make sure to initialize the default font size if it hasn't already been set! + this.banner.element.style.fontSize = this.baseFontSize; + if(this.vkbd) { + this.vkbd.fontSize = this.parsedBaseFontSize; + } + } if(!pending) { this.headerView?.refreshLayout(); this.bannerView.refreshLayout(); @@ -396,13 +525,9 @@ namespace com.keyman.osk { // Add suggestion banner bar to OSK this._Box.appendChild(this.banner.element); - this.banner.element.style.fontSize = this.baseFontSize; let kbdView: KeyboardView = this.keyboardView = this._GenerateKeyboardView(this.activeKeyboard); this._Box.appendChild(kbdView.element); - if(kbdView instanceof VisualKeyboard) { - kbdView.fontSize = this.parsedBaseFontSize; - } kbdView.postInsert(); // Add footer element to OSK only for desktop browsers @@ -515,11 +640,17 @@ namespace com.keyman.osk { return vkbd; } - // Corresponds to the desktop OSK's _Show, but acts as a core, common method - // usable by all display patterns. - protected makeVisible() { - // Do not try to display/render the OSK if undefined or no keyboard is loaded. - if(!this._Box || !this.keyboardView) { + /** + * The main function for presenting the OSKView. + * + * This includes: + * - refreshing its layout + * - displaying it + * - positioning it + */ + public present(): void { + // Do not try to display OSK if no active element + if(!this.mayShow()) { return; } @@ -535,6 +666,15 @@ namespace com.keyman.osk { this.keyboardView.showLanguage(); } + this._Visible=true; + + /* In case it's still '0' from a hide() operation. + * + * (Opacity is only modified when device.touchable = true, + * though a couple of extra conditions may apply.) + */ + this._Box.style.opacity = '1'; + // If OSK still hidden, make visible only after all calculation finished if(this._Box.style.visibility == 'hidden') { let _this = this; @@ -542,16 +682,222 @@ namespace com.keyman.osk { _this._Box.style.visibility = 'visible'; }, 0); } + + this.setDisplayPositioning(); } - protected makeHidden(hiddenByUser: boolean) { - // Save current size if visible - if(this._Box && this._Box.style.display == 'block' && this.keyboardView instanceof VisualKeyboard) { - this.keyboardView.refit(); + /** + * Method usable by subclasses of OSKView to control that OSKView type's + * positioning behavior when needed by the present() method. + */ + protected abstract setDisplayPositioning(); + + /** + * Method used to start a potentially-asynchronous hide of the OSK. + * @param hiddenByUser `true` if this hide operation was directly requested by the user. + */ + public startHide(hiddenByUser: boolean): void { + if(!this.mayHide(hiddenByUser)) { + return; + } + + if(hiddenByUser) { + // The one location outside of the `displayIfActive` property that bypasses the setter. + // Avoids needless recursion that could be triggered by it, as we're already in the + // process of hiding the OSK anyway. + this._displayIfActive = ((this.keyboard.isCJK || this.hostDevice.touchable)? true : false); // I3363 (Build 301) + } + + let promise: Promise = null; + if(this._Box && this.hostDevice.touchable && !(this.keyboardView instanceof EmptyView)) { + /** + * Note: this refactored code appears to reflect a currently-dead code path. 14.0's + * equivalent is either extremely niche or is actually inaccessible. + */ + promise = this.useHideAnimation(); + } else { + promise = Promise.resolve(true); + } + + const _this = this; + promise.then(function(shouldHide: boolean) { + if(shouldHide) { + _this.finalizeHide(); + } + }); + + // Allow UI to execute code when hiding the OSK + var p={}; + p['HiddenByUser']=hiddenByUser; + this.doHide(p); + + // If hidden by the UI, be sure to restore the focus + if(hiddenByUser && this.activeTarget instanceof dom.targets.OutputTarget) { + this.activeTarget?.focus(); + } + } + + /** + * Performs the _actual_ logic and functionality involved in hiding the OSK. + */ + protected finalizeHide() { + if(document.body.className.indexOf('osk-always-visible') >= 0) { + return; } if(this._Box) { - this._Box.style.display = 'none'; + let bs=this._Box.style; + bs.display = 'none'; + bs.transition = ''; + bs.opacity = '1'; + this._Visible=false; + } + + if(this.vkbd) { + this.vkbd.onHide(); + } + } + + /** + * + * @returns `false` if the OSK is in an invalid state for being presented to the user. + */ + protected mayShow(): boolean { + if(!this.activationConditionsMet) { + return false; + } + + // Never display the OSK for desktop browsers unless KMW element is focused, and a keyboard selected + if(!this.keyboardView || this.keyboardView instanceof EmptyView || !this.displayIfActive) { + return false; + } + + if(!this._Box) { + return false; + } + + return true; + } + + /** + * + * @param hiddenByUser + * @returns `false` if the OSK is in an invalid state for being hidden from the user. + */ + protected mayHide(hiddenByUser: boolean): boolean { + if(this.activationMode != 'automatic' && this.displayIfActive) { + return false; + } + + if(!hiddenByUser && this.hostDevice.formFactor == 'desktop') { + //Allow desktop OSK to remain visible on blur if body class set + if(document.body.className.indexOf('osk-always-visible') >= 0) { + return false; + } + } + + return true; + } + + /** + * Applies CSS styling and handling needed to perform a fade animation when + * hiding the OSK. + * + * Note: currently reflects an effectively-dead code path, though this is + * likely not intentional. Other parts of the KMW engine seem to call hideNow() + * synchronously after each and every part of the engine that calls this function, + * cancelling the Promise. + * + * @returns A Promise denoting either cancellation of the hide (`false`) or + * completion of the hide & its animation (`true`) + */ + protected useHideAnimation(): Promise { + const os = this._Box.style; + const _this = this; + const promise = new Promise(function(resolve) { + os.transition='opacity 0.5s linear 0'; + + _this._animatedHideResolver = resolve; + + // Cannot hide the OSK smoothly using a transitioned drop, since for + // position:fixed elements transitioning is incompatible with translate3d(), + // and also does not work with top, bottom or height styles. + // Opacity can be transitioned and is probably the simplest alternative. + // We must condition on osk._Visible in case focus has since been moved to another + // input (in which case osk._Visible will be non-zero) + _this._animatedHideTimeout = window.setTimeout(function(this: AnchoredOSKView) { + if(_this._animatedHideResolver) { + _this._animatedHideResolver(); + } + + _this._animatedHideTimeout = 0; + _this._animatedHideResolver = null; + }.bind(_this), 200); // Wait a bit before starting, to allow for moving to another element + }); + + return promise.then(function() { + // Repro for passing this condition: + // 1. Touch an input element of the page + // 2. Within a second (before the focusing timer expires), touch the base page. + // See domEventHandlers.ts, `focusTimer` / `setFocusTimer`. + // 3. After the timer expires, touch the base page again. + if(_this._Visible && _this.activationConditionsMet) { + // Leave opacity alone and clear transition if another element activated + os.transition=''; + return false; + } else { + // Set opacity to zero, should decrease smoothly. Starts the actual animation. + os.opacity='0'; + + // Listen for the animation's end. + return new Promise(function(resolve) { + _this._animatedHideResolver = resolve; + // Actually hide the OSK at the end of the transition + _this._Box.addEventListener('transitionend', _this._animatedHideResolver, false); + _this._Box.addEventListener('webkitTransitionEnd', _this._animatedHideResolver, false); + }).then(function() { + // Remove the promise's resolver method from future handling. + _this._Box.removeEventListener('transitionend', _this._animatedHideResolver, false); + _this._Box.removeEventListener('webkitTransitionEnd', _this._animatedHideResolver, false); + + // The hide animation is considered complete now. + return true; + }); + } + }); + } + + /** + * Used to synchronously hide the OSK, cancelling any async hide animations that have + * not started and immediately completing the hide of any hide ops pending completion + * of their animation. + */ + public hideNow() { + if(!this._Box) { + return; + } + + // Two possible uses for _animatedHideResolver: + // - _animatedHideTimeout is set: animation is waiting to start + // - _animatedHideTimeout is null: animation has already started. + + // Was an animated hide waiting to start? Just cancel it. + if(this._animatedHideTimeout) { + window.clearTimeout(this._animatedHideTimeout); + this._animatedHideResolver = null; + this._animatedHideTimeout = 0; + } + + // Was an animated hide already in progress? If so, just trigger it early. + if(this._animatedHideResolver) { + this._animatedHideResolver(); // also triggers finalizeHide(). + this._animatedHideResolver = null; + } else { + const os = this._Box.style; + os.transition=''; + os.opacity='0'; + + this.finalizeHide(); } } @@ -595,24 +941,6 @@ namespace com.keyman.osk { * The simplest way forward is to maintain them, then resolve them independently, * one at a time. */ - - /* - * Display KMW OSK at specified position (returns nothing) - * - * The positioning parameters only make sense for the FloatingOSKView type; - * other implementations should use no parameters whatsoever. - * - * @param {number=} Px x-coordinate for OSK rectangle - * @param {number=} Py y-coordinate for OSK rectangle - */ - public abstract _Show(Px?: number, Py?: number); - - /** - * Hide Keymanweb On Screen Keyboard - * - * @param {boolean} hiddenByUser Distinguish between hiding on loss of focus and explicit hiding by user - */ - public abstract _Hide(hiddenByUser: boolean); /** * Display build number @@ -640,49 +968,10 @@ namespace com.keyman.osk { menu.show(); } - /** - * Function hideNow - * Scope Private - * Description Hide the OSK unconditionally and immediately, cancel any pending transition - * - * Usages: - * - during rotations to temporarily hide the OSK during layout ops - * - when controls lose focus (N/A to embedded mode) - * - * Somewhat conflated with the _Show / _Hide methods, which often serve more as an - * "enable" vs "disable" feature on the OSK - though that distinction isn't super-clear. - * - * Definitely needs clearer design & modeling, at the least. - */ - hideNow: () => void = function(this: OSKView) { // I3363 (Build 301) - if(!this._Box) { - return; - } - - this._Box.removeEventListener('transitionend', this.hideNow, false); - this._Box.removeEventListener('webkitTransitionEnd', this.hideNow, false); - - if(document.body.className.indexOf('osk-always-visible') >= 0) { - return; - } - - var os=this._Box.style; - os.display='none'; - os.opacity='1'; - this._Visible=false; - os.transition=os.msTransition=os.MozTransition=os.WebkitTransition=''; - - if(this.vkbd) { - this.vkbd.onHide(); - } - }.bind(this); - - // OSK state fields + // OSK state fields & events // - // They're not very well defined or encapsulated; there's definitely room for more - // "polish" here. + // These are relatively stable and may be preserved as they are. _Visible: boolean = false; - _Enabled: boolean = true; /** * Function enabled @@ -691,7 +980,7 @@ namespace com.keyman.osk { * Description Test if KMW OSK is enabled */ ['isEnabled'](): boolean { - return this._Enabled; + return this.displayIfActive; } /** @@ -711,8 +1000,8 @@ namespace com.keyman.osk { * Description Prevent display of OSK window on focus */ ['hide']() { - this._Enabled = false; - this._Hide(true); + this.displayIfActive = false; + this.startHide(true); } /** @@ -723,21 +1012,36 @@ namespace com.keyman.osk { */ ['show'](bShow: boolean) { if(arguments.length > 0) { - this._Enabled=bShow; - if(bShow) { - this._Show(); - } else { - this._Hide(true); - } + this.displayIfActive = bShow; } else { - if(this._Visible) { - this._Hide(true); - } else { - this._Show(); + if(this.activationConditionsMet) { + this.displayIfActive = !this.displayIfActive; } } } + /** + * Allow UI to respond to OSK being shown (passing position and properties) + * + * @param {Object=} p object with coordinates and userdefined flag + * @return {boolean} + * + */ + doShow(p) { + return com.keyman.singleton.util.callEvent('osk.show',p); + } + + /** + * Allow UI to update respond to OSK being hidden + * + * @param {Object=} p object with coordinates and userdefined flag + * @return {boolean} + * + */ + doHide(p) { + return com.keyman.singleton.util.callEvent('osk.hide',p); + } + /** * Function addEventListener * Scope Public diff --git a/web/source/osk/visualKeyboard.ts b/web/source/osk/visualKeyboard.ts index 6273f897e7..abff959eed 100644 --- a/web/source/osk/visualKeyboard.ts +++ b/web/source/osk/visualKeyboard.ts @@ -308,15 +308,6 @@ namespace com.keyman.osk { } } - /** - * Called by OSKManager after resize operations in order to determine the final - * size actually used by the visual keyboard. - */ - public refit() { // TODO: Should probably remove; VisualKeyboard shouldn't be responsible for it. - this._width = this.kbdDiv.offsetWidth; - this._height = this.kbdDiv.offsetHeight; - } - /** * Returns the default properties for a key object, used to construct * both a base keyboard key and popup keys @@ -1175,7 +1166,8 @@ namespace com.keyman.osk { layerElement.style.display='block'; //b[n].style.visibility='visible'; - // If osk._Show has been called, there's probably been a change in modifier or state key state. Keep it updated! + // Most functions that call this one often indicate a change in modifier + // or state key state. Keep it updated! this._UpdateVKShiftStyle(); } else { layerElement.style.display='none'; @@ -1591,7 +1583,7 @@ namespace com.keyman.osk { oskManager.showLanguageMenu(); } else if(keyName.indexOf('K_ROPT') >= 0) { keyman.uiManager.setActivatingUI(false); - oskManager._Hide(true); + oskManager.startHide(true); let active = keyman.domManager.activeElement; if(dom.Utils.instanceof(active, "TouchAliasElement")) { (active as dom.TouchAliasElement).hideCaret(); diff --git a/web/source/text/domKbdInterface.ts b/web/source/text/domKbdInterface.ts index 2718014226..00da01aad9 100644 --- a/web/source/text/domKbdInterface.ts +++ b/web/source/text/domKbdInterface.ts @@ -101,20 +101,37 @@ namespace com.keyman.text { //The following entry points are defined but should not normally be used in a keyboard, as OSK display is no longer determined by the keyboard KeyboardInterface.prototype['hideHelp'] = function(): void { let keyman = com.keyman.singleton; - keyman.osk._Hide(true); + keyman.osk.startHide(true); } KeyboardInterface.prototype['showHelp'] = function(Px: number, Py: number): void { let keyman = com.keyman.singleton; - keyman.osk._Show(Px,Py); + if(keyman.osk instanceof osk.FloatingOSKView) { + keyman.osk.presentAtPosition(Px,Py); + } else { + keyman.osk.present(); + } } KeyboardInterface.prototype['showPinnedHelp'] = function(): void { let keyman = com.keyman.singleton; if(keyman.osk instanceof osk.FloatingOSKView) { - keyman.osk.userPositioned=true; + // An old KMW bug previously auto-unset the affected field when this function was + // used by CJK keyboards during rule processing. As a result, we need to condition + // on whether or not: + // 1. The active keyboard is CJK + // 2. A keyboard rule is actively processing. + // + // If BOTH are true, we do NOT mutate keyman.osk.userPositioned. + // Otherwise, not all conditions are met, so we still allow OSK pinning. + if(!keyman.core.activeKeyboard.isCJK || !this.ruleBehavior) { + keyman.osk.userPositioned=true; + } } - keyman.osk._Show(-1,-1); + // Automatically reuses previously-set positioning. + // Other OSK API functions must have previously been used to set the + // pinned position. + keyman.osk.present(); } // Also needed for some legacy CJK keyboards.