Merge pull request #8806 from keymanapp/chore/web/page-integration-handlers

chore(web): modularization of Web's page-integration handlers 🧩
This commit is contained in:
Joshua Horton 2023-05-23 11:49:42 +07:00 committed by GitHub
commit 112cea00ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 238 additions and 159 deletions

View file

@ -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);
}
}

View file

@ -46,7 +46,6 @@ export default class ContextManager extends ContextManagerBase<BrowserConfigurat
private cookieManager = new CookieSerializer<KeyboardCookie>('KeymanWeb_Keyboard');
readonly focusAssistant = new FocusAssistant();
readonly page: PageContextAttachment;
private mostRecentTarget: OutputTarget<any>;
private currentTarget: OutputTarget<any>;
@ -81,6 +80,7 @@ export default class ContextManager extends ContextManagerBase<BrowserConfigurat
this.engineConfig.deferForInitialization.then(() => {
const device = this.engineConfig.hostDevice;
const noPropagation = (event: Event) => event.stopPropagation()
// For any elements being attached, or being enabled after having been disabled...

View file

@ -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<ContextManager, KeyEventKeyboard> {
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<ContextManager, KeyEventKeybo
setupOskListeners(this, this.osk, this.contextManager);
// Automatically performs related handler setup & maintains references
// needed for related cleanup / shutdown.
this.pageIntegration = new PageIntegrationHandlers(window, this);
// Initialize supplementary plane string extensions
String.kmwEnableSupplementaryPlane(true);
this.config.finalizeInit();
}
@ -149,4 +157,19 @@ export class KeymanEngine extends KeymanEngineBase<ContextManager, KeyEventKeybo
this.contextManager.setKeyboardForTarget(Pelem._kmwAttachment.interface, Pkbd, Plc);
}
/**
* Detaches all KMW event handlers attached by this instance of the engine and releases
* other related resources as appropriate.
*
* The primary use of this method is to facilitate a clean transition between engine
* instances during integration testing. The goal is to prevent interactions intended
* for the 'current' instance from being accidentally intercepted by a discarded one.
*/
shutdown() {
this.pageIntegration.shutdown();
this.contextManager.shutdown();
this.osk?.shutdown();
this.core.languageProcessor.shutdown();
}
}

View file

@ -159,37 +159,6 @@ namespace com.keyman.dom {
this._BeepObjects = [];
}
/* ------------- Page and document-level management events ------------------ */
_WindowLoad: (e: Event) => 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;
}
}
}

View file

@ -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);
})();
}

View file

@ -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();