namespace com.keyman.osk { export class KeyData { ['key']: OSKKey; ['keyId']: string; ['subKeys']?: OSKKeySpec[]; constructor(keyData: OSKKey, keyId: string) { this['key'] = keyData; this['keyId'] = keyId; } } export type KeyElement = HTMLDivElement & KeyData; // Many thanks to https://www.typescriptlang.org/docs/handbook/advanced-types.html for this. export function link(elem: HTMLDivElement, data: KeyData): KeyElement { let e = elem; // Merges all properties and methods of KeyData onto the underlying HTMLDivElement, creating a merged class. for(let id in data) { if(!e.hasOwnProperty(id)) { (e)[id] = (data)[id]; } } return e; } export function isKey(elem: Node): boolean { return elem && ('key' in elem) && (( elem['key']) instanceof OSKKey); } export function getKeyFrom(elem: Node): KeyElement { if(isKey(elem)) { return elem; } else { return null; } } export class OSKKeySpec implements keyboards.LayoutKey { id: string; // Only set (within @keymanapp/keyboard-processor) for keys actually specified in a loaded layout baseKeyID?: string; coreID?: string; elementID?: string; text?: string; sp?: number | keyboards.ButtonClass; width: string; layer?: string; // The key will derive its base modifiers from this property - may not equal the layer on which it is displayed. nextlayer?: string; pad?: string; sk?: OSKKeySpec[]; constructor(id: string, text?: string, width?: string, sp?: number | keyboards.ButtonClass, nextlayer?: string, pad?: string) { this.id = id; this.text = text; this.width = width ? width : "50"; this.sp = sp; this.nextlayer = nextlayer; this.pad = pad; } } export abstract class OSKKey { // Defines the PUA code mapping for the various 'special' modifier/control keys on keyboards. // `specialCharacters` must be kept in sync with the same variable in builder.js. See also CompileKeymanWeb.pas: CSpecialText10 static readonly specialCharacters = { '*Shift*': 8, '*Enter*': 5, '*Tab*': 6, '*BkSp*': 4, '*Menu*': 11, '*Hide*': 10, '*Alt*': 25, '*Ctrl*': 1, '*Caps*': 3, '*ABC*': 16, '*abc*': 17, '*123*': 19, '*Symbol*': 21, '*Currency*': 20, '*Shifted*': 8, // set SHIFTED->9 for filled arrow icon '*AltGr*': 2, '*TabLeft*': 7, '*LAlt*': 0x56, '*RAlt*': 0x57, '*LCtrl*': 0x58, '*RCtrl*': 0x59, '*LAltCtrl*': 0x60, '*RAltCtrl*': 0x61, '*LAltCtrlShift*': 0x62, '*RAltCtrlShift*': 0x63, '*AltShift*': 0x64, '*CtrlShift*': 0x65, '*AltCtrlShift*': 0x66, '*LAltShift*': 0x67, '*RAltShift*': 0x68, '*LCtrlShift*': 0x69, '*RCtrlShift*': 0x70, // Added in Keyman 14.0. '*LTREnter*': 0x05, // Default alias of '*Enter*'. '*LTRBkSp*': 0x04, // Default alias of '*BkSp*'. '*RTLEnter*': 0x71, '*RTLBkSp*': 0x72, '*ShiftLock*': 0x73, '*ShiftedLock*': 0x74, '*ZWNJ*': 0x75, // If this one is specified, auto-detection will kick in. '*ZWNJiOS*': 0x75, // The iOS version will be used by default, but the '*ZWNJAndroid*': 0x76, // Android platform has its own default glyph. }; static readonly BUTTON_CLASSES = [ 'default', 'shift', 'shift-on', 'special', 'special-on', '', // Key classes 5 through 7 are reserved for future use. '', '', 'deadkey', 'blank', 'hidden' ]; static readonly HIGHLIGHT_CLASS = 'kmw-key-touched'; readonly spec: OSKKeySpec; btn: KeyElement; label: HTMLSpanElement; square: HTMLDivElement; /** * The layer of the OSK on which the key is displayed. */ readonly layer: string; constructor(spec: OSKKeySpec, layer: string) { this.spec = spec; this.layer = layer; } abstract getId(): string; /** * Attach appropriate class to each key button, according to the layout * * @param {Object=} layout source layout description (optional, sometimes) */ public setButtonClass() { let key = this.spec; let btn = this.btn; var n=0; if(typeof key['dk'] == 'string' && key['dk'] == '1') { n=8; } if(typeof key['sp'] == 'string') { n=parseInt(key['sp'],10); } if(n < 0 || n > 10) { n=0; } btn.className='kmw-key kmw-key-'+OSKKey.BUTTON_CLASSES[n]; } /** * For keys with button classes that support toggle states, this method * may be used to toggle which state the key's button class is in. * - shift <=> shift-on * - special <=> special-on * @param {boolean=} flag The new toggle state */ public setToggleState(flag?: boolean) { let btnClassId: number; let classAsString: boolean; if(classAsString = typeof this.spec['sp'] == 'string') { btnClassId = parseInt(this.spec['sp'], 10); } else { btnClassId = this.spec['sp']; } // 1 + 2: shift + shift-on // 3 + 4: special + special-on switch(OSKKey.BUTTON_CLASSES[btnClassId]) { case 'shift': case 'shift-on': if(flag === undefined) { flag = OSKKey.BUTTON_CLASSES[btnClassId] == 'shift'; } this.spec['sp'] = 1 + (flag ? 1 : 0); break; // Added in 15.0: special key highlight toggling. // Was _intended_ in earlier versions, but not actually implemented. case 'special': case 'special-on': if(flag === undefined) { flag = OSKKey.BUTTON_CLASSES[btnClassId] == 'special'; } this.spec['sp'] = 3 + (flag ? 1 : 0); break; default: return; } if(classAsString) { // KMW currently doesn't handle raw numbers for 'sp' properly. this.spec['sp'] = ('' + this.spec['sp']) as keyboards.ButtonClass; } this.setButtonClass(); } // "Frame key" - generally refers to non-linguistic keys on the keyboard public isFrameKey(): boolean { let classIndex = this.spec['sp'] || 0; switch(OSKKey.BUTTON_CLASSES[classIndex]) { case 'default': case 'deadkey': // Note: will (generally) include the spacebar. return false; default: return true; } } public allowsKeyTip(): boolean { if(this.isFrameKey()) { return false; } else { return !this.btn.classList.contains('kmw-spacebar'); } } public highlight(on: boolean) { var classes=this.btn.classList; if(on) { if(!classes.contains(OSKKey.HIGHLIGHT_CLASS)) { classes.add(OSKKey.HIGHLIGHT_CLASS); } } else { classes.remove(OSKKey.HIGHLIGHT_CLASS); } } /** * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. * * @param {String} text The text to be rendered. * @param {String} style The CSSStyleDeclaration for an element to measure against, without modification. * * @see https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 * This version has been substantially modified to work for this particular application. */ static getTextMetrics(text: string, emScale: number, style: {fontFamily?: string, fontSize: string}): TextMetrics { // Since we may mutate the incoming style, let's make sure to copy it first. // Only the relevant properties, though. style = { fontFamily: style.fontFamily, fontSize: style.fontSize }; // A final fallback - having the right font selected makes a world of difference. if(!style.fontFamily) { style.fontFamily = getComputedStyle(document.body).fontFamily; } if(!style.fontSize || style.fontSize == "") { style.fontSize = '1em'; } let fontFamily = style.fontFamily; let fontSpec = getFontSizeStyle(style.fontSize); var fontSize: string; if(fontSpec.absolute) { // We've already got an exact size - use it! fontSize = fontSpec.val + 'px'; } else { fontSize = fontSpec.val * emScale + 'px'; } // re-use canvas object for better performance var canvas: HTMLCanvasElement = OSKKey.getTextMetrics['canvas'] || (OSKKey.getTextMetrics['canvas'] = document.createElement("canvas")); var context = canvas.getContext("2d"); context.font = fontSize + " " + fontFamily; var metrics = context.measureText(text); return metrics; } /** * Calculate the font size required for a key cap, scaling to fit longer text * @param vkbd * @param style specification for the desired base font size * @param override if true, don't use the font spec from the button, just use the passed in spec * @returns font size as a style string */ getIdealFontSize(vkbd: VisualKeyboard, style: {height?: string, fontFamily?: string, fontSize: string}, override?: boolean): string { let buttonStyle = getComputedStyle(this.btn); let keyWidth = parseFloat(buttonStyle.width); let emScale = 1; const originalSize = getFontSizeStyle(style.fontSize || '1em'); // Not yet available; it'll be handled in a later layout pass. if(!buttonStyle.fontSize) { // NOTE: preserves old behavior for use in documentation keyboards, for now. // Once we no longer need to maintain this code block, we can drop all current // method parameters safely. // // Recompute the new width for use in autoscaling calculations below, just in case. emScale = vkbd.getKeyEmFontSize(); keyWidth = this.getKeyWidth(vkbd); } else if(!override) { // When available, just use computedStyle instead. style = buttonStyle; } let fontSpec = getFontSizeStyle(style.fontSize || '1em'); let metrics = OSKKey.getTextMetrics(this.spec.text, emScale, style); const MAX_X_PROPORTION = 0.90; const MAX_Y_PROPORTION = 0.90; const X_PADDING = 2; const Y_PADDING = 2; var fontHeight: number, keyHeight: number; if(metrics.fontBoundingBoxAscent) { fontHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; } let textHeight = fontHeight ? fontHeight + Y_PADDING : 0; if(style.height && style.height.indexOf('px') != -1) { keyHeight = Number.parseFloat(style.height.substring(0, style.height.indexOf('px'))); } let xProportion = (keyWidth * MAX_X_PROPORTION) / (metrics.width + X_PADDING); // How much of the key does the text want to take? let yProportion = textHeight && keyHeight ? (keyHeight * MAX_Y_PROPORTION) / textHeight : undefined; var proportion: number = xProportion; if(yProportion && yProportion < xProportion) { proportion = yProportion; } // Never upscale keys past the default - only downscale them. // Proportion < 1: ratio of key width to (padded [loosely speaking]) text width // maxProportion determines the 'padding' involved. // if(proportion < 1) { if(originalSize.absolute) { return proportion * fontSpec.val + 'px'; } else { return proportion * originalSize.val + 'em'; } } else { if(originalSize.absolute) { return fontSpec.val + 'px'; } else { return originalSize.val + 'em'; } } } getKeyWidth(vkbd: VisualKeyboard): number { let key = this.spec as keyboards.ActiveKey; return key.proportionalWidth * vkbd.width; } /** * Replace default key names by special font codes for modifier keys * * @param {string} oldText * @return {string} **/ protected renameSpecialKey(oldText: string, vkbd: VisualKeyboard): string { // If a 'special key' mapping exists for the text, replace it with its corresponding special OSK character. switch(oldText) { case '*ZWNJ*': // Default ZWNJ symbol comes from iOS. We'd rather match the system defaults where // possible / available though, and there's a different standard symbol on Android. oldText = vkbd.device.OS == com.keyman.utils.OperatingSystem.Android ? '*ZWNJAndroid*' : '*ZWNJiOS*'; break; case '*Enter*': oldText = vkbd.isRTL ? '*RTLEnter*' : '*LTREnter*'; break; case '*BkSp*': oldText = vkbd.isRTL ? '*RTLBkSp*' : '*LTRBkSp*'; break; default: // do nothing. } let specialCodePUA = 0XE000 + VisualKeyboard.specialCharacters[oldText]; return VisualKeyboard.specialCharacters[oldText] ? String.fromCharCode(specialCodePUA) : oldText; } private unicodeKeyIdToString(id: string): string { // This is similar to defaultOutput.ts:forUnicodeKeynames and could potentially // be refactored in the future. if(!id || id.substr(0,2) != 'U_') { return null; } let result = ''; const codePoints = id.substr(2).split('_'); for(let codePoint of codePoints) { const codePointValue = parseInt(codePoint, 16); if (((0x0 <= codePointValue) && (codePointValue <= 0x1F)) || ((0x80 <= codePointValue) && (codePointValue <= 0x9F))) { continue; } else { // String.fromCharCode() is inadequate to handle the entire range of Unicode // Someday after upgrading to ES2015, can use String.fromCodePoint() result += String.kmwFromCharCode(codePointValue); } } return result ? result : null; } // Produces a HTMLSpanElement with the key's actual text. protected generateKeyText(vkbd: VisualKeyboard): HTMLSpanElement { let spec = this.spec; // Add OSK key labels var keyText; var t = document.createElement('span'), ts=t.style; if(spec['text'] == null || spec['text'] == '') { keyText='\xa0'; // default: nbsp. if(typeof spec['id'] == 'string') { // If the ID's Unicode-based, just use that code. keyText = this.unicodeKeyIdToString(spec['id']) || keyText; } } else { keyText=spec['text']; // Unique layer-based transformation: SHIFT-TAB uses a different glyph. if(keyText == '*Tab*' && this.layer == 'shift') { keyText = '*TabLeft*'; } } t.className='kmw-key-text'; let specialText = this.renameSpecialKey(keyText, vkbd); if(specialText != keyText) { // The keyboard wants to use the code for a special glyph defined by the SpecialOSK font. keyText = specialText; spec['font'] = "SpecialOSK"; } //Override font spec if set for this key in the layout if(typeof spec['font'] == 'string' && spec['font'] != '') { ts.fontFamily=spec['font']; } if(typeof spec['fontsize'] == 'string' && spec['fontsize'] != '') { ts.fontSize=spec['fontsize']; } // For some reason, fonts will sometimes 'bug out' for the embedded iOS page if we // instead assign fontFamily to the existing style 'ts'. (Occurs in iOS 12.) let styleSpec: {fontFamily?: string, fontSize: string} = {fontSize: ts.fontSize}; if(ts.fontFamily) { styleSpec.fontFamily = ts.fontFamily; } else { styleSpec.fontFamily = vkbd.fontFamily; // Helps with style sheet calculations. } // Check the key's display width - does the key visualize well? let emScale = vkbd.getKeyEmFontSize(); var width: number = OSKKey.getTextMetrics(keyText, emScale, styleSpec).width; if(width == 0 && keyText != '' && keyText != '\xa0') { // Add the Unicode 'empty circle' as a base support for needy diacritics. // Disabled by mcdurdin 2020-10-19; dotted circle display is inconsistent on iOS/Safari // at least and doesn't combine with diacritic marks. For consistent display, it may be // necessary to build a custom font that does not depend on renderer choices for base // mark display -- e.g. create marks with custom base included, potentially even on PUA // code points and use those in rendering the OSK. See #3039 for more details. // keyText = '\u25cc' + keyText; if(vkbd.isRTL) { // Add the RTL marker to ensure it displays properly. keyText = '\u200f' + keyText; } } ts.fontSize = this.getIdealFontSize(vkbd, styleSpec); // Finalize the key's text. t.innerText = keyText; return t; } public isUnderTouch(input: InputEventCoordinate): boolean { let x = input.x; let y = input.y; let btn = this.btn; let x0 = dom.Utils.getAbsoluteX(btn); let y0 = dom.Utils.getAbsoluteY(btn); let x1 = x0 + btn.offsetWidth; let y1 = y0 + btn.offsetHeight; return (x > x0 && x < x1 && y > y0 && y < y1); } public refreshLayout(vkbd: VisualKeyboard) { if(this.label) { // space bar may not define the text span! this.label.style.fontSize = this.getIdealFontSize(vkbd, this.btn.style); } } } }