/// /// /*** KeymanWeb 11.0 Copyright 2019 SIL International ***/ namespace com.keyman.text { //#region Helper type definitions export class KeyInformation { vk: boolean; code: number; modifiers: number; } /* * Type alias definitions to reflect the parameters of the fullContextMatch() callback (KMW 10+). * No constructors or methods since keyboards will not utilize the same backing prototype, and * property names are shorthanded to promote minification. */ type PlainKeyboardStore = string; export type KeyboardStoreElement = (string|StoreNonCharEntry); export type ComplexKeyboardStore = KeyboardStoreElement[]; type KeyboardStore = PlainKeyboardStore | ComplexKeyboardStore; type RuleChar = string; class RuleDeadkey { /** Discriminant field - 'd' for Deadkey. */ ['t']: 'd'; /** * Value: the deadkey's ID. */ ['d']: number; // For 'd'eadkey; also reflects the Deadkey class's 'd' property. } class ContextAny { /** Discriminant field - 'a' for `any()`. */ ['t']: 'a'; /** * Value: the store to search. */ ['a']: KeyboardStore; // For 'a'ny statement. /** * If set to true, negates the 'any'. */ ['n']: boolean|0|1; } class RuleIndex { /** Discriminant field - 'i' for `index()`. */ ['t']: 'i'; /** * Value: the Store from which to output */ ['i']: KeyboardStore; /** * Offset: the offset in context for the corresponding `any()`. */ ['o']: number; } class ContextEx { /** Discriminant field - 'c' for `context()`. */ ['t']: 'c'; /** * Value: The offset into the current rule's context to be matched. */ ['c']: number; // For 'c'ontext statement. } class ContextNul { /** Discriminant field - 'n' for `nul` */ ['t']: 'n'; } class StoreBeep { /** Discriminant field - 'b' for `beep` */ ['t']: 'b'; } type ContextNonCharEntry = RuleDeadkey | ContextAny | RuleIndex | ContextEx | ContextNul; type ContextEntry = RuleChar | ContextNonCharEntry; type StoreNonCharEntry = RuleDeadkey | StoreBeep; /** * Cache of context storing and retrieving return values from KC * Must be reset prior to each keystroke and after any text changes * MCD 3/1/14 **/ class CachedContext { _cache: string[][]; reset(): void { this._cache = []; } get(n: number, ln: number): string { // return null; // uncomment this line to disable context caching if(typeof this._cache[n] == 'undefined') { return null; } else if(typeof this._cache[n][ln] == 'undefined') { return null; } return this._cache[n][ln]; } set(n: number, ln: number, val: string): void { if(typeof this._cache[n] == 'undefined') { this._cache[n] = []; } this._cache[n][ln] = val; } }; type CachedExEntry = {valContext: (string|number)[], deadContext: text.Deadkey[]}; /** * An extended version of cached context storing designed to work with * `fullContextMatch` and its helper functions. */ class CachedContextEx { _cache: CachedExEntry[][]; reset(): void { this._cache = []; } get(n: number, ln: number): CachedExEntry { // return null; // uncomment this line to disable context caching if(typeof this._cache[n] == 'undefined') { return null; } else if(typeof this._cache[n][ln] == 'undefined') { return null; } return this._cache[n][ln]; } set(n: number, ln: number, val: CachedExEntry): void { if(typeof this._cache[n] == 'undefined') { this._cache[n] = []; } this._cache[n][ln] = val; } }; class BeepData { e: HTMLElement; c: string; constructor(e: HTMLElement) { this.e = e; this.c = e.style.backgroundColor; } reset(): void { this.e.style.backgroundColor = this.c; } } //#endregion export class KeyboardInterface { cachedContext: CachedContext = new CachedContext(); cachedContextEx: CachedContextEx = new CachedContextEx(); activeTargetOutput: OutputTarget; TSS_LAYER: number = 33; TSS_PLATFORM: number = 31; _AnyIndices: number[] = []; // AnyIndex - array of any/index match indices _BeepObjects: BeepData[] = []; // BeepObjects - maintains a list of active 'beep' visual feedback elements _BeepTimeout: number = 0; // BeepTimeout - a flag indicating if there is an active 'beep'. // Set to 1 if there is an active 'beep', otherwise leave as '0'. constructor() { } /** * Function KSF * Scope Public * Description Save keyboard focus */ saveFocus(): void { let keyman = com.keyman.singleton; if(!keyman.isHeadless) { DOMEventHandlers.states._IgnoreNextSelChange = 1; } } /** * Function _NotifyKeyboard * Scope Private * @param {number} _PCommand event code (16,17,18) or 0 * @param {Object} _PTarget target element * @param {number} _PData 1 or 0 * Description Notifies keyboard of keystroke or other event */ notifyKeyboard(_PCommand: number, _PTarget: OutputTarget|HTMLElement|Document, _PData: number) { // I2187 let keyman = com.keyman.singleton; var activeKeyboard = keyman.keyboardManager.activeKeyboard; var target: OutputTarget; if(_PTarget instanceof text.OutputTarget) { target = _PTarget; } else { target = text.Processor.getOutputTarget(_PTarget as HTMLElement); } // Good example use case - the Japanese CJK-picker keyboard if(activeKeyboard != null && typeof(activeKeyboard['KNS']) == 'function') { activeKeyboard['KNS'](_PCommand, target, _PData); } } /** * Function KT * Scope Public * @param {string} Ptext Text to insert * @param {?number} PdeadKey Dead key number, if any (???) * @return {boolean} true if inserted * Description Insert text into active control */ insertText(Ptext: string, PdeadKey:number): boolean { let keyman = com.keyman.singleton; this.resetContextCache(); // Find the correct output target to manipulate. let outputTarget: OutputTarget = this.activeTargetOutput ? this.activeTargetOutput : text.Processor.getOutputTarget(); if(outputTarget != null) { if(!keyman.isHeadless) { keyman.uiManager.setActivatingUI(true); DOMEventHandlers.states._IgnoreNextSelChange = 100; keyman.domManager.focusLastActiveElement(); DOMEventHandlers.states._IgnoreNextSelChange = 0; } if(Ptext!=null) { this.output(0, outputTarget, Ptext); } if((typeof(PdeadKey)!=='undefined') && (PdeadKey !== null)) { this.deadkeyOutput(0, outputTarget, PdeadKey); } outputTarget.invalidateSelection(); return true; } return false; } /** * Function registerKeyboard KR * Scope Public * @param {Object} Pk Keyboard object * Description Register and load the keyboard */ registerKeyboard(Pk): void { let keyman = com.keyman.singleton; keyman.keyboardManager._registerKeyboard(Pk); } /** * Add the basic keyboard parameters (keyboard stub) to the array of keyboard stubs * If no language code is specified in a keyboard it cannot be registered, * and a keyboard stub must be registered before the keyboard is loaded * for the keyboard to be usable. * * @param {Object} Pstub Keyboard stub object * @return {?number} 1 if already registered, else null */ registerStub(Pstub): number { let keyman = com.keyman.singleton; return keyman.keyboardManager._registerStub(Pstub); } /** * Get *cached or uncached* keyboard context for a specified range, relative to caret * * @param {number} n Number of characters to move back from caret * @param {number} ln Number of characters to return * @param {Object} Pelem Element to work with (must be currently focused element) * @return {string} Context string * * Example [abcdef|ghi] as INPUT, with the caret position marked by |: * KC(2,1,Pelem) == "e" * KC(3,3,Pelem) == "def" * KC(10,10,Pelem) == "abcdef" i.e. return as much as possible of the requested string */ context(n: number, ln: number, outputTarget: OutputTarget): string { var v = this.cachedContext.get(n, ln); if(v !== null) { return v; } var r = this.KC_(n, ln, outputTarget); this.cachedContext.set(n, ln, r); return r; } /** * Get (uncached) keyboard context for a specified range, relative to caret * * @param {number} n Number of characters to move back from caret * @param {number} ln Number of characters to return * @param {Object} Pelem Element to work with (must be currently focused element) * @return {string} Context string * * Example [abcdef|ghi] as INPUT, with the caret position marked by |: * KC(2,1,Pelem) == "e" * KC(3,3,Pelem) == "def" * KC(10,10,Pelem) == "XXXXabcdef" i.e. return as much as possible of the requested string, where X = \uFFFE */ private KC_(n: number, ln: number, outputTarget: OutputTarget): string { var tempContext = ''; tempContext = outputTarget.getTextBeforeCaret(); if(tempContext._kmwLength() < n) { tempContext = Array(n-tempContext._kmwLength()+1).join("\uFFFE") + tempContext; } return tempContext._kmwSubstr(-n)._kmwSubstr(0,ln); } /** * Function nul KN * Scope Public * @param {number} n Length of context to check * @param {Object} Ptarg Element to work with (must be currently focused element) * @return {boolean} True if length of context is less than or equal to n * Description Test length of context, return true if the length of the context is less than or equal to n * * Example [abc|def] as INPUT, with the caret position marked by |: * KN(3,Pelem) == TRUE * KN(2,Pelem) == FALSE * KN(4,Pelem) == TRUE */ nul(n: number, outputTarget: OutputTarget): boolean { var cx=this.context(n+1, 1, outputTarget); // With #31, the result will be a replacement character if context is empty. return cx === "\uFFFE"; } /** * Function contextMatch KCM * Scope Public * @param {number} n Number of characters to move back from caret * @param {Object} Ptarg Focused element * @param {string} val String to match * @param {number} ln Number of characters to return * @return {boolean} True if selected context matches val * Description Test keyboard context for match */ contextMatch(n: number, outputTarget: OutputTarget, val: string, ln: number): boolean { var cx=this.context(n, ln, outputTarget); if(cx === val) { return true; // I3318 } outputTarget.deadkeys().resetMatched(); // I3318 return false; } /** * Builds the *cached or uncached* keyboard context for a specified range, relative to caret * * @param {number} n Number of characters to move back from caret * @param {number} ln Number of characters to return * @param {Object} Pelem Element to work with (must be currently focused element) * @return {Array} Context array (of strings and numbers) */ private _BuildExtendedContext(n: number, ln: number, outputTarget: OutputTarget): CachedExEntry { var cache: CachedExEntry = this.cachedContextEx.get(n, ln); if(cache !== null) { return cache; } else { // By far the easiest way to correctly build what we want is to start from the right and work to what we need. // We may have done it for a similar cursor position before. cache = this.cachedContextEx.get(n, n); if(cache === null) { // First, let's make sure we have a cloned, sorted copy of the deadkey array. let unmatchedDeadkeys = outputTarget.deadkeys().toSortedArray(); // Is reverse-order sorted for us already. // Time to build from scratch! var index = 0; cache = { valContext: [], deadContext: []}; while(cache.valContext.length < n) { // As adapted from `deadkeyMatch`. var sp = outputTarget.getDeadkeyCaret(); var deadPos = sp - index; if(unmatchedDeadkeys.length > 0 && unmatchedDeadkeys[0].p > deadPos) { // We have deadkeys at the right-hand side of the caret! They don't belong in the context, so pop 'em off. unmatchedDeadkeys.splice(0, 1); continue; } else if(unmatchedDeadkeys.length > 0 && unmatchedDeadkeys[0].p == deadPos) { // Take the deadkey. cache.deadContext[n-cache.valContext.length-1] = unmatchedDeadkeys[0]; cache.valContext = ([unmatchedDeadkeys[0].d] as (string|number)[]).concat(cache.valContext); unmatchedDeadkeys.splice(0, 1); } else { // Take the character. We get "\ufffe" if it doesn't exist. var kc = this.context(++index, 1, outputTarget); cache.valContext = ([kc] as (string|number)[]).concat(cache.valContext); } } this.cachedContextEx.set(n, n, cache); } // Now that we have the cache... var subCache = cache; subCache.valContext = subCache.valContext.slice(0, ln); for(var i=0; i < subCache.valContext.length; i++) { if(subCache[i] == '\ufffe') { subCache.valContext.splice(0, 1); subCache.deadContext.splice(0, 1); } } if(subCache.valContext.length == 0) { subCache.valContext = ['\ufffe']; subCache.deadContext = []; } this.cachedContextEx.set(n, ln, subCache); return subCache; } } /** * Function fullContextMatch KFCM * Scope Private * @param {number} n Number of characters to move back from caret * @param {Object} Ptarg Focused element * @param {Array} rule An array of ContextEntries to match. * @return {boolean} True if the fully-specified rule context matches the current KMW state. * * A KMW 10+ function designed to bring KMW closer to Keyman Desktop functionality, * near-directly modeling (externally) the compiled form of Desktop rules' context section. */ fullContextMatch(n: number, outputTarget: OutputTarget, rule: ContextEntry[]): boolean { // Stage one: build the context index map. var fullContext = this._BuildExtendedContext(n, rule.length, outputTarget); var context = fullContext.valContext; var deadContext = fullContext.deadContext; var mismatch = false; // This symbol internally indicates lack of context in a position. (See KC_) const NUL_CONTEXT = "\uFFFE"; var assertNever = function(x: never): never { // Could be accessed by improperly handwritten calls to `fullContextMatch`. throw new Error("Unexpected object in fullContextMatch specification: " + x); } // Stage two: time to match against the rule specified. for(var i=0; i < rule.length; i++) { if(typeof rule[i] == 'string') { var str = rule[i] as string; if(str !== context[i]) { mismatch = true; break; } } else { // TypeScript needs a cast to this intermediate type to do its discriminated union magic. var r = rule[i] as ContextNonCharEntry; switch(r.t) { case 'd': // We still need to set a flag here; if(r['d'] !== context[i]) { mismatch = true; } else { deadContext[i].set(); } break; case 'a': var lookup: KeyboardStoreElement; if(typeof context[i] == 'string') { lookup = context[i] as string; } else { lookup = {'t': 'd', 'd': context[i] as number}; } var result = this.any(i, lookup, r.a); if(!r.n) { // If it's a standard 'any'... if(!result) { mismatch = true; } else if(deadContext[i] !== undefined) { // It's a deadkey match, so indicate that. deadContext[i].set(); } // 'n' for 'notany'. If we actually match or if we have nul context (\uFFFE), notany fails. } else if(r.n && (result || context[i] !== NUL_CONTEXT)) { mismatch = true; } break; case 'i': // The context will never hold a 'beep.' var ch = this._Index(r.i, r.o) as string | RuleDeadkey; if(ch !== undefined && (typeof(ch) == 'string' ? ch : ch.d) !== context[i]) { mismatch = true; } else if(deadContext[i] !== undefined) { deadContext[i].set(); } break; case 'c': if(context[r.c - 1] !== context[i]) { mismatch = true; } else if(deadContext[i] !== undefined) { deadContext[i].set(); } break; case 'n': // \uFFFE is the internal 'no context here sentinel'. if(context[i] != NUL_CONTEXT) { mismatch = true; } break; default: assertNever(r); } } } if(mismatch) { // Reset the matched 'any' indices, if any. outputTarget.deadkeys().resetMatched(); this._AnyIndices = []; } return !mismatch; } /** * Function KIK * Scope Public * @param {Object} e keystroke event * @return {boolean} true if keypress event * Description Test if event as a keypress event */ isKeypress(e: KeyEvent):boolean { let keyman = com.keyman.singleton; if(keyman.keyboardManager.activeKeyboard['KM']) { // I1380 - support KIK for positional layouts return !e.LisVirtualKey; // will now return true for U_xxxx keys, but not for T_xxxx keys } else { return keyman.keyMapManager._USKeyCodeToCharCode(e) ? true : false; // I1380 - support KIK for positional layouts } } /** * Function keyMatch KKM * Scope Public * @param {Object} e keystroke event * @param {number} Lruleshift * @param {number} Lrulekey * @return {boolean} True if key matches rule * Description Test keystroke with modifiers against rule */ keyMatch(e: KeyEvent, Lruleshift:number, Lrulekey:number): boolean { var retVal = false; // I3318 var keyCode = (e.Lcode == 173 ? 189 : e.Lcode); //I3555 (Firefox hyphen issue) let keyman = com.keyman.singleton; let bitmask = keyman.keyboardManager.getKeyboardModifierBitmask(); let Codes = com.keyman.text.Codes; var modifierBitmask = bitmask & Codes.modifierBitmasks["ALL"]; var stateBitmask = bitmask & Codes.stateBitmasks["ALL"]; if(e.vkCode > 255) { keyCode = e.vkCode; // added to support extended (touch-hold) keys for mnemonic layouts } if(e.LisVirtualKey || keyCode > 255) { if((Lruleshift & 0x4000) == 0x4000 || (keyCode > 255)) { // added keyCode test to support extended keys retVal = ((Lrulekey == keyCode) && ((Lruleshift & modifierBitmask) == e.Lmodifiers)); //I3318, I3555 retVal = retVal && this.stateMatch(e, Lruleshift & stateBitmask); } } else if((Lruleshift & 0x4000) == 0) { retVal = (keyCode == Lrulekey); // I3318, I3555 } if(!retVal) { this.activeTargetOutput.deadkeys().resetMatched(); // I3318 } return retVal; // I3318 }; /** * Function stateMatch KSM * Scope Public * @param {Object} e keystroke event * @param {number} Lstate * Description Test keystroke against state key rules */ stateMatch(e: KeyEvent, Lstate: number) { return ((Lstate & e.Lstates) == Lstate); } /** * Function keyInformation KKI * Scope Public * @param {Object} e * @return {Object} Object with event's virtual key flag, key code, and modifiers * Description Get object with extended key event information */ keyInformation(e: KeyEvent): KeyInformation { var ei = new KeyInformation(); ei['vk'] = e.LisVirtualKey; ei['code'] = e.Lcode; ei['modifiers'] = e.Lmodifiers; return ei; }; /** * Function deadkeyMatch KDM * Scope Public * @param {number} n offset from current cursor position * @param {Object} Ptarg target element * @param {number} d deadkey * @return {boolean} True if deadkey found selected context matches val * Description Match deadkey at current cursor position */ deadkeyMatch(n: number, outputTarget: OutputTarget, d: number): boolean { return outputTarget.hasDeadkeyMatch(n, d); } /** * Function beepReset KBR * Scope Public * Description Reset/terminate beep or flash (not currently used: Aug 2011) */ beepReset(): void { this.resetContextCache(); var Lbo; this._BeepTimeout = 0; for(Lbo=0;Lbo= 0; } /** * Function _Index * Scope Public * @param {string} Ps string * @param {number} Pn index * Description Returns the character from a store string according to the offset in the index array */ _Index(Ps: KeyboardStore, Pn: number): KeyboardStoreElement { Ps = this._ExplodeStore(Ps); if(this._AnyIndices[Pn-1] < Ps.length) { //I3319 return Ps[this._AnyIndices[Pn-1]]; } else { /* Should not be possible for a compiled keyboard, but may arise * during the development of handwritten keyboards. */ console.warn("Unmatched contextual index() statement detected in rule with index " + Pn + "!"); return ""; } } /** * Function indexOutput KIO * Scope Public * @param {number} Pdn no of character to overwrite (delete) * @param {string} Ps string * @param {number} Pn index * @param {Object} Pelem element to output to * Description Output a character selected from the string according to the offset in the index array */ indexOutput(Pdn: number, Ps: KeyboardStore, Pn: number, outputTarget: OutputTarget): void { this.resetContextCache(); var assertNever = function(x: never): never { // Could be accessed by improperly handwritten calls to `fullContextMatch`. throw new Error("Unexpected object in fullContextMatch specification: " + x); } var indexChar = this._Index(Ps, Pn); if(indexChar !== "") { if(typeof indexChar == 'string' ) { this.output(Pdn, outputTarget, indexChar); //I3319 } else if(indexChar['t']) { var storeEntry = indexChar as StoreNonCharEntry; switch(storeEntry.t) { case 'b': // Beep commands may appear within stores. this.beep(outputTarget); break; case 'd': this.deadkeyOutput(Pdn, outputTarget, indexChar['d']); break; default: assertNever(storeEntry); } } else { // For keyboards developed during 10.0's alpha phase - t:'d' was assumed. this.deadkeyOutput(Pdn, outputTarget, indexChar['d']); } } } /** * Function deleteContext KDC * Scope Public * @param {number} dn number of context entries to overwrite * @param {Object} Pelem element to output to * @param {string} s string to output * Description Keyboard output */ deleteContext(dn: number, outputTarget: OutputTarget): void { var context: CachedExEntry; // We want to control exactly which deadkeys get removed. if(dn > 0) { context = this._BuildExtendedContext(dn, dn, outputTarget); let nulCount = 0; for(var i=0; i < context.valContext.length; i++) { var dk = context.deadContext[i]; if(dk) { // Remove deadkey in context. outputTarget.deadkeys().remove(dk); // Reduce our reported context size. dn--; } else if(context.valContext[i] == "\uFFFE") { // Count any `nul` sentinels that would contribute to our deletion count. nulCount++; } } // Prevent attempts to delete nul sentinels, as they don't exist in the actual context. // (Addresses regression from KMW v 12.0 paired with Developer bug through same version) let contextLength = context.valContext.length - nulCount; if(dn > contextLength) { dn = contextLength; } } // If a matched deadkey hasn't been deleted, we don't WANT to delete it. outputTarget.deadkeys().resetMatched(); // Why reinvent the wheel? Delete the remaining characters by 'inserting a blank string'. this.output(dn, outputTarget, ''); } /** * Function output KO * Scope Public * @param {number} dn number of characters to overwrite * @param {Object} Pelem element to output to * @param {string} s string to output * Description Keyboard output */ output(dn: number, outputTarget: OutputTarget, s:string): void { this.resetContextCache(); let keyman = com.keyman.singleton; // KeymanTouch for Android uses direct insertion of the character string if('oninserttext' in keyman && !(outputTarget instanceof Mock)) { keyman['oninserttext'](dn,s); } outputTarget.saveProperties(); outputTarget.clearSelection(); outputTarget.deadkeys().deleteMatched(); // I3318 if(dn >= 0) { // Automatically manages affected deadkey positions. Does not delete deadkeys b/c legacy behavior support. outputTarget.deleteCharsBeforeCaret(dn); } // Automatically manages affected deadkey positions. outputTarget.insertTextBeforeCaret(s); outputTarget.restoreProperties(); // Refresh element content after change (if needed) if(typeof(keyman.refreshElementContent) == 'function') { keyman.refreshElementContent(outputTarget.getElement()); } if((dn >= 0 || s) && outputTarget.getElement() == DOMEventHandlers.states.activeElement) { // Record that we've made an edit. DOMEventHandlers.states.changed = true; } } /** * Function deadkeyOutput KDO * Scope Public * @param {number} Pdn no of character to overwrite (delete) * @param {Object} Pelem element to output to * @param {number} Pd deadkey id * Description Record a deadkey at current cursor position, deleting Pdn characters first */ deadkeyOutput(Pdn: number, outputTarget: OutputTarget, Pd: number): void { this.resetContextCache(); if(Pdn >= 0) { this.output(Pdn, outputTarget,""); //I3318 corrected to >= } outputTarget.insertDeadkeyBeforeCaret(Pd); // _DebugDeadKeys(Pelem, 'KDeadKeyOutput: dn='+Pdn+'; deadKey='+Pd); } /** * KIFS compares the content of a system store with a string value * * @param {number} systemId ID of the system store to test (only TSS_LAYER currently supported) * @param {string} strValue String value to compare to * @param {Object} Pelem Currently active element (may be needed by future tests) * @return {boolean} True if the test succeeds */ ifStore(systemId: number, strValue: string, outputTarget: OutputTarget): boolean { let keyman = com.keyman.singleton; var result=true; if(systemId == this.TSS_LAYER) { // How would this be handled in an eventual headless mode? result = (keyman.osk.vkbd.layerId === strValue); } else if(systemId == this.TSS_PLATFORM) { var i,constraint,constraints=strValue.split(' '); for(i=0; i