From f2048a8e91eb8bb9aac94a4a52d38e648e1196dc Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 13 Nov 2023 11:48:55 +0700 Subject: [PATCH 1/3] chore(web): splits banner.ts into separate files per banner type --- web/src/engine/osk/src/banner/banner.ts | 469 +----------------- web/src/engine/osk/src/banner/bannerView.ts | 10 +- web/src/engine/osk/src/banner/imageBanner.ts | 47 ++ .../engine/osk/src/banner/suggestionBanner.ts | 423 ++++++++++++++++ 4 files changed, 477 insertions(+), 472 deletions(-) create mode 100644 web/src/engine/osk/src/banner/imageBanner.ts create mode 100644 web/src/engine/osk/src/banner/suggestionBanner.ts diff --git a/web/src/engine/osk/src/banner/banner.ts b/web/src/engine/osk/src/banner/banner.ts index 999f7d63486..a1aa5fdbdd1 100644 --- a/web/src/engine/osk/src/banner/banner.ts +++ b/web/src/engine/osk/src/banner/banner.ts @@ -1,13 +1,4 @@ -import EventEmitter from 'eventemitter3'; - -import { DeviceSpec } from '@keymanapp/web-utils'; import { Keyboard, KeyboardProperties } from '@keymanapp/keyboard-processor'; -import { type PredictionContext } from '@keymanapp/input-processor'; -import InputEventEngine, { InputEventEngineConfig } from '../input/event-interpreter/inputEventEngine.js'; -import MouseEventEngine from '../input/event-interpreter/mouseEventEngine.js'; -import TouchEventEngine from '../input/event-interpreter/touchEventEngine.js'; -import UITouchHandlerBase from '../input/event-interpreter/uiTouchHandlerBase.js'; - import { createUnselectableElement } from 'keyman/engine/dom-utils'; // Base class for a banner above the keyboard in the OSK @@ -109,462 +100,4 @@ export class BlankBanner extends Banner { constructor() { super(0); } -} - -/** - * Function ImageBanner - * @param {string} imagePath Path of image to display in the banner - * @param {number} height If provided, the height of the banner in pixels - * Description Display an image in the banner - */ -export class ImageBanner extends Banner { - private img: HTMLElement; - - constructor(imagePath: string, height?: number) { - if (imagePath.length > 0) { - super(); - if (height) { - this.height = height; - } - } else { - super(0); - } - - if(imagePath.indexOf('base64') >=0) { - console.log("Loading img from base64 data"); - } else { - console.log("Loading img with src '" + imagePath + "'"); - } - this.img = document.createElement('img'); - this.img.setAttribute('src', imagePath); - let ds = this.img.style; - ds.width = '100%'; - ds.height = '100%'; - this.getDiv().appendChild(this.img); - console.log("Image loaded."); - } - - /** - * Function setImagePath - * Scope Public - * @param {string} imagePath Path of image to display in the banner - * Description Update the image in the banner - */ - public setImagePath(imagePath: string) { - if (this.img) { - this.img.setAttribute('src', imagePath); - } - } -} - -export class BannerSuggestion { - div: HTMLDivElement; - private display: HTMLSpanElement; - private fontFamily?: string; - private rtl: boolean = false; - - private _suggestion: Suggestion; - - private index: number; - - static readonly BASE_ID = 'kmw-suggestion-'; - - constructor(index: number, isRTL: boolean) { - this.index = index; - this.rtl = isRTL; - - this.constructRoot(); - - // Provides an empty, base SPAN for text display. We'll swap these out regularly; - // `Suggestion`s will have varying length and may need different styling. - let display = this.display = createUnselectableElement('span'); - this.div.appendChild(display); - } - - private constructRoot() { - // Add OSK suggestion labels - let div = this.div = createUnselectableElement('div'), ds=div.style; - div.className = "kmw-suggest-option"; - div.id = BannerSuggestion.BASE_ID + this.index; - - // Ensures that a reasonable width % is set. - let usableWidth = 100 - SuggestionBanner.MARGIN * (SuggestionBanner.SUGGESTION_LIMIT - 1); - let widthpc = usableWidth / SuggestionBanner.SUGGESTION_LIMIT; - - ds.width = widthpc + '%'; - - this.div['suggestion'] = this; - } - - public matchKeyboardProperties(keyboardProperties: KeyboardProperties) { - const div = this.div; - - if(keyboardProperties) { - if (keyboardProperties['KLC']) { - div.lang = keyboardProperties['KLC']; - } - - // Establish base font settings - let font = keyboardProperties['KFont']; - if(font && font.family && font.family != '') { - div.style.fontFamily = this.fontFamily = font.family; - } - } - } - - get suggestion(): Suggestion { - return this._suggestion; - } - - /** - * Function update - * @param {string} id Element ID for the suggestion span - * @param {Suggestion} suggestion Suggestion from the lexical model - * Description Update the ID and text of the BannerSuggestionSpec - */ - public update(suggestion: Suggestion) { - this._suggestion = suggestion; - this.updateText(); - } - - private updateText() { - let display = this.generateSuggestionText(this.rtl); - this.div.replaceChild(display, this.display); - this.display = display; - } - - public isEmpty(): boolean { - return !this._suggestion; - } - - /** - * Function generateSuggestionText - * @return {HTMLSpanElement} Span element of the suggestion - * Description Produces a HTMLSpanElement with the key's actual text. - */ - // - public generateSuggestionText(rtl: boolean): HTMLSpanElement { - let suggestion = this._suggestion; - var suggestionText: string; - - var s=createUnselectableElement('span'); - s.className = 'kmw-suggestion-text'; - - if(suggestion == null) { - return s; - } - - if(suggestion.displayAs == null || suggestion.displayAs == '') { - suggestionText = '\xa0'; // default: nbsp. - } else { - // Default the LTR ordering to match that of the active keyboard. - let orderCode = rtl ? 0x202e /* RTL */ : 0x202d /* LTR */; - suggestionText = String.fromCharCode(orderCode) + suggestion.displayAs; - } - - // TODO: Dynamic suggestion text resizing. (Refer to OSKKey.getTextWidth in visualKeyboard.ts.) - - // Finalize the suggestion text - s.innerHTML = suggestionText; - return s; - } -} - -/** - * Function SuggestionBanner - * Scope Public - * @param {number} height - If provided, the height of the banner in pixels - * Description Display lexical model suggestions in the banner - */ -export class SuggestionBanner extends Banner { - public static readonly SUGGESTION_LIMIT: number = 3; - public static readonly MARGIN = 1; - - public readonly events: EventEmitter; - - private currentSuggestions: Suggestion[] = []; - - private options : BannerSuggestion[] = []; - private hostDevice: DeviceSpec; - - private manager: SuggestionInputManager; - - private _predictionContext: PredictionContext; - - static readonly TOUCHED_CLASS: string = 'kmw-suggest-touched'; - static readonly BANNER_CLASS: string = 'kmw-suggest-banner'; - - constructor(hostDevice: DeviceSpec, height?: number) { - super(height || Banner.DEFAULT_HEIGHT); - this.hostDevice = hostDevice; - - this.getDiv().className = this.getDiv().className + ' ' + SuggestionBanner.BANNER_CLASS; - - this.buildInternals(false); - - this.manager = new SuggestionInputManager(this.getDiv()); - this.events = this.manager.events; - - this.setupInputHandling(); - } - - buildInternals(rtl: boolean) { - if(this.options.length > 0) { - this.options.splice(0, this.options.length); // Clear the array. - } - for (var i=0; i { - const elem = suggestion.div; - let classes = elem.className; - let cs = ' ' + SuggestionBanner.TOUCHED_CLASS; - - if(on && classes.indexOf(cs) < 0) { - elem.className=classes+cs; - } else { - elem.className=classes.replace(cs,''); - } - }); - - this.manager.events.on('apply', (option) => { - if(this.predictionContext) { - this.predictionContext.accept(option.suggestion); - } - }); - } - - public configureForKeyboard(keyboard: Keyboard, keyboardProperties: KeyboardProperties) { - const rtl = keyboard.isRTL; - - // Removes all previous children. (.replaceChildren requires Chrome for Android 86.) - // Instantly replaces all children with an empty text node, bypassing the need to actually - // parse incoming HTML. - // - // Just in case, alternative approaches: https://stackoverflow.com/a/3955238 - this.getDiv().textContent = ''; - - // Builds new children to match needed RTL properties. - this.buildInternals(rtl); - - this.options.forEach((option) => option.matchKeyboardProperties(keyboardProperties)); - this.onSuggestionUpdate(this.currentSuggestions); // restore suggestions - } - - private get mouseEventConfig() { - const config: InputEventEngineConfig = { - targetRoot: this.getDiv(), - // document.body is the event root b/c we need to track the mouse if it leaves - // the VisualKeyboard's hierarchy. - eventRoot: document.body, - inputStartHandler: this.manager.touchStart.bind(this.manager), - inputMoveHandler: this.manager.touchMove.bind(this.manager), - inputEndHandler: this.manager.touchEnd.bind(this.manager), - coordConstrainedWithinInteractiveBounds: function() { return true; } - }; - - return new MouseEventEngine(config); - } - - private get touchEventConfig() { - const config: InputEventEngineConfig = { - targetRoot: this.getDiv(), - // document.body is the event root b/c we need to track the mouse if it leaves - // the VisualKeyboard's hierarchy. - eventRoot: this.getDiv(), - inputStartHandler: this.manager.touchStart.bind(this.manager), - inputMoveHandler: this.manager.touchMove.bind(this.manager), - inputEndHandler: this.manager.touchEnd.bind(this.manager), - coordConstrainedWithinInteractiveBounds: function() { return true; } - }; - - return new TouchEventEngine(config); - } - - public get predictionContext(): PredictionContext { - return this._predictionContext; - } - - public set predictionContext(context: PredictionContext) { - if(this._predictionContext) { - // disconnect the old one! - this._predictionContext.off('update', this.onSuggestionUpdate); - } - - // connect the new one! - this._predictionContext = context; - if(context) { - context.on('update', this.onSuggestionUpdate); - this.onSuggestionUpdate(context.currentSuggestions); - } - } - - public onSuggestionUpdate = (suggestions: Suggestion[]): void => { - this.currentSuggestions = suggestions; - - this.options.forEach((option: BannerSuggestion, i: number) => { - if(i < suggestions.length) { - option.update(suggestions[i]); - } else { - option.update(null); - } - }); - } -} - -interface SuggestionInputEventMap { - highlight: (bannerSuggestion: BannerSuggestion, state: boolean) => void, - apply: (bannerSuggestion: BannerSuggestion) => void; - hold: (bannerSuggestion: BannerSuggestion) => void; -} - -class SuggestionInputManager extends UITouchHandlerBase { - public readonly events = new EventEmitter(); - - private eventDisablePromise: Promise; - - platformHold: (suggestion: BannerSuggestion, isCustom: boolean) => void; - - //#region Touch handling implementation - findTargetFrom(e: HTMLElement): HTMLDivElement { - try { - if(e) { - if(e.classList.contains('kmw-suggest-option')) { - return e as HTMLDivElement; - } - if(e.parentElement && e.parentElement.classList.contains('kmw-suggest-option')) { - return e.parentElement as HTMLDivElement; - } - // if(e.firstChild && util.hasClass( e.firstChild,'kmw-suggest-option')) { - // return e.firstChild as HTMLDivElement; - // } - } - } catch(ex) {} - return null; - } - - protected highlight(t: HTMLDivElement, on: boolean): void { - let suggestion = t['suggestion'] as BannerSuggestion; - - // Never highlight an empty suggestion button. - if(suggestion.isEmpty()) { - on = false; - } - - this.events.emit('highlight', suggestion, on); - } - - protected select(t: HTMLDivElement): void { - this.events.emit('apply', t['suggestion'] as BannerSuggestion); - } - - //#region Long-press support - protected hold(t: HTMLDivElement): void { - // let suggestionObj = t['suggestion'] as BannerSuggestion; - // - // // Is this the suggestion? It's never in this.currentSuggestions, so check against that. - // let isCustom = this.currentSuggestions.indexOf(suggestionObj.suggestion) == -1; - - this.events.emit('hold', t['suggestion'] as BannerSuggestion); - } - protected clearHolds(): void { - // Temp, pending implementation of suggestion longpress submenus - // - nothing to clear without them - - - // only really used in native-KMW - } - - protected hasModalPopup(): boolean { - return this.eventsBlocked; - } - - protected dealiasSubTarget(target: HTMLDivElement): HTMLDivElement { - return target; - } - - protected hasSubmenu(t: HTMLDivElement): boolean { - // Temp, pending implementation of suggestion longpress submenus - - // Only really used by native-KMW - see kmwnative's highlightSubKeys func. - return false; - } - - protected isSubmenuActive(): boolean { - // Temp, pending implementation of suggestion longpress submenus - - // Utilized only by native-KMW - it parallels hasModalPopup() in purpose. - return false; - } - - protected displaySubmenuFor(target: HTMLDivElement) { - // Utilized only by native-KMW to show submenus. - throw new Error("Method not implemented."); - } - //#endregion - //#endregion - - public get eventsBlocked(): boolean { - return !!this.eventDisablePromise; - } - - /** - * Intended for use by the mobile apps, which sometimes 'takes over' touch handling. - * For such cases, input should be blocked within KMW when the apps are managing an - * ongoing touch-hold for any other interaction. - * - * Formerly: - ``` - let keyman = com.keyman.singleton; - return keyman['osk'].vkbd.subkeyGesture && keyman.isEmbedded; - ``` - */ - public temporarilyBlockEvents(promise: Promise) { // TODO: ensure connection for embedded mode! - this.eventDisablePromise = promise; // Will require routing; this class is not exported! - promise.finally(() => { - this.eventDisablePromise = null; - }) - } - - constructor(div: HTMLElement) { - // TODO: Determine appropriate CSS styling names, etc. - super(div, Banner.BANNER_CLASS, SuggestionBanner.TOUCHED_CLASS); - } -} +} \ No newline at end of file diff --git a/web/src/engine/osk/src/banner/bannerView.ts b/web/src/engine/osk/src/banner/bannerView.ts index 556ec61762d..eb8c6166846 100644 --- a/web/src/engine/osk/src/banner/bannerView.ts +++ b/web/src/engine/osk/src/banner/bannerView.ts @@ -1,13 +1,15 @@ import EventEmitter from 'eventemitter3'; -import { Banner, BlankBanner, ImageBanner, SuggestionBanner } from './banner.js'; -import OSKViewComponent from '../components/oskViewComponent.interface.js'; -import { ParsedLengthStyle } from '../lengthStyle.js'; - import { DeviceSpec } from '@keymanapp/web-utils'; import type { PredictionContext, StateChangeEnum } from '@keymanapp/input-processor'; import { createUnselectableElement } from 'keyman/engine/dom-utils'; +import { Banner, BlankBanner } from './banner.js'; +import { ImageBanner } from './imageBanner.js'; +import OSKViewComponent from '../components/oskViewComponent.interface.js'; +import { ParsedLengthStyle } from '../lengthStyle.js'; +import { SuggestionBanner } from './suggestionBanner.js'; + /** * This object is used to specify options by both `BannerManager.getOptions` * and `BannerManager.setOptions`. Refer to the latter for specification of diff --git a/web/src/engine/osk/src/banner/imageBanner.ts b/web/src/engine/osk/src/banner/imageBanner.ts new file mode 100644 index 00000000000..55b71a3d6f7 --- /dev/null +++ b/web/src/engine/osk/src/banner/imageBanner.ts @@ -0,0 +1,47 @@ +import { Banner } from "./banner.js"; + +/** + * Function ImageBanner + * @param {string} imagePath Path of image to display in the banner + * @param {number} height If provided, the height of the banner in pixels + * Description Display an image in the banner + */ +export class ImageBanner extends Banner { + private img: HTMLElement; + + constructor(imagePath: string, height?: number) { + if (imagePath.length > 0) { + super(); + if (height) { + this.height = height; + } + } else { + super(0); + } + + if(imagePath.indexOf('base64') >=0) { + console.log("Loading img from base64 data"); + } else { + console.log("Loading img with src '" + imagePath + "'"); + } + this.img = document.createElement('img'); + this.img.setAttribute('src', imagePath); + let ds = this.img.style; + ds.width = '100%'; + ds.height = '100%'; + this.getDiv().appendChild(this.img); + console.log("Image loaded."); + } + + /** + * Function setImagePath + * Scope Public + * @param {string} imagePath Path of image to display in the banner + * Description Update the image in the banner + */ + public setImagePath(imagePath: string) { + if (this.img) { + this.img.setAttribute('src', imagePath); + } + } +} \ No newline at end of file diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts new file mode 100644 index 00000000000..0cd1fd4287c --- /dev/null +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -0,0 +1,423 @@ + +import { type PredictionContext } from '@keymanapp/input-processor'; +import { createUnselectableElement } from 'keyman/engine/dom-utils'; + +import InputEventEngine, { InputEventEngineConfig } from '../input/event-interpreter/inputEventEngine.js'; +import MouseEventEngine from '../input/event-interpreter/mouseEventEngine.js'; +import TouchEventEngine from '../input/event-interpreter/touchEventEngine.js'; +import UITouchHandlerBase from '../input/event-interpreter/uiTouchHandlerBase.js'; +import { DeviceSpec, Keyboard, KeyboardProperties } from '@keymanapp/keyboard-processor'; +import { Banner } from './banner.js'; +import EventEmitter from 'eventemitter3'; + +export class BannerSuggestion { + div: HTMLDivElement; + private display: HTMLSpanElement; + private fontFamily?: string; + private rtl: boolean = false; + + private _suggestion: Suggestion; + + private index: number; + + static readonly BASE_ID = 'kmw-suggestion-'; + + constructor(index: number, isRTL: boolean) { + this.index = index; + this.rtl = isRTL; + + this.constructRoot(); + + // Provides an empty, base SPAN for text display. We'll swap these out regularly; + // `Suggestion`s will have varying length and may need different styling. + let display = this.display = createUnselectableElement('span'); + this.div.appendChild(display); + } + + private constructRoot() { + // Add OSK suggestion labels + let div = this.div = createUnselectableElement('div'), ds=div.style; + div.className = "kmw-suggest-option"; + div.id = BannerSuggestion.BASE_ID + this.index; + + // Ensures that a reasonable width % is set. + let usableWidth = 100 - SuggestionBanner.MARGIN * (SuggestionBanner.SUGGESTION_LIMIT - 1); + let widthpc = usableWidth / SuggestionBanner.SUGGESTION_LIMIT; + + ds.width = widthpc + '%'; + + this.div['suggestion'] = this; + } + + public matchKeyboardProperties(keyboardProperties: KeyboardProperties) { + const div = this.div; + + if(keyboardProperties) { + if (keyboardProperties['KLC']) { + div.lang = keyboardProperties['KLC']; + } + + // Establish base font settings + let font = keyboardProperties['KFont']; + if(font && font.family && font.family != '') { + div.style.fontFamily = this.fontFamily = font.family; + } + } + } + + get suggestion(): Suggestion { + return this._suggestion; + } + + /** + * Function update + * @param {string} id Element ID for the suggestion span + * @param {Suggestion} suggestion Suggestion from the lexical model + * Description Update the ID and text of the BannerSuggestionSpec + */ + public update(suggestion: Suggestion) { + this._suggestion = suggestion; + this.updateText(); + } + + private updateText() { + let display = this.generateSuggestionText(this.rtl); + this.div.replaceChild(display, this.display); + this.display = display; + } + + public isEmpty(): boolean { + return !this._suggestion; + } + + /** + * Function generateSuggestionText + * @return {HTMLSpanElement} Span element of the suggestion + * Description Produces a HTMLSpanElement with the key's actual text. + */ + // + public generateSuggestionText(rtl: boolean): HTMLSpanElement { + let suggestion = this._suggestion; + var suggestionText: string; + + var s=createUnselectableElement('span'); + s.className = 'kmw-suggestion-text'; + + if(suggestion == null) { + return s; + } + + if(suggestion.displayAs == null || suggestion.displayAs == '') { + suggestionText = '\xa0'; // default: nbsp. + } else { + // Default the LTR ordering to match that of the active keyboard. + let orderCode = rtl ? 0x202e /* RTL */ : 0x202d /* LTR */; + suggestionText = String.fromCharCode(orderCode) + suggestion.displayAs; + } + + // TODO: Dynamic suggestion text resizing. (Refer to OSKKey.getTextWidth in visualKeyboard.ts.) + + // Finalize the suggestion text + s.innerHTML = suggestionText; + return s; + } +} + +/** + * Function SuggestionBanner + * Scope Public + * @param {number} height - If provided, the height of the banner in pixels + * Description Display lexical model suggestions in the banner + */ +export class SuggestionBanner extends Banner { + public static readonly SUGGESTION_LIMIT: number = 3; + public static readonly MARGIN = 1; + + public readonly events: EventEmitter; + + private currentSuggestions: Suggestion[] = []; + + private options : BannerSuggestion[] = []; + private hostDevice: DeviceSpec; + + private manager: SuggestionInputManager; + + private _predictionContext: PredictionContext; + + static readonly TOUCHED_CLASS: string = 'kmw-suggest-touched'; + static readonly BANNER_CLASS: string = 'kmw-suggest-banner'; + + constructor(hostDevice: DeviceSpec, height?: number) { + super(height || Banner.DEFAULT_HEIGHT); + this.hostDevice = hostDevice; + + this.getDiv().className = this.getDiv().className + ' ' + SuggestionBanner.BANNER_CLASS; + + this.buildInternals(false); + + this.manager = new SuggestionInputManager(this.getDiv()); + this.events = this.manager.events; + + this.setupInputHandling(); + } + + buildInternals(rtl: boolean) { + if(this.options.length > 0) { + this.options.splice(0, this.options.length); // Clear the array. + } + for (var i=0; i { + const elem = suggestion.div; + let classes = elem.className; + let cs = ' ' + SuggestionBanner.TOUCHED_CLASS; + + if(on && classes.indexOf(cs) < 0) { + elem.className=classes+cs; + } else { + elem.className=classes.replace(cs,''); + } + }); + + this.manager.events.on('apply', (option) => { + if(this.predictionContext) { + this.predictionContext.accept(option.suggestion); + } + }); + } + + public configureForKeyboard(keyboard: Keyboard, keyboardProperties: KeyboardProperties) { + const rtl = keyboard.isRTL; + + // Removes all previous children. (.replaceChildren requires Chrome for Android 86.) + // Instantly replaces all children with an empty text node, bypassing the need to actually + // parse incoming HTML. + // + // Just in case, alternative approaches: https://stackoverflow.com/a/3955238 + this.getDiv().textContent = ''; + + // Builds new children to match needed RTL properties. + this.buildInternals(rtl); + + this.options.forEach((option) => option.matchKeyboardProperties(keyboardProperties)); + this.onSuggestionUpdate(this.currentSuggestions); // restore suggestions + } + + private get mouseEventConfig() { + const config: InputEventEngineConfig = { + targetRoot: this.getDiv(), + // document.body is the event root b/c we need to track the mouse if it leaves + // the VisualKeyboard's hierarchy. + eventRoot: document.body, + inputStartHandler: this.manager.touchStart.bind(this.manager), + inputMoveHandler: this.manager.touchMove.bind(this.manager), + inputEndHandler: this.manager.touchEnd.bind(this.manager), + coordConstrainedWithinInteractiveBounds: function() { return true; } + }; + + return new MouseEventEngine(config); + } + + private get touchEventConfig() { + const config: InputEventEngineConfig = { + targetRoot: this.getDiv(), + // document.body is the event root b/c we need to track the mouse if it leaves + // the VisualKeyboard's hierarchy. + eventRoot: this.getDiv(), + inputStartHandler: this.manager.touchStart.bind(this.manager), + inputMoveHandler: this.manager.touchMove.bind(this.manager), + inputEndHandler: this.manager.touchEnd.bind(this.manager), + coordConstrainedWithinInteractiveBounds: function() { return true; } + }; + + return new TouchEventEngine(config); + } + + public get predictionContext(): PredictionContext { + return this._predictionContext; + } + + public set predictionContext(context: PredictionContext) { + if(this._predictionContext) { + // disconnect the old one! + this._predictionContext.off('update', this.onSuggestionUpdate); + } + + // connect the new one! + this._predictionContext = context; + if(context) { + context.on('update', this.onSuggestionUpdate); + this.onSuggestionUpdate(context.currentSuggestions); + } + } + + public onSuggestionUpdate = (suggestions: Suggestion[]): void => { + this.currentSuggestions = suggestions; + + this.options.forEach((option: BannerSuggestion, i: number) => { + if(i < suggestions.length) { + option.update(suggestions[i]); + } else { + option.update(null); + } + }); + } +} + +interface SuggestionInputEventMap { + highlight: (bannerSuggestion: BannerSuggestion, state: boolean) => void, + apply: (bannerSuggestion: BannerSuggestion) => void; + hold: (bannerSuggestion: BannerSuggestion) => void; +} + +class SuggestionInputManager extends UITouchHandlerBase { + public readonly events = new EventEmitter(); + + private eventDisablePromise: Promise; + + platformHold: (suggestion: BannerSuggestion, isCustom: boolean) => void; + + //#region Touch handling implementation + findTargetFrom(e: HTMLElement): HTMLDivElement { + try { + if(e) { + if(e.classList.contains('kmw-suggest-option')) { + return e as HTMLDivElement; + } + if(e.parentElement && e.parentElement.classList.contains('kmw-suggest-option')) { + return e.parentElement as HTMLDivElement; + } + // if(e.firstChild && util.hasClass( e.firstChild,'kmw-suggest-option')) { + // return e.firstChild as HTMLDivElement; + // } + } + } catch(ex) {} + return null; + } + + protected highlight(t: HTMLDivElement, on: boolean): void { + let suggestion = t['suggestion'] as BannerSuggestion; + + // Never highlight an empty suggestion button. + if(suggestion.isEmpty()) { + on = false; + } + + this.events.emit('highlight', suggestion, on); + } + + protected select(t: HTMLDivElement): void { + this.events.emit('apply', t['suggestion'] as BannerSuggestion); + } + + //#region Long-press support + protected hold(t: HTMLDivElement): void { + // let suggestionObj = t['suggestion'] as BannerSuggestion; + // + // // Is this the suggestion? It's never in this.currentSuggestions, so check against that. + // let isCustom = this.currentSuggestions.indexOf(suggestionObj.suggestion) == -1; + + this.events.emit('hold', t['suggestion'] as BannerSuggestion); + } + protected clearHolds(): void { + // Temp, pending implementation of suggestion longpress submenus + // - nothing to clear without them - + + // only really used in native-KMW + } + + protected hasModalPopup(): boolean { + return this.eventsBlocked; + } + + protected dealiasSubTarget(target: HTMLDivElement): HTMLDivElement { + return target; + } + + protected hasSubmenu(t: HTMLDivElement): boolean { + // Temp, pending implementation of suggestion longpress submenus + + // Only really used by native-KMW - see kmwnative's highlightSubKeys func. + return false; + } + + protected isSubmenuActive(): boolean { + // Temp, pending implementation of suggestion longpress submenus + + // Utilized only by native-KMW - it parallels hasModalPopup() in purpose. + return false; + } + + protected displaySubmenuFor(target: HTMLDivElement) { + // Utilized only by native-KMW to show submenus. + throw new Error("Method not implemented."); + } + //#endregion + //#endregion + + public get eventsBlocked(): boolean { + return !!this.eventDisablePromise; + } + + /** + * Intended for use by the mobile apps, which sometimes 'takes over' touch handling. + * For such cases, input should be blocked within KMW when the apps are managing an + * ongoing touch-hold for any other interaction. + * + * Formerly: + ``` + let keyman = com.keyman.singleton; + return keyman['osk'].vkbd.subkeyGesture && keyman.isEmbedded; + ``` + */ + public temporarilyBlockEvents(promise: Promise) { // TODO: ensure connection for embedded mode! + this.eventDisablePromise = promise; // Will require routing; this class is not exported! + promise.finally(() => { + this.eventDisablePromise = null; + }) + } + + constructor(div: HTMLElement) { + // TODO: Determine appropriate CSS styling names, etc. + super(div, Banner.BANNER_CLASS, SuggestionBanner.TOUCHED_CLASS); + } +} From 355565ef07b25174a1c7fafa2d336749e7f746a1 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 14 Nov 2023 08:13:34 +0700 Subject: [PATCH 2/3] chore(web): BlankBanner gets its own source file, too --- web/src/engine/osk/src/banner/banner.ts | 11 ----------- web/src/engine/osk/src/banner/bannerView.ts | 3 ++- web/src/engine/osk/src/banner/blankBanner.ts | 12 ++++++++++++ 3 files changed, 14 insertions(+), 12 deletions(-) create mode 100644 web/src/engine/osk/src/banner/blankBanner.ts diff --git a/web/src/engine/osk/src/banner/banner.ts b/web/src/engine/osk/src/banner/banner.ts index a1aa5fdbdd1..06468304a3a 100644 --- a/web/src/engine/osk/src/banner/banner.ts +++ b/web/src/engine/osk/src/banner/banner.ts @@ -89,15 +89,4 @@ export abstract class Banner { * @param keyboardProperties */ public configureForKeyboard(keyboard: Keyboard, keyboardProperties: KeyboardProperties) { } -} - -/** - * Function BlankBanner - * Description A banner of height 0 that should not be shown - */ -export class BlankBanner extends Banner { - - constructor() { - super(0); - } } \ No newline at end of file diff --git a/web/src/engine/osk/src/banner/bannerView.ts b/web/src/engine/osk/src/banner/bannerView.ts index eb8c6166846..20059411bbb 100644 --- a/web/src/engine/osk/src/banner/bannerView.ts +++ b/web/src/engine/osk/src/banner/bannerView.ts @@ -4,11 +4,12 @@ import { DeviceSpec } from '@keymanapp/web-utils'; import type { PredictionContext, StateChangeEnum } from '@keymanapp/input-processor'; import { createUnselectableElement } from 'keyman/engine/dom-utils'; -import { Banner, BlankBanner } from './banner.js'; +import { Banner } from './banner.js'; import { ImageBanner } from './imageBanner.js'; import OSKViewComponent from '../components/oskViewComponent.interface.js'; import { ParsedLengthStyle } from '../lengthStyle.js'; import { SuggestionBanner } from './suggestionBanner.js'; +import { BlankBanner } from './blankBanner.js'; /** * This object is used to specify options by both `BannerManager.getOptions` diff --git a/web/src/engine/osk/src/banner/blankBanner.ts b/web/src/engine/osk/src/banner/blankBanner.ts new file mode 100644 index 00000000000..7a37d27b5a5 --- /dev/null +++ b/web/src/engine/osk/src/banner/blankBanner.ts @@ -0,0 +1,12 @@ +import { Banner } from "./banner.js"; + +/** + * Function BlankBanner + * Description A banner of height 0 that should not be shown + */ +export class BlankBanner extends Banner { + + constructor() { + super(0); + } +} \ No newline at end of file From 0d796583d50cb573a4248df2d76879b3284eb194 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 14 Nov 2023 08:16:58 +0700 Subject: [PATCH 3/3] chore(web): also splits BannerView and BannerController into separate files --- .../engine/osk/src/banner/bannerController.ts | 157 ++++++++++++++++++ web/src/engine/osk/src/banner/bannerView.ts | 156 +---------------- web/src/engine/osk/src/index.ts | 2 +- web/src/engine/osk/src/views/oskView.ts | 3 +- 4 files changed, 161 insertions(+), 157 deletions(-) create mode 100644 web/src/engine/osk/src/banner/bannerController.ts diff --git a/web/src/engine/osk/src/banner/bannerController.ts b/web/src/engine/osk/src/banner/bannerController.ts new file mode 100644 index 00000000000..a62f492c3ec --- /dev/null +++ b/web/src/engine/osk/src/banner/bannerController.ts @@ -0,0 +1,157 @@ +import { DeviceSpec } from '@keymanapp/web-utils'; +import type { PredictionContext, StateChangeEnum } from '@keymanapp/input-processor'; +import { ImageBanner } from './imageBanner.js'; +import { SuggestionBanner } from './suggestionBanner.js'; +import { BannerView, BannerOptions, BannerType } from './bannerView.js'; +import { Banner } from './banner.js'; +import { BlankBanner } from './blankBanner.js'; + +export class BannerController { + private _activeType: BannerType; + private _options: BannerOptions = {}; + private container: BannerView; + private alwaysShow: boolean; + private imagePath?: string = ""; + + private predictionContext?: PredictionContext; + + private readonly hostDevice: DeviceSpec; + + public static readonly DEFAULT_OPTIONS: BannerOptions = { + alwaysShow: false, + imagePath: "" + } + + constructor(bannerView: BannerView, hostDevice: DeviceSpec, predictionContext?: PredictionContext) { + // Step 1 - establish the container element. Must come before this.setOptions. + this.hostDevice = hostDevice; + this.container = bannerView; + this.predictionContext = predictionContext; + + // Initialize with the default options - any 'manually set' options come post-construction. + // This will also automatically set the default banner in place. + this.setOptions(BannerController.DEFAULT_OPTIONS); + } + + /** + * This function corresponds to `keyman.osk.banner.getOptions`. + * + * Gets the current control settings in use by `BannerManager`. + */ + public getOptions(): BannerOptions { + let retObj = {}; + + for(let key in this._options) { + retObj[key] = this._options[key]; + } + + return retObj; + } + + /** + * This function corresponds to `keyman.osk.banner.setOptions`. + * + * Sets options used to tweak the automatic `Banner` + * control logic used by `BannerManager`. + * @param optionSpec An object specifying one or more of the following options: + * * `persistentBanner` (boolean) When `true`, ensures that a `Banner` + * is always displayed, even when no predictive model exists + * for the active language. + * + * Default: `false` + * * `imagePath` (URL string) Specifies the file path to use for an + * `ImageBanner` when `persistentBanner` is `true` and no predictive model exists. + * + * Default: `''`. + * * `enablePredictions` (boolean) Turns KMW predictions + * on (when `true`) and off (when `false`). + * + * Default: `true`. + */ + public setOptions(optionSpec: BannerOptions) { + for(let key in optionSpec) { + switch(key) { + // Each defined option may require specialized handling. + case 'alwaysShow': + // Determines the banner type to activate. + this.alwaysShow = optionSpec[key]; + break; + case 'imagePath': + // Determines the image file to use for ImageBanners. + this.imagePath = optionSpec[key]; + break; + default: + // Invalid option specified! + } + this._options[key] = optionSpec[key]; + + // If no banner instance exists yet, go with a safe, blank initialization. + if(!this.container.banner) { + this.selectBanner('inactive'); + } + } + } + + /** + * Sets the active `Banner` to the specified type, regardless of + * existing management logic settings. + * + * @param type `'blank' | 'image' | 'suggestion'` - A plain-text string + * representing the type of `Banner` to set active. + * @param height - Optional banner height in pixels. + */ + public setBanner(type: BannerType) { + var banner: Banner; + + let oldBanner = this.container.banner; + if(oldBanner instanceof SuggestionBanner) { + this.predictionContext.off('update', oldBanner.onSuggestionUpdate); + } + + switch(type) { + case 'blank': + banner = new BlankBanner(); + break; + case 'image': + banner = new ImageBanner(this.imagePath, this.container.activeBannerHeight); + break; + case 'suggestion': + let suggestBanner = banner = new SuggestionBanner(this.hostDevice, this.container.activeBannerHeight); + suggestBanner.predictionContext = this.predictionContext; + suggestBanner.events.on('apply', (selection) => this.predictionContext.accept(selection.suggestion)); + + this.predictionContext.on('update', suggestBanner.onSuggestionUpdate); + break; + default: + throw new Error("Invalid type specified for the banner!"); + } + + this._activeType = type; + + if(banner) { + this.container.banner = banner; + } + } + + /** + * Handles `LanguageProcessor`'s `'statechange'` events, + * allowing logic to automatically hot-swap `Banner`s as needed. + * @param state + */ + selectBanner(state: StateChangeEnum) { + // Only display a SuggestionBanner when LanguageProcessor states it is active. + if(state == 'active' || state == 'configured') { + this.setBanner('suggestion'); + } else if(state == 'inactive') { + if(this.alwaysShow) { + this.setBanner('image'); + } else { + this.setBanner('blank'); + } + } + } + + public get activeType(): BannerType { + return this._activeType; + } +} \ No newline at end of file diff --git a/web/src/engine/osk/src/banner/bannerView.ts b/web/src/engine/osk/src/banner/bannerView.ts index 20059411bbb..f59c0dd3744 100644 --- a/web/src/engine/osk/src/banner/bannerView.ts +++ b/web/src/engine/osk/src/banner/bannerView.ts @@ -1,14 +1,10 @@ import EventEmitter from 'eventemitter3'; -import { DeviceSpec } from '@keymanapp/web-utils'; -import type { PredictionContext, StateChangeEnum } from '@keymanapp/input-processor'; import { createUnselectableElement } from 'keyman/engine/dom-utils'; import { Banner } from './banner.js'; -import { ImageBanner } from './imageBanner.js'; import OSKViewComponent from '../components/oskViewComponent.interface.js'; import { ParsedLengthStyle } from '../lengthStyle.js'; -import { SuggestionBanner } from './suggestionBanner.js'; import { BlankBanner } from './blankBanner.js'; /** @@ -61,7 +57,7 @@ interface BannerViewEventMap { * needs to reserve this space (i.e: Keyman for iOS), * rather than as its standalone app. */ -export default class BannerView implements OSKViewComponent { +export class BannerView implements OSKViewComponent { private bannerContainer: HTMLDivElement; private activeBanner: Banner; private _activeBannerHeight: number = Banner.DEFAULT_HEIGHT; @@ -163,154 +159,4 @@ export default class BannerView implements OSKViewComponent { } public refreshLayout() {}; -} - -export class BannerController { - private _activeType: BannerType; - private _options: BannerOptions = {}; - private container: BannerView; - private alwaysShow: boolean; - private imagePath?: string = ""; - - private predictionContext?: PredictionContext; - - private readonly hostDevice: DeviceSpec; - - public static readonly DEFAULT_OPTIONS: BannerOptions = { - alwaysShow: false, - imagePath: "" - } - - constructor(bannerView: BannerView, hostDevice: DeviceSpec, predictionContext?: PredictionContext) { - // Step 1 - establish the container element. Must come before this.setOptions. - this.hostDevice = hostDevice; - this.container = bannerView; - this.predictionContext = predictionContext; - - // Initialize with the default options - any 'manually set' options come post-construction. - // This will also automatically set the default banner in place. - this.setOptions(BannerController.DEFAULT_OPTIONS); - } - - /** - * This function corresponds to `keyman.osk.banner.getOptions`. - * - * Gets the current control settings in use by `BannerManager`. - */ - public getOptions(): BannerOptions { - let retObj = {}; - - for(let key in this._options) { - retObj[key] = this._options[key]; - } - - return retObj; - } - - /** - * This function corresponds to `keyman.osk.banner.setOptions`. - * - * Sets options used to tweak the automatic `Banner` - * control logic used by `BannerManager`. - * @param optionSpec An object specifying one or more of the following options: - * * `persistentBanner` (boolean) When `true`, ensures that a `Banner` - * is always displayed, even when no predictive model exists - * for the active language. - * - * Default: `false` - * * `imagePath` (URL string) Specifies the file path to use for an - * `ImageBanner` when `persistentBanner` is `true` and no predictive model exists. - * - * Default: `''`. - * * `enablePredictions` (boolean) Turns KMW predictions - * on (when `true`) and off (when `false`). - * - * Default: `true`. - */ - public setOptions(optionSpec: BannerOptions) { - for(let key in optionSpec) { - switch(key) { - // Each defined option may require specialized handling. - case 'alwaysShow': - // Determines the banner type to activate. - this.alwaysShow = optionSpec[key]; - break; - case 'imagePath': - // Determines the image file to use for ImageBanners. - this.imagePath = optionSpec[key]; - break; - default: - // Invalid option specified! - } - this._options[key] = optionSpec[key]; - - // If no banner instance exists yet, go with a safe, blank initialization. - if(!this.container.banner) { - this.selectBanner('inactive'); - } - } - } - - /** - * Sets the active `Banner` to the specified type, regardless of - * existing management logic settings. - * - * @param type `'blank' | 'image' | 'suggestion'` - A plain-text string - * representing the type of `Banner` to set active. - * @param height - Optional banner height in pixels. - */ - public setBanner(type: BannerType) { - var banner: Banner; - - let oldBanner = this.container.banner; - if(oldBanner instanceof SuggestionBanner) { - this.predictionContext.off('update', oldBanner.onSuggestionUpdate); - } - - switch(type) { - case 'blank': - banner = new BlankBanner(); - break; - case 'image': - banner = new ImageBanner(this.imagePath, this.container.activeBannerHeight); - break; - case 'suggestion': - let suggestBanner = banner = new SuggestionBanner(this.hostDevice, this.container.activeBannerHeight); - suggestBanner.predictionContext = this.predictionContext; - suggestBanner.events.on('apply', (selection) => this.predictionContext.accept(selection.suggestion)); - - this.predictionContext.on('update', suggestBanner.onSuggestionUpdate); - break; - default: - throw new Error("Invalid type specified for the banner!"); - } - - this._activeType = type; - - if(banner) { - this.container.banner = banner; - } - } - - /** - * Handles `LanguageProcessor`'s `'statechange'` events, - * allowing logic to automatically hot-swap `Banner`s as needed. - * @param state - */ - selectBanner(state: StateChangeEnum) { - // Only display a SuggestionBanner when LanguageProcessor states it is active. - if(state == 'active' || state == 'configured') { - this.setBanner('suggestion'); - } else if(state == 'inactive') { - if(this.alwaysShow) { - this.setBanner('image'); - } else { - this.setBanner('blank'); - } - } - } - - public get activeType(): BannerType { - return this._activeType; - } } \ No newline at end of file diff --git a/web/src/engine/osk/src/index.ts b/web/src/engine/osk/src/index.ts index b5729285d95..1340424583c 100644 --- a/web/src/engine/osk/src/index.ts +++ b/web/src/engine/osk/src/index.ts @@ -4,7 +4,7 @@ export { default as OSKView } from './views/oskView.js'; export { default as FloatingOSKView, FloatingOSKViewConfiguration } from './views/floatingOskView.js'; export { default as AnchoredOSKView } from './views/anchoredOskView.js'; export { default as InlinedOSKView } from './views/inlinedOskView.js'; -export { BannerController } from './banner/bannerView.js'; +export { BannerController } from './banner/bannerController.js'; // Is referenced by at least one desktop UI module. export { FloatingOSKCookie as FloatingOSKViewCookie } from './views/floatingOskCookie.js'; export { default as VisualKeyboard } from './visualKeyboard.js'; diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index dfc6aa18db1..0399991fe08 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -1,6 +1,7 @@ import EventEmitter from 'eventemitter3'; -import BannerView, { BannerController } from '../banner/bannerView.js'; +import { BannerView } from '../banner/bannerView.js'; +import { BannerController } from '../banner/bannerController.js'; import OSKViewComponent from '../components/oskViewComponent.interface.js'; import EmptyView from '../components/emptyView.js'; import HelpPageView from '../components/helpPageView.js';