spiegel-keyman/web/source/osk/banner.ts
Marc Durdin 49eb062c65 feat(web): prediction casing follows current layer
Fixes #6455.

This involved adding an extra hint to the data passed into the lexical
model layer, being the CasingForm that the current layer wants to use.

Given there are several entry points to the lexical model layer, this
meant a few places where we needed to check the current layer.

I refactored the very long function `InputProcessor.processKeyEvent` to
extract out the `buildAlternates` function. There should be no
functional changes to the `buildAlternates` code. But it makes
`processKeyEvent` significantly easier to read.
2022-04-08 16:14:01 +10:00

733 lines
24 KiB
TypeScript

///<reference path="visualKeyboard.ts" />
///<reference path="uiTouchHandlerBase.ts" />
namespace com.keyman.osk {
// Base class for a banner above the keyboard in the OSK
export abstract class Banner {
private _height: number; // pixels
private div: HTMLDivElement;
public static DEFAULT_HEIGHT: number = 37; // pixels; embedded apps can modify
public static readonly BANNER_CLASS: string = 'kmw-banner-bar';
public static readonly BANNER_ID: string = 'kmw-banner-bar';
/**
* Function height
* Scope Public
* @returns {number} height in pixels
* Description Returns the height of the banner in pixels
*/
public get height(): number {
return this._height;
}
/**
* Function height
* Scope Public
* @param {number} height the height in pixels
* Description Sets the height of the banner in pixels. If a negative
* height is given, set height to 0 pixels.
* Also updates the banner styling.
*/
public set height(height: number) {
this._height = (height > 0) ? height : 0;
this.update();
}
/**
* Function update
* @return {boolean} true if the banner styling changed
* Description Update the height and display styling of the banner
*/
private update() : boolean {
let ds = this.div.style;
let currentHeightStyle = ds.height;
let currentDisplayStyle = ds.display;
if (this._height > 0) {
ds.height = this._height + 'px';
ds.display = 'block';
} else {
ds.height = '0px';
ds.display = 'none';
}
return (!(currentHeightStyle === ds.height) ||
!(currentDisplayStyle === ds.display));
}
public constructor(height?: number) {
let keymanweb = com.keyman.singleton;
let util = keymanweb.util;
let d = util._CreateElement('div');
d.id = Banner.BANNER_ID;
d.className = Banner.BANNER_CLASS;
this.div = d;
this.height = height;
this.update();
}
public appendStyleSheet() {
let keymanweb = com.keyman.singleton;
let util = keymanweb.util;
// TODO: add stylesheets
}
/**
* Function getDiv
* Scope Public
* @returns {HTMLElement} Base element of the banner
* Description Returns the HTMLElelemnt of the banner
*/
public getDiv(): HTMLElement {
return this.div;
}
/**
* Function activate
* Scope Public
* Description Adds any relevant event listeners needed by this banner type.
*/
public activate() {
// Default implementation - no listeners.
}
/**
* Function activate
* Scope Public
* Description Removes any relevant event listeners previously added by this banner.
*/
public deactivate() {
// Default implementation - no listeners.
}
}
/**
* Function BlankBanner
* Description A banner of height 0 that should not be shown
*/
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 _suggestion: Suggestion;
private index: number;
static readonly BASE_ID = 'kmw-suggestion-';
constructor(index: number) {
let keyman = com.keyman.singleton;
this.index = index;
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 = keyman.util._CreateElement('span');
this.div.appendChild(display);
}
private constructRoot() {
let keyman = com.keyman.singleton;
// Add OSK suggestion labels
let div = this.div = keyman.util._CreateElement('div'), ds=div.style;
div.className = "kmw-suggest-option";
div.id = BannerSuggestion.BASE_ID + this.index;
let kbdDetails = keyman.keyboardManager.activeStub;
if(kbdDetails) {
if (kbdDetails['KLC']) {
div.lang = kbdDetails['KLC'];
}
// Establish base font settings
let font = kbdDetails['KFont'];
if(font && font.family && font.family != '') {
ds.fontFamily = this.fontFamily = font.family;
}
}
// 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;
}
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.div.replaceChild(display, this.display);
this.display = display;
}
/**
* Function apply
* @param target (Optional) The OutputTarget to which the `Suggestion` ought be applied.
* Description Applies the predictive `Suggestion` represented by this `BannerSuggestion`.
*/
public apply(target?: text.OutputTarget): Promise<Reversion> {
let keyman = com.keyman.singleton;
if(this.isEmpty()) {
return null;
}
if(!target) {
/* Assume it's the currently-active `OutputTarget`. We should probably invalidate
* everything if/when the active `OutputTarget` changes, though we haven't gotten that
* far in implementation yet.
*/
target = dom.Utils.getOutputTarget();
}
if(this._suggestion.tag == 'revert') {
keyman.core.languageProcessor.applyReversion(this._suggestion as Reversion, target);
return null;
} else {
return keyman.core.languageProcessor.applySuggestion(this.suggestion, target, () => keyman.core.keyboardProcessor.layerId);
}
}
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(): HTMLSpanElement {
let keyman = com.keyman.singleton;
let util = keyman.util;
let suggestion = this._suggestion;
var suggestionText: string;
var s=util._CreateElement('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 activeKeyboard = keyman.core.activeKeyboard;
let rtl = activeKeyboard && activeKeyboard.isRTL;
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;
private options : BannerSuggestion[];
private hostDevice: utils.DeviceSpec;
private manager: SuggestionManager;
static readonly TOUCHED_CLASS: string = 'kmw-suggest-touched';
static readonly BANNER_CLASS: string = 'kmw-suggest-banner';
constructor(hostDevice: utils.DeviceSpec, height?: number) {
super(height || Banner.DEFAULT_HEIGHT);
this.hostDevice = hostDevice;
this.getDiv().className = this.getDiv().className + ' ' + SuggestionBanner.BANNER_CLASS;
this.options = new Array();
for (var i=0; i<SuggestionBanner.SUGGESTION_LIMIT; i++) {
let d = new BannerSuggestion(i);
this.options[i] = d;
}
/* LTR behavior: the default (index 0) suggestion should be at the left
* RTL behavior: the default (index 0) suggestion should be at the right
*
* The cleanest way to make it work - simply invert the order in which
* the elements are inserted for RTL. This allows the banner to be RTL
* for visuals/UI while still being internally LTR.
*/
let activeKeyboard = com.keyman.singleton.core.activeKeyboard;
let rtl = activeKeyboard && activeKeyboard.isRTL;
for (var i=0; i<SuggestionBanner.SUGGESTION_LIMIT; i++) {
let indexToInsert = rtl ? SuggestionBanner.SUGGESTION_LIMIT - i -1 : i;
this.getDiv().appendChild(this.options[indexToInsert].div);
if(i != SuggestionBanner.SUGGESTION_LIMIT) {
// Adds a 'separator' div element for UI purposes.
let separatorDiv = com.keyman.singleton.util._CreateElement('div');
separatorDiv.className = 'kmw-banner-separator';
let ds = separatorDiv.style;
ds.marginLeft = (SuggestionBanner.MARGIN / 2) + '%';
ds.marginRight = (SuggestionBanner.MARGIN / 2) + '%';
this.getDiv().appendChild(separatorDiv);
}
}
this.manager = new SuggestionManager(this.getDiv(), this.options);
this.setupInputHandling();
}
private setupInputHandling() {
let inputEngine: InputEventEngine;
if(this.hostDevice.touchable) { // /*&& ('ontouchstart' in window)*/ // Except Chrome emulation doesn't set this.
// Not to mention, it's rather redundant.
inputEngine = TouchEventEngine.forPredictiveBanner(this, this.manager);
} else {
inputEngine = MouseEventEngine.forPredictiveBanner(this, this.manager);
}
inputEngine.registerEventHandlers();
}
activate() {
let keyman = com.keyman.singleton;
let manager = this.manager;
keyman.core.languageProcessor.addListener('invalidatesuggestions', manager.invalidateSuggestions);
keyman.core.languageProcessor.addListener('suggestionsready', manager.updateSuggestions);
keyman.core.languageProcessor.addListener('tryaccept', manager.tryAccept);
keyman.core.languageProcessor.addListener('tryrevert', manager.tryRevert);
keyman.core.languageProcessor.addListener('suggestionapplied', this.suggestionApplied);
}
/**
* Handler for post-processing once a suggestion has been applied: calls
* into the active keyboard's `begin postKeystroke` entry point.
* @param outputTarget
* @returns true
*/
suggestionApplied(outputTarget: text.OutputTarget): boolean {
const keyman = com.keyman.singleton;
// Tell the keyboard that the current layer has not changed
keyman.core.keyboardProcessor.newLayerStore.set('');
keyman.core.keyboardProcessor.oldLayerStore.set('');
// Call the keyboard's entry point.
keyman.core.keyboardProcessor.processPostKeystroke(this.hostDevice, outputTarget)
// If we have a RuleBehavior as a result, run it on the target. This should
// only change system store and variable store values.
?.finalize(keyman.core.keyboardProcessor, outputTarget, true);
return true;
};
postConfigure() {
let keyman = com.keyman.singleton;
// Trigger a null-based initial prediction to kick things off.
keyman.core.languageProcessor.predictFromTarget(dom.Utils.getOutputTarget(), keyman.core.keyboardProcessor.layerId);
}
deactivate() {
let keyman = com.keyman.singleton;
let manager = this.manager;
keyman.core.languageProcessor.removeListener('invalidatesuggestions', manager.invalidateSuggestions);
keyman.core.languageProcessor.removeListener('suggestionsready', manager.updateSuggestions);
keyman.core.languageProcessor.removeListener('tryaccept', manager.tryAccept);
keyman.core.languageProcessor.removeListener('tryrevert', manager.tryRevert);
}
}
export class SuggestionManager extends UITouchHandlerBase<HTMLDivElement> {
private selected: BannerSuggestion;
platformHold: (suggestion: BannerSuggestion, isCustom: boolean) => void;
//#region Touch handling implementation
findTargetFrom(e: HTMLElement): HTMLDivElement {
let keyman = com.keyman.singleton;
let util = keyman.util;
try {
if(e) {
if(util.hasClass(e,'kmw-suggest-option')) {
return e as HTMLDivElement;
}
if(e.parentNode && util.hasClass(<HTMLElement> e.parentNode,'kmw-suggest-option')) {
return e.parentNode as HTMLDivElement;
}
// if(e.firstChild && util.hasClass(<HTMLElement> e.firstChild,'kmw-suggest-option')) {
// return e.firstChild as HTMLDivElement;
// }
}
} catch(ex) {}
return null;
}
protected highlight(t: HTMLDivElement, on: boolean): void {
let classes = t.className;
let cs = ' ' + SuggestionBanner.TOUCHED_CLASS;
if(t.id.indexOf(BannerSuggestion.BASE_ID) == -1) {
console.warn("Cannot find BannerSuggestion object for element to highlight!");
} else {
// Never highlight an empty suggestion button.
let suggestion = this.selected = t['suggestion'] as BannerSuggestion;
if(suggestion.isEmpty()) {
on = false;
this.selected = null;
}
}
if(on && classes.indexOf(cs) < 0) {
t.className=classes+cs;
} else {
t.className=classes.replace(cs,'');
}
}
protected select(t: HTMLDivElement): void {
this.doAccept(t['suggestion'] as BannerSuggestion);
}
//#region Long-press support
protected hold(t: HTMLDivElement): void {
let suggestionObj = t['suggestion'] as BannerSuggestion;
// Is this the <keep> suggestion? It's never in this.currentSuggestions, so check against that.
let isCustom = this.currentSuggestions.indexOf(suggestionObj.suggestion) == -1;
if(this.platformHold) {
// Implemented separately for native + embedded mode branches.
// Embedded mode should pass any info needed to show a submenu IMMEDIATELY.
this.platformHold(suggestionObj, isCustom); // No implementation yet for native.
}
}
protected clearHolds(): void {
// Temp, pending implementation of suggestion longpress submenus
// - nothing to clear without them -
// only really used in native-KMW
}
protected hasModalPopup(): boolean {
// Utilized by the mobile apps; allows them to 'take over' touch handling,
// blocking it within KMW when the apps are already managing an ongoing touch-hold.
let keyman = com.keyman.singleton;
return keyman['osk'].vkbd.subkeyGesture && keyman.isEmbedded;
}
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
private options: BannerSuggestion[];
private initNewContext: boolean = true;
private currentSuggestions: Suggestion[] = [];
private keepSuggestion: Keep;
private revertSuggestion: Reversion;
private recentAccept: boolean = false;
private revertAcceptancePromise: Promise<Reversion>;
private swallowPrediction: boolean = false;
private doRevert: boolean = false;
private recentRevert: boolean = false;
constructor(div: HTMLElement, options: BannerSuggestion[]) {
// TODO: Determine appropriate CSS styling names, etc.
super(div, Banner.BANNER_CLASS, SuggestionBanner.TOUCHED_CLASS);
this.options = options;
}
private doAccept(suggestion: BannerSuggestion) {
let _this = this;
this.revertAcceptancePromise = suggestion.apply();
if(!this.revertAcceptancePromise) {
// We get here either if suggestion acceptance fails or if it was a reversion.
if(suggestion.suggestion && suggestion.suggestion.tag == 'revert') {
// Reversion state management
this.recentAccept = false;
this.doRevert = false;
this.recentRevert = true;
this.doUpdate();
}
return;
}
this.revertAcceptancePromise.then(function(suggestion) {
// Always null-check!
if(suggestion) {
_this.revertSuggestion = suggestion;
}
});
this.selected = null;
this.recentAccept = true;
this.doRevert = false;
this.recentRevert = false;
this.swallowPrediction = true;
this.doUpdate();
}
private showRevert() {
// Construct a 'revert suggestion' to facilitate a reversion UI component.
this.doRevert = true;
this.doUpdate();
}
/**
* Receives messages from the keyboard that the 'accept' keystroke has been entered.
* Should return 'false' if the current state allows accepting a suggestion and act accordingly.
* Otherwise, return true.
*/
tryAccept: (source: string) => boolean = function(this: SuggestionManager, source: string, returnObj: {shouldSwallow: boolean}) {
let keyman = com.keyman.singleton;
if(!this.recentAccept && this.selected) {
this.doAccept(this.selected);
returnObj.shouldSwallow = true;
} else if(this.recentAccept && source == 'space') {
this.recentAccept = false;
// If the model doesn't insert wordbreaks, don't swallow the space. If it does,
// we consider that insertion to be the results of the first post-accept space.
returnObj.shouldSwallow = !!keyman.core.languageProcessor.wordbreaksAfterSuggestions;
} else {
returnObj.shouldSwallow = false;
}
}.bind(this);
/**
* Receives messages from the keyboard that the 'revert' keystroke has been entered.
* Should return 'false' if the current state allows reverting a recently-applied suggestion and act accordingly.
* Otherwise, return true.
*/
tryRevert: () => boolean = function(this: SuggestionManager, returnObj: {shouldSwallow: boolean}) {
// Has the revert keystroke (BKSP) already been sent once since the last accept?
if(this.doRevert) {
// If so, clear the 'revert' option and start doing normal predictions again.
this.doRevert = false;
this.recentAccept = false;
// Otherwise, did we just accept something before the revert signal was received?
} else if(this.recentAccept) {
this.showRevert();
this.swallowPrediction = true;
}
// We don't yet actually do key-based reversions.
returnObj.shouldSwallow = false;
return;
}.bind(this);
/**
* Function invalidateSuggestions
* Scope Public
* Description Clears the suggestions in the suggestion banner
*/
public invalidateSuggestions: (this: SuggestionManager, source: text.prediction.InvalidateSourceEnum) => boolean =
function(this: SuggestionManager, source: string) {
// By default, we assume that the context is the same until we notice otherwise.
this.initNewContext = false;
if(!this.swallowPrediction || source == 'context') {
this.recentAccept = false;
this.doRevert = false;
this.recentRevert = false;
if(source == 'context') {
this.swallowPrediction = false;
this.initNewContext = true;
}
}
this.options.forEach((option: BannerSuggestion) => {
option.update(null);
});
}.bind(this);
public activateKeep(): boolean {
return !this.recentAccept && !this.recentRevert && !this.initNewContext;
}
private doUpdate() {
let suggestions = [];
// Insert 'current text' if/when valid as the leading option.
// Since we don't yet do auto-corrections, we only show 'keep' whenever it's
// a valid word (according to the model).
if(this.activateKeep() && this.keepSuggestion && this.keepSuggestion.matchesModel) {
suggestions.push(this.keepSuggestion);
} else if(this.doRevert) {
suggestions.push(this.revertSuggestion);
}
suggestions = suggestions.concat(this.currentSuggestions);
this.options.forEach((option: BannerSuggestion, i: number) => {
if(i < suggestions.length) {
option.update(suggestions[i]);
} else {
option.update(null);
}
});
}
/**
* Function updateSuggestions
* Scope Public
* @param {Suggestion[]} suggestions Array of suggestions from the lexical model.
* Description Update the displayed suggestions in the SuggestionBanner
*/
public updateSuggestions: (this: SuggestionManager, prediction: text.prediction.ReadySuggestions) => boolean =
function(this: SuggestionManager, prediction: text.prediction.ReadySuggestions) {
let suggestions = prediction.suggestions;
this.currentSuggestions = suggestions;
// Do we have a keep suggestion? If so, remove it from the list so that we can control its display position
// and prevent it from being hidden after reversion operations.
this.keepSuggestion = null;
for(let s of suggestions) {
if(s.tag == 'keep') {
this.keepSuggestion = s as Keep;
}
}
if(this.keepSuggestion) {
this.currentSuggestions.splice(this.currentSuggestions.indexOf(this.keepSuggestion), 1);
}
// If we've gotten an update request like this, it's almost always user-triggered and means the context has shifted.
if(!this.swallowPrediction) {
this.recentAccept = false;
this.doRevert = false;
this.recentRevert = false;
} else { // This prediction was triggered by a recent 'accept.' Now that it's fulfilled, we clear the flag.
this.swallowPrediction = false;
}
// The rest is the same, whether from input or from "self-updating" after a reversion to provide new suggestions.
this.doUpdate();
}.bind(this);
}
}