diff --git a/web/src/app/browser/src/context/pageIntegrationHandlers.ts b/web/src/app/browser/src/context/pageIntegrationHandlers.ts new file mode 100644 index 0000000000..36bd089056 --- /dev/null +++ b/web/src/app/browser/src/context/pageIntegrationHandlers.ts @@ -0,0 +1,209 @@ +import { DomEventTracker } from 'keyman/engine/events'; + +import { KeymanEngine } from "../keymanEngine.js"; +import { FocusAssistant } from './focusAssistant.js'; + +// Note: in the future, it'd probably be best to have an instance per iframe window as +// well as the top-level window. This was not done in or before KMW 16.0 though, so +// we'll leave that out for now within the initial modular form of app/browser KMW in 17.0. +export class PageIntegrationHandlers { + private readonly window: Window; + private readonly engine: KeymanEngine; + private readonly domEventTracker = new DomEventTracker(); + + /** + * Used together with `deactivateOnRelease` to determine the distance of vertical scrolls; + * if sufficiently far at any point, we avoid deactivating the current context when it ends. + */ + private touchY: number; + + /** + * Used together with `touchY` to determine the distance of vertical scrolls; + * if sufficiently far at any point, we avoid deactivating the current context when it ends. + */ + private deactivateOnRelease: boolean; + + /** + * Used on certain browser/OS combinations (e.g. Chrome on Android) to prevent odd behaviors + * that arise when URL bars scroll into view during an ongoing scroll, as this can impede + * proper / smooth positioning of the OSK. (Deactivating the active target also hides the OSK.) + */ + private deactivateOnScroll: boolean; + + constructor(window: Window, engine: KeymanEngine) { + this.window = window; + this.engine = engine; + + this.attachHandlers(); + } + + private get focusAssistant(): FocusAssistant { + return this.engine.contextManager.focusAssistant; + } + + private suppressFocusCheck: (e: FocusEvent) => boolean = (e) => { + if(this.focusAssistant._IgnoreBlurFocus) { + // Prevent triggering other blur-handling events (as possible) + e.stopPropagation(); + e.cancelBubble = true; + } + // But DO perform default event behavior (actually blurring & focusing the affected element) + return true; + } + + /** + * Reset context when entering or exiting the active element. + * Will also trigger OSK shift state / layer reset. + **/ + private pageFocusHandler: (e: FocusEvent) => boolean = () => { + if(!this.focusAssistant.maintainingFocus && this.engine.osk?.vkbd) { + this.engine.contextManager.deactivateCurrentTarget(); + this.engine.contextManager.resetContext(); + } + return false; + } + + // Sets up page-default touch-based handling for activation-state management. + // These always trigger for the page, wherever a touch may occur. Does not + // prevent element-specific or OSK-key-specific handling from triggering. + + private touchStartActivationHandler: (e: TouchEvent) => boolean = (e) => { + const osk = this.engine.osk; + if(!osk) { + return false; + } + const device = this.engine.config.hostDevice; + + this.deactivateOnRelease=true; + this.touchY=e.touches[0].screenY; + + // On Chrome, scrolling up or down causes the URL bar to be shown or hidden + // according to whether or not the document is at the top of the screen. + // But when doing that, each OSK row top and height gets modified by Chrome + // looking very ugly. It would be best to hide the OSK then show it again + // when the user scroll finishes, but Chrome has no way to reliably report + // the touch end event after a move. c.f. http://code.google.com/p/chromium/issues/detail?id=152913 + // The best compromise behaviour is simply to hide the OSK whenever any + // non-input and non-OSK element is touched. + this.deactivateOnScroll=false; + if(device.OS == 'android' && device.browser == 'chrome') { + // this.deactivateOnScroll has the inverse of the 'true' default, + // but that fact actually facilitates the following conditional logic. + if(typeof(osk._Box) == 'undefined') return false; + if(typeof(osk._Box.style) == 'undefined') return false; + + // The following tests are needed to prevent the OSK from being hidden during normal input! + let p=(e.target as HTMLElement).parentElement; + if(typeof(p) != 'undefined' && p != null) { + if(p.className.indexOf('kmw-key-') >= 0) return false; + if(typeof(p.parentElement) != 'undefined' && p.parentElement != null) { + p=p.parentElement; + if(p.className.indexOf('kmw-key-') >= 0) return false; + } + } + + this.deactivateOnScroll = true; + } + return false; + }; + + private touchMoveActivationHandler: (e: TouchEvent) => boolean = (e) => { + if(this.deactivateOnScroll) { // Android / Chrone case. + this.focusAssistant.focusing = false; + this.engine.contextManager.deactivateCurrentTarget(); + } + + const y = e.touches[0].screenY; + const y0 = this.touchY; + if(y-y0 > 5 || y0-y < 5) { + this.deactivateOnRelease = false; + } + return false; + }; + + private touchEndActivationHandler: (e: TouchEvent) => boolean = (e) => { + // Should not hide OSK if simply closing the language menu (30/4/15) + // or if the focusing timer (focusAssistant.setFocusTimer) is still active. + if(this.deactivateOnRelease && !osk['lgList'] && !this.focusAssistant.focusing) { + this.engine.contextManager.deactivateCurrentTarget(); + } + this.deactivateOnRelease=false; + return false; + }; + + + private _WindowLoad: (e: Event) => void = () => { + // Always return to top of page after a page reload + document.body.scrollTop=0; + if(typeof document.documentElement != 'undefined') { + document.documentElement.scrollTop=0; + } + } + + /** + * Function _WindowUnload + * Scope Private + * Description Remove handlers before detaching KMW window + */ + private _WindowUnload: () => void = () => { + // Future note: should restrict this to anything for the corresponding document if on a + // child iframe, not the whole engine. + this.engine.shutdown(); + } + + private attachHandlers() { + const eventTracker = this.domEventTracker; + const device = this.engine.config.hostDevice; + const docBody = this.window.document.body; + + eventTracker.attachDOMEvent(this.window, 'focus', this.pageFocusHandler, false); + eventTracker.attachDOMEvent(this.window, 'blur', this.pageFocusHandler, false); + + /* + * To prevent propagation of focus & blur events from the input-scroll workaround, + * we attach top-level capturing listeners to the focus & blur events. They prevent propagation + * but NOT default behavior, allowing the scroll to complete while preventing nearly all + * possible event 'noise' that could result from the workaround. + */ + eventTracker.attachDOMEvent(docBody, 'focus', this.suppressFocusCheck, true); + eventTracker.attachDOMEvent(docBody, 'blur', this.suppressFocusCheck, true); + + if(device.touchable) { + eventTracker.attachDOMEvent(docBody, 'touchstart', this.touchStartActivationHandler,false); + eventTracker.attachDOMEvent(docBody, 'touchmove', this.touchMoveActivationHandler, false); + eventTracker.attachDOMEvent(docBody, 'touchend', this.touchEndActivationHandler, false); + } + + eventTracker.attachDOMEvent(window, 'load', this._WindowLoad, false); + eventTracker.attachDOMEvent(window, 'unload', this._WindowUnload,false); + + // TODO: Hotkey module stuff. Is not yet modularized. + // eventTracker.attachDOMEvent(document, 'keyup', this.engine.hotkeyManager._Process, false); + } + + public shutdown() { + const eventTracker = this.domEventTracker; + const device = this.engine.config.hostDevice; + const docBody = this.window.document.body; + + // See `attachHandlers` for the purpose behind all handlers listed here. + + eventTracker.detachDOMEvent(this.window, 'focus', this.pageFocusHandler, false); + eventTracker.detachDOMEvent(this.window, 'blur', this.pageFocusHandler, false); + + eventTracker.detachDOMEvent(docBody, 'focus', this.suppressFocusCheck, true); + eventTracker.detachDOMEvent(docBody, 'blur', this.suppressFocusCheck, true); + + if(device.touchable) { + eventTracker.detachDOMEvent(docBody, 'touchstart', this.touchStartActivationHandler,false); + eventTracker.detachDOMEvent(docBody, 'touchmove', this.touchMoveActivationHandler, false); + eventTracker.detachDOMEvent(docBody, 'touchend', this.touchEndActivationHandler, false); + } + + eventTracker.detachDOMEvent(window, 'load', this._WindowLoad, false); + eventTracker.detachDOMEvent(window, 'unload', this._WindowUnload,false); + + // TODO: Hotkey module stuff. + // eventTracker.detachDOMEvent(document, 'keyup', this.engine.hotkeyManager._Process, false); + } +} \ No newline at end of file diff --git a/web/src/app/browser/src/contextManager.ts b/web/src/app/browser/src/contextManager.ts index ec192938a3..9f6d2d5789 100644 --- a/web/src/app/browser/src/contextManager.ts +++ b/web/src/app/browser/src/contextManager.ts @@ -46,7 +46,6 @@ export default class ContextManager extends ContextManagerBase('KeymanWeb_Keyboard'); readonly focusAssistant = new FocusAssistant(); readonly page: PageContextAttachment; - private mostRecentTarget: OutputTarget; private currentTarget: OutputTarget; @@ -81,6 +80,7 @@ export default class ContextManager extends ContextManagerBase { const device = this.engineConfig.hostDevice; + const noPropagation = (event: Event) => event.stopPropagation() // For any elements being attached, or being enabled after having been disabled... diff --git a/web/src/app/browser/src/keymanEngine.ts b/web/src/app/browser/src/keymanEngine.ts index 10dc149dc7..02c95b348b 100644 --- a/web/src/app/browser/src/keymanEngine.ts +++ b/web/src/app/browser/src/keymanEngine.ts @@ -3,19 +3,21 @@ import { Device as DeviceDetector } from 'keyman/engine/device-detect'; import { getAbsoluteY } from 'keyman/engine/dom-utils'; import { OutputTarget } from 'keyman/engine/element-wrappers'; import { AnchoredOSKView, FloatingOSKView, FloatingOSKViewConfiguration, OSKView } from 'keyman/engine/osk'; -import { DeviceSpec, ProcessorInitOptions } from "@keymanapp/keyboard-processor"; +import { DeviceSpec, ProcessorInitOptions, extendString } from "@keymanapp/keyboard-processor"; import { BrowserConfiguration, BrowserInitOptionDefaults, BrowserInitOptionSpec } from './configuration.js'; import ContextManager from './contextManager.js'; import DefaultBrowserRules from './defaultBrowserRules.js'; import KeyEventKeyboard from './keyEventKeyboard.js'; import { FocusStateAPIObject } from './context/focusAssistant.js'; +import { PageIntegrationHandlers } from './context/pageIntegrationHandlers.js'; import { setupOskListeners } from './oskConfiguration.js'; export class KeymanEngine extends KeymanEngineBase { keyEventRefocus = () => { this.contextManager.restoreLastActiveTarget(); } + private pageIntegration: PageIntegrationHandlers; constructor(worker: Worker, sourceUri: string) { const config = new BrowserConfiguration(sourceUri); // currently set to perform device auto-detect. @@ -91,6 +93,12 @@ export class KeymanEngine extends KeymanEngineBase void = function(e: Event) { - //keymanweb.completeInitialization(); - // Always return to top of page after a page reload - document.body.scrollTop=0; - if(typeof document.documentElement != 'undefined') { - document.documentElement.scrollTop=0; - } - }.bind(this); - - /** - * Function _WindowUnload - * Scope Private - * Description Remove handlers before detaching KMW window - */ - _WindowUnload: () => void = function(this: DOMManager) { - // Allow the UI to release its own resources - this.keyman.uiManager.doUnload(); - - // Allow the OSK to release its own resources - if(this.keyman.osk) { - this.keyman.osk.shutdown(); - if(this.keyman.osk['_Unload']) { - this.keyman.osk['_Unload'](); // I3363 (Build 301) - } - } - - this.lastActiveElement = null; - }.bind(this); - /* ------ Defines independent, per-control keyboard setting behavior for the API. ------ */ /** @@ -442,6 +411,8 @@ namespace com.keyman.dom { this.keyman.setInitialized(1); // Finish keymanweb and initialize the OSK once all necessary resources are available + // OSK type selection is already modularized... but the ordering related to the parts + // afterward, which are not yet modularized, may be important. if(device.touchable) { this.keyman.osk = new com.keyman.osk.AnchoredOSKView(device.coreSpec); } else { @@ -458,12 +429,6 @@ namespace com.keyman.dom { // Initialize the desktop UI this.initializeUI(); - // Exit initialization here if we're using an embedded code path. - if(this.keyman.isEmbedded) { - this.keyman.keyboardManager.setDefaultKeyboard(); - return Promise.resolve(); - } - // Determine the default font for mapped elements this.keyman.appliedFont=this.keyman.baseFont=this.getBaseFont(); @@ -499,70 +464,6 @@ namespace com.keyman.dom { ds.width='100%'; ds.height=(screen.width/2)+'px'; document.body.appendChild(dTrailer); - - // Sets up page-default touch-based handling for activation-state management. - // These always trigger for the page, wherever a touch may occur. Does not - // prevent element-specific or OSK-key-specific handling from triggering. - const _this = this; - this.touchStartActivationHandler=function(e) { - _this.deactivateOnRelease=true; - _this.touchY=e.touches[0].screenY; - - // On Chrome, scrolling up or down causes the URL bar to be shown or hidden - // according to whether or not the document is at the top of the screen. - // But when doing that, each OSK row top and height gets modified by Chrome - // looking very ugly. It would be best to hide the OSK then show it again - // when the user scroll finishes, but Chrome has no way to reliably report - // the touch end event after a move. c.f. http://code.google.com/p/chromium/issues/detail?id=152913 - // The best compromise behaviour is simply to hide the OSK whenever any - // non-input and non-OSK element is touched. - _this.deactivateOnScroll=false; - if(device.OS == 'Android' && navigator.userAgent.indexOf('Chrome') > 0) { - // _this.deactivateOnScroll has the inverse of the 'true' default, - // but that fact actually facilitates the following conditional logic. - if(typeof(osk._Box) == 'undefined') return false; - if(typeof(osk._Box.style) == 'undefined') return false; - - // The following tests are needed to prevent the OSK from being hidden during normal input! - let p=(e.target as HTMLElement).parentElement; - if(typeof(p) != 'undefined' && p != null) { - if(p.className.indexOf('kmw-key-') >= 0) return false; - if(typeof(p.parentElement) != 'undefined' && p.parentElement != null) { - p=p.parentElement; - if(p.className.indexOf('kmw-key-') >= 0) return false; - } - } - - _this.deactivateOnScroll = true; - } - return false; - }; - this.touchMoveActivationHandler = function(e) { - if(_this.deactivateOnScroll) { // Android / Chrone case. - DOMEventHandlers.states.focusing = false; - _this.activeElement = null; - } - - const y = e.touches[0].screenY; - const y0 = _this.touchY; - if(y-y0 > 5 || y0-y < 5) { - _this.deactivateOnRelease = false; - } - return false; - }; - this.touchEndActivationHandler = function() { - // Should not hide OSK if simply closing the language menu (30/4/15) - // or if the focusing timer (setFocusTimer) is still active. - if(_this.deactivateOnRelease && !osk['lgList'] && !DOMEventHandlers.states.focusing) { - _this.activeElement = null; - } - _this.deactivateOnRelease=false; - return false; - }; - - this.keyman.util.attachDOMEvent(document.body, 'touchstart', this.touchStartActivationHandler,false); - this.keyman.util.attachDOMEvent(document.body, 'touchmove', this.touchMoveActivationHandler, false); - this.keyman.util.attachDOMEvent(document.body, 'touchend', this.touchEndActivationHandler, false); } //document.body.appendChild(keymanweb._StyleBlock); @@ -572,13 +473,8 @@ namespace com.keyman.dom { // Set exposed initialization flag to 2 to indicate deferred initialization also complete - /* To prevent propagation of focus & blur events from the input-scroll workaround, - * we attach top-level capturing listeners to the focus & blur events. They prevent propagation - * but NOT default behavior, allowing the scroll to complete while preventing nearly all - * possible event 'noise' that could result from the workaround. - */ - this.keyman.util.attachDOMEvent(document.body, 'focus', DOMManager.suppressFocusCheck, true); - this.keyman.util.attachDOMEvent(document.body, 'blur', DOMManager.suppressFocusCheck, true); + // Other initialization details after this point have already been modularized: + // within app/browser KeymanEngine.init, see `setupOskListeners` call and after. this.keyman.setInitialized(2); return Promise.resolve(); @@ -648,15 +544,5 @@ namespace com.keyman.dom { window.setTimeout(this.initializeUI.bind(this),1000); } } - - static suppressFocusCheck(e: Event) { - if(DOMEventHandlers.states._IgnoreBlurFocus) { - // Prevent triggering other blur-handling events (as possible) - e.stopPropagation(); - e.cancelBubble = true; - } - // But DO perform default event behavior (actually blurring & focusing the affected element) - return true; - } } } \ No newline at end of file diff --git a/web/src/engine/namespaced-main/keymanweb.ts b/web/src/engine/namespaced-main/keymanweb.ts index 88cef3e2b1..57d1901d23 100644 --- a/web/src/engine/namespaced-main/keymanweb.ts +++ b/web/src/engine/namespaced-main/keymanweb.ts @@ -103,26 +103,11 @@ if(!window['keyman']['initialized']) { keymanweb.debugElement=null; var dbg=keymanweb.debug; - keymanweb.delayedInit(); - //TODO: find all references to next three routines and disambiguate!! // Complete page initialization only after the page is fully loaded, including any embedded fonts // This avoids the need to use a timer to test for the fonts - util.attachDOMEvent(window, 'load', keymanweb.domManager._WindowLoad,false); - util.attachDOMEvent(window, 'unload', keymanweb.domManager._WindowUnload,false); // added fourth argument (default value) - // *** I3319 Supplementary Plane modifications - end new code - - util.attachDOMEvent(document, 'keyup', keymanweb.hotkeyManager._Process, false); - - // We need to track this handler, as it causes... interesting... interactions during testing in certain browsers. - util.attachDOMEvent(window, 'focus', keymanweb.pageFocusHandler, false); // I775 - util.attachDOMEvent(window, 'blur', keymanweb.pageFocusHandler, false); // I775 - - // Initialize supplementary plane string extensions - String.kmwEnableSupplementaryPlane(true); - })(); } \ No newline at end of file diff --git a/web/src/engine/namespaced-main/kmwbase.ts b/web/src/engine/namespaced-main/kmwbase.ts index f69e6be286..ac32535495 100644 --- a/web/src/engine/namespaced-main/kmwbase.ts +++ b/web/src/engine/namespaced-main/kmwbase.ts @@ -176,22 +176,6 @@ namespace com.keyman { this['loaded'] = true; } - delayedInit() { - // Track the selected Event-handling object. - this.touchAliasing = this.util.device.touchable ? this.domManager.touchHandlers : this.domManager.nonTouchHandlers; - } - - /** - * Reset context when entering or exiting the active element. - * Will also trigger OSK shift state / layer reset. - **/ - pageFocusHandler = () => { - if(!focusAssistant.maintainingFocus && this.osk?.vkbd) { - this.core.resetContext(null); - } - return false; - } - /** * Triggers a KeymanWeb engine shutdown to facilitate a full system reset. * This function is designed for use with KMW unit-testing, which reloads KMW @@ -199,14 +183,6 @@ namespace com.keyman { */ ['shutdown']() { // Disable page focus/blur events, which can sometimes trigger and cause parallel KMW instances in testing. - this.util.detachDOMEvent(window, 'focus', this.pageFocusHandler, false); - this.util.detachDOMEvent(window, 'blur', this.pageFocusHandler, false); - - this.domManager.shutdown(); - this.osk.shutdown(); - this.util.shutdown(); - this.keyboardManager.shutdown(); - this.core.languageProcessor.shutdown(); if(this.ui && this.ui.shutdown) { this.ui.shutdown();