diff --git a/web/source/osk/banner.ts b/web/source/osk/banner.ts index 531508a65a..da78f359e5 100644 --- a/web/source/osk/banner.ts +++ b/web/source/osk/banner.ts @@ -163,7 +163,7 @@ namespace com.keyman.osk { private display: HTMLSpanElement; private fontFamily?: string; - private suggestion: Suggestion; + private _suggestion: Suggestion; private index: number; @@ -213,6 +213,10 @@ namespace com.keyman.osk { this.div['suggestion'] = this; } + get suggestion(): Suggestion { + return this._suggestion; + } + /** * Function update * @param {string} id Element ID for the suggestion span @@ -220,7 +224,7 @@ namespace com.keyman.osk { * Description Update the ID and text of the BannerSuggestionSpec */ public update(suggestion: Suggestion) { - this.suggestion = suggestion; + this._suggestion = suggestion; this.updateText(); } @@ -235,18 +239,18 @@ namespace com.keyman.osk { * @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) { + public apply(target?: text.OutputTarget): text.Transcription { let keyman = com.keyman.singleton; if(this.isEmpty()) { - return; + return null; } // Find the state of the context at the time the prediction-triggering keystroke was applied. - let original = keyman.modelManager.getPredictionState(this.suggestion.transformId); + let original = keyman.modelManager.getPredictionState(this._suggestion.transformId); if(!original) { console.warn("Could not apply the Suggestion!"); - return; + return null; } else { if(!target) { /* Assume it's the currently-active `OutputTarget`. We should probably invalidate @@ -260,7 +264,7 @@ namespace com.keyman.osk { // Step 1: determine the final output text let final = text.Mock.from(original.preInput); - final.apply(this.suggestion.transform); + final.apply(this._suggestion.transform); // Step 2: build a final, master Transform that will produce the desired results from the CURRENT state. // In embedded mode, both Android and iOS are best served by calculating this transform and applying its @@ -272,11 +276,16 @@ namespace com.keyman.osk { if(keyman['oninserttext'] && keyman.isEmbedded) { keyman['oninserttext'](transform.deleteLeft, transform.insert, transform.deleteRight); } + + // Build a 'reversion' Transcription that can be used to undo this apply() if needed. + let preApply = text.Mock.from(original.preInput); + preApply.apply(original.transform); + return preApply.buildTranscriptionFrom(target, null); } } public isEmpty(): boolean { - return !this.suggestion; + return !this._suggestion; } /** @@ -289,7 +298,7 @@ namespace com.keyman.osk { let keyman = com.keyman.singleton; let util = keyman.util; - let suggestion = this.suggestion; + let suggestion = this._suggestion; var suggestionText: string; var s=util._CreateElement('span'); @@ -319,7 +328,84 @@ namespace com.keyman.osk { } } - class SuggestionTouchManager extends dom.UITouchHandlerBase { + /** + * 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 manager: SuggestionManager; + + static readonly TOUCHED_CLASS: string = 'kmw-suggest-touched'; + + constructor(height?: number) { + super(height || SuggestionBanner.DEFAULT_HEIGHT); + + this.options = new Array(); + for (var i=0; i { + private selected: BannerSuggestion; + + //#region Touch handling implementation findTargetFrom(e: HTMLElement): HTMLDivElement { let keyman = com.keyman.singleton; let util = keyman.util; @@ -348,7 +434,7 @@ namespace com.keyman.osk { console.warn("Cannot find BannerSuggestion object for element to highlight!"); } else { // Never highlight an empty suggestion button. - let suggestion = t['suggestion'] as BannerSuggestion; + let suggestion = this.selected = t['suggestion'] as BannerSuggestion; if(suggestion.isEmpty()) { on = false; } @@ -362,8 +448,7 @@ namespace com.keyman.osk { } protected select(t: HTMLDivElement): void { - let suggestion = t['suggestion'] as BannerSuggestion; - suggestion.apply(); + this.doAccept(t['suggestion'] as BannerSuggestion); } //#region Long-press support @@ -394,128 +479,145 @@ namespace com.keyman.osk { throw new Error("Method not implemented."); } //#endregion + //#endregion - constructor(div: HTMLElement) { - // TODO: Determine appropriate CSS styling names, etc. - super(div, Banner.BANNER_CLASS, SuggestionBanner.TOUCHED_CLASS); - } - } + private options: BannerSuggestion[]; - /** - * 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 suggestionList : BannerSuggestion[]; private currentSuggestions: Suggestion[] = []; - private touchHandler: SuggestionTouchManager; + private recentAccept: boolean = false; + private recentAccepted: Suggestion; + private preAccept: text.Transcription = null; - static readonly TOUCHED_CLASS: string = 'kmw-suggest-touched'; + private recentRevert: boolean = false; + private rejectedSuggestions: Suggestion[] = []; - constructor(height?: number) { - super(height || SuggestionBanner.DEFAULT_HEIGHT); - - this.suggestionList = new Array(); - for (var i=0; i boolean = function(this: SuggestionManager, source: string): boolean { + if(!this.recentAccept && this.selected) { + this.doAccept(this.selected); + return false; + } else if(this.recentAccept && source == 'space') { + this.recentAccept = false; + return false; // Swallows a single space post-accept. + } + return true; // Not yet implemented + }.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): boolean { + if(this.recentAccept) { + this.doRevert(); + return false; + } else { + return true; + } + }.bind(this); + /** * Function invalidateSuggestions * Scope Public * Description Clears the suggestions in the suggestion banner */ - public invalidateSuggestions: (this: SuggestionBanner) => boolean = - function(this: SuggestionBanner) { - this.suggestionList.forEach((option: BannerSuggestion) => { + public invalidateSuggestions: (this: SuggestionManager) => boolean = + function(this: SuggestionManager) { + + this.recentAccept = false; + this.recentRevert = false; + this.rejectedSuggestions = []; + + this.options.forEach((option: BannerSuggestion) => { option.update(null); }); }.bind(this); + private doUpdate() { + // TODO: Insert 'current text' if/when valid as the leading option. + // We need the LMLayer to tell us this somehow. + let 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: SuggestionBanner, suggestions: Suggestion[]) => boolean = - function(this: SuggestionBanner, suggestions: Suggestion[]) { - this.currentSuggestions = suggestions; + public updateSuggestions: (this: SuggestionManager, suggestions: Suggestion[]) => boolean = + function(this: SuggestionManager, suggestions: Suggestion[]) { - this.suggestionList.forEach((option: BannerSuggestion, i: number) => { - if(i < suggestions.length) { - option.update(suggestions[i]); - } else { - option.update(null); - } - }); + this.currentSuggestions = suggestions; + + // If we've gotten an update request like this, it's almost always user-triggered and means the context has shifted. + this.recentAccept = false; + this.recentRevert = false; + this.rejectedSuggestions = []; + + // The rest is the same, whether from input or from "self-updating" after a reversion to provide new suggestions. + this.doUpdate(); }.bind(this); - - /** - * 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() { - return true; // Not yet implemented - } - - /** - * 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() { - return true; - } - - activate() { - let keyman = com.keyman.singleton; - keyman.modelManager['addEventListener']('invalidatesuggestions', this.invalidateSuggestions); - keyman.modelManager['addEventListener']('suggestionsready', this.updateSuggestions); - keyman.modelManager['addEventListener']('tryaccept', this.tryAccept); - keyman.modelManager['addEventListener']('tryrevert', this.tryRevert); - } - - deactivate() { - let keyman = com.keyman.singleton; - keyman.modelManager['removeEventListener']('invalidatesuggestions', this.invalidateSuggestions); - keyman.modelManager['removeEventListener']('suggestionsready', this.updateSuggestions); - keyman.modelManager['removeEventListener']('tryaccept', this.tryAccept); - keyman.modelManager['removeEventListener']('tryrevert', this.tryRevert); - } } } diff --git a/web/source/text/prediction/modelManager.ts b/web/source/text/prediction/modelManager.ts index 46e8bf90bb..9f8560ef02 100644 --- a/web/source/text/prediction/modelManager.ts +++ b/web/source/text/prediction/modelManager.ts @@ -63,8 +63,13 @@ namespace com.keyman.text.prediction { */ export type ModelChangeHandler = (state: ModelChangeEnum) => boolean; + /** + * Covers both 'tryaccept' and 'tryrevert' events. + */ + export type TryUIHandler = (source: string) => boolean; + type SupportedEventNames = "suggestionsready" | "invalidatesuggestions" | "modelchange" | "tryaccept" | "tryrevert"; - type SupportedEventHandler = InvalidateSuggestionsHandler | ReadySuggestionsHandler | ModelChangeHandler; + type SupportedEventHandler = InvalidateSuggestionsHandler | ReadySuggestionsHandler | ModelChangeHandler | TryUIHandler; export class ModelManager { private lmEngine: LMLayer; @@ -292,11 +297,11 @@ namespace com.keyman.text.prediction { } } - public tryAcceptSuggestion(): boolean { + public tryAcceptSuggestion(source: string): boolean { let keyman = com.keyman.singleton; // Handlers of this event should return 'false' when the 'try' is successful. - return !keyman.util.callEvent(ModelManager.EVENT_PREFIX + 'tryaccept', null); + return !keyman.util.callEvent(ModelManager.EVENT_PREFIX + 'tryaccept', source); } public tryRevertSuggestion(): boolean { diff --git a/web/source/text/processor.ts b/web/source/text/processor.ts index 8f2cbc1589..d6c47ac1ca 100644 --- a/web/source/text/processor.ts +++ b/web/source/text/processor.ts @@ -390,7 +390,7 @@ namespace com.keyman.text { if((keyEvent.kName == "K_BKSP" || keyEvent.Lcode == Codes.keyCodes["K_BKSP"]) && keyman.modelManager.tryRevertSuggestion()) { return; // Can the suggestion UI accept an existing suggestion? If so, do that and swallow the space character. - } else if((keyEvent.kName == "K_SPACE" || keyEvent.Lcode == Codes.keyCodes["K_SPACE"]) && keyman.modelManager.tryAcceptSuggestion()) { + } else if((keyEvent.kName == "K_SPACE" || keyEvent.Lcode == Codes.keyCodes["K_SPACE"]) && keyman.modelManager.tryAcceptSuggestion('space')) { return; } }