spiegel-keyman/web/source/osk/banner.ts
2022-03-13 08:31:15 +11: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);
}
}
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());
}
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);
}
}