mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-05 16:35:33 +00:00
1751 lines
61 KiB
TypeScript
1751 lines
61 KiB
TypeScript
/// <reference path="preProcessor.ts" />
|
|
/// <reference path="lengthStyle.ts" />
|
|
/// <reference path="utils.ts" />
|
|
/// <reference path="oskLayerGroup.ts" />
|
|
/// <reference path="keytip.interface.ts" />
|
|
/// <reference path="browser/keytip.ts" />
|
|
/// <reference path="browser/pendingLongpress.ts" />
|
|
/// <reference path="browser/pendingMultiTap.ts" />
|
|
/// <reference path="keyboardView.interface.ts" />
|
|
/// <reference path="touchEventEngine.ts" />
|
|
/// <reference path="mouseEventEngine.ts" />
|
|
|
|
namespace com.keyman.osk {
|
|
interface BoundingRect {
|
|
left: number,
|
|
right: number,
|
|
top: number,
|
|
bottom: number
|
|
};
|
|
|
|
export class VisualKeyboard implements KeyboardView {
|
|
// Legacy alias, maintaining a reference for code built against older
|
|
// versions of KMW.
|
|
static specialCharacters = OSKKey.specialCharacters;
|
|
|
|
/**
|
|
* Contains layout properties corresponding to the OSK's layout. Needs to be public
|
|
* so that its geometry may be updated on rotations and keyboard resize events, as
|
|
* said geometry needs to be accurate for fat-finger probability calculations.
|
|
*/
|
|
kbdLayout: keyboards.ActiveLayout;
|
|
layerGroup: OSKLayerGroup;
|
|
|
|
private _layerId: string = "default";
|
|
layerIndex: number = 0; // the index of the default layer
|
|
readonly isRTL: boolean;
|
|
|
|
device: com.keyman.utils.DeviceSpec;
|
|
hostDevice: com.keyman.utils.DeviceSpec;
|
|
|
|
inputEngine: InputEventEngine;
|
|
|
|
isStatic: boolean = false;
|
|
_fixedWidthScaling: boolean = false;
|
|
_fixedHeightScaling: boolean = true;
|
|
|
|
// Stores the base element for this instance of the visual keyboard.
|
|
// Formerly known as osk._DivVKbd
|
|
kbdDiv: HTMLDivElement;
|
|
styleSheet: HTMLStyleElement;
|
|
|
|
/**
|
|
* The configured width for this VisualKeyboard. May be `undefined` or `null`
|
|
* to allow automatic width scaling.
|
|
*/
|
|
private _width: number;
|
|
|
|
/**
|
|
* The configured height for this VisualKeyboard. May be `undefined` or `null`
|
|
* to allow automatic height scaling.
|
|
*/
|
|
private _height: number;
|
|
|
|
/**
|
|
* The computed width for this VisualKeyboard. May be null if auto sizing
|
|
* is allowed and the VisualKeyboard is not currently in the DOM hierarchy.
|
|
*/
|
|
private _computedWidth: number;
|
|
|
|
/**
|
|
* The computed height for this VisualKeyboard. May be null if auto sizing
|
|
* is allowed and the VisualKeyboard is not currently in the DOM hierarchy.
|
|
*/
|
|
private _computedHeight: number;
|
|
|
|
// Style-related properties
|
|
fontFamily: string;
|
|
private _fontSize: ParsedLengthStyle;
|
|
// fontSize: string;
|
|
|
|
// State-related properties
|
|
keyPending: KeyElement;
|
|
touchPending: InputEventCoordinate;
|
|
deleteKey: KeyElement;
|
|
deleting: number; // Tracks a timer id for repeated deletions.
|
|
nextLayer: string;
|
|
currentKey: string;
|
|
|
|
// Touch-tracking properties
|
|
initTouchCoord: InputEventCoordinate;
|
|
touchCount: number;
|
|
currentTarget: KeyElement;
|
|
|
|
// Used by embedded-mode's globe key
|
|
menuEvent: KeyElement; // Used by embedded-mode.
|
|
|
|
// Popup key management
|
|
keytip: KeyTip;
|
|
pendingSubkey: PendingGesture;
|
|
subkeyGesture: RealizedGesture;
|
|
|
|
// Multi-tap gesture management
|
|
pendingMultiTap: PendingMultiTap;
|
|
|
|
// The keyboard object corresponding to this VisualKeyboard.
|
|
private layoutKeyboard: keyboards.Keyboard;
|
|
|
|
get layerId(): string {
|
|
return this._layerId;
|
|
}
|
|
|
|
set layerId(value: string) {
|
|
this._layerId = value;
|
|
}
|
|
|
|
get currentLayer(): OSKLayer {
|
|
return this.layerId ? this.layerGroup?.layers[this.layerId] : null;
|
|
}
|
|
|
|
// Special keys (for the currently-visible layer)
|
|
get lgKey(): KeyElement { // currently, must be visible for the touch language menu.
|
|
return this.currentLayer?.globeKey?.btn;
|
|
}
|
|
|
|
private get hkKey(): KeyElement { // hide keyboard key
|
|
return this.currentLayer?.hideKey?.btn;
|
|
}
|
|
|
|
public get spaceBar(): KeyElement { // also referenced by the touch language menu.
|
|
return this.currentLayer?.spaceBarKey?.btn;
|
|
}
|
|
|
|
//#region OSK constructor and helpers
|
|
|
|
/**
|
|
* @param {Object} PVK Visual keyboard name
|
|
* @param {Object} Lhelp true if OSK defined for this keyboard
|
|
* @param {Object} layout0
|
|
* @param {Number} kbdBitmask Keyboard modifier bitmask
|
|
* Description Generates the base visual keyboard element, prepping for attachment to KMW
|
|
*/
|
|
constructor(keyboard: keyboards.Keyboard, device: com.keyman.utils.DeviceSpec, hostDevice?: com.keyman.utils.DeviceSpec, isStatic?: boolean) {
|
|
this.device = device;
|
|
this.hostDevice = hostDevice || device;
|
|
if (isStatic) {
|
|
this.isStatic = isStatic;
|
|
}
|
|
|
|
this._fixedWidthScaling = this.device.touchable && !this.isStatic;
|
|
this._fixedHeightScaling = this.device.touchable && !this.isStatic;
|
|
|
|
// Create the collection of HTML elements from the device-dependent layout object
|
|
var Lkbd = document.createElement('div');
|
|
let layout: keyboards.ActiveLayout;
|
|
if (keyboard) {
|
|
layout = this.kbdLayout = keyboard.layout(device.formFactor as utils.FormFactor);
|
|
this.isRTL = keyboard.isRTL;
|
|
} else {
|
|
// This COULD be called with no backing keyboard; KMW will try to force-show the OSK even without
|
|
// a backing keyboard on mobile, using the most generic default layout as the OSK's base.
|
|
//
|
|
// In KMW's current state, it'd take a major break, though - Processor always has an activeKeyboard,
|
|
// even if it's "hollow".
|
|
let rawLayout = keyboards.Layouts.buildDefaultLayout(null, null, device.formFactor);
|
|
layout = this.kbdLayout = keyboards.ActiveLayout.polyfill(rawLayout, null, device.formFactor as utils.FormFactor);
|
|
this.isRTL = false;
|
|
}
|
|
|
|
// Override font if specified by keyboard
|
|
if ('font' in layout) {
|
|
this.fontFamily = layout['font'];
|
|
} else {
|
|
this.fontFamily = '';
|
|
}
|
|
|
|
// Now to build the actual layout.
|
|
const formFactor = device.formFactor as utils.FormFactor;
|
|
this.layoutKeyboard = keyboard;
|
|
if (!this.layoutKeyboard) {
|
|
// May occasionally be null in embedded contexts; have seen this when iOS engine sets
|
|
// keyboard height during change of keyboards.
|
|
this.layoutKeyboard = new keyboards.Keyboard(null);
|
|
}
|
|
|
|
this.layerGroup = new OSKLayerGroup(this, this.layoutKeyboard, formFactor);
|
|
|
|
// Now that we've properly processed the keyboard's layout, mark it as calibrated.
|
|
// TODO: drop the whole 'calibration' thing. The newer layout system supersedes the
|
|
// need for it. (Is no longer really used, so the drop ought be clean.)
|
|
this.layoutKeyboard.markLayoutCalibrated(formFactor);
|
|
|
|
// Append the OSK layer group container element to the containing element
|
|
//osk.keyMap = divLayerContainer;
|
|
Lkbd.appendChild(this.layerGroup.element);
|
|
|
|
// Set base class - OS and keyboard added for Build 360
|
|
this.kbdDiv = Lkbd;
|
|
|
|
// For 'live' touch keyboards, attach touch-based event handling.
|
|
// Needs to occur AFTER this.kbdDiv is initialized.
|
|
if (!this.isStatic) {
|
|
if (this.hostDevice.touchable) {
|
|
this.inputEngine = TouchEventEngine.forVisualKeyboard(this);
|
|
} else {
|
|
this.inputEngine = MouseEventEngine.forVisualKeyboard(this);
|
|
}
|
|
this.inputEngine.registerEventHandlers();
|
|
}
|
|
|
|
Lkbd.className = device.formFactor + ' kmw-osk-inner-frame';
|
|
}
|
|
|
|
public get element(): HTMLDivElement {
|
|
return this.kbdDiv;
|
|
}
|
|
|
|
public postInsert(): void { }
|
|
|
|
/**
|
|
* The configured width for this VisualKeyboard. May be `undefined` or `null`
|
|
* to allow automatic width scaling.
|
|
*/
|
|
get width(): number {
|
|
return this._width;
|
|
}
|
|
|
|
/**
|
|
* The configured height for this VisualKeyboard. May be `undefined` or `null`
|
|
* to allow automatic height scaling.
|
|
*/
|
|
get height(): number {
|
|
return this._height;
|
|
}
|
|
|
|
get layoutWidth(): ParsedLengthStyle {
|
|
if (this.usesFixedWidthScaling) {
|
|
let baseWidth = this.width;
|
|
let cs = getComputedStyle(this.element);
|
|
if (cs.border) {
|
|
let borderWidth = new ParsedLengthStyle(cs.borderWidth).val;
|
|
baseWidth -= borderWidth * 2;
|
|
}
|
|
return ParsedLengthStyle.inPixels(baseWidth);
|
|
} else {
|
|
return ParsedLengthStyle.forScalar(1);
|
|
}
|
|
}
|
|
|
|
get layoutHeight(): ParsedLengthStyle {
|
|
if (this.usesFixedHeightScaling) {
|
|
let baseHeight = this.height;
|
|
let cs = getComputedStyle(this.element);
|
|
if (cs.border) {
|
|
let borderHeight = new ParsedLengthStyle(cs.borderWidth).val;
|
|
baseHeight -= borderHeight * 2;
|
|
}
|
|
return ParsedLengthStyle.inPixels(baseHeight);
|
|
} else {
|
|
return ParsedLengthStyle.forScalar(1);
|
|
}
|
|
}
|
|
|
|
get internalHeight(): ParsedLengthStyle {
|
|
if (this.usesFixedHeightScaling) {
|
|
// Touch OSKs may apply internal padding to prevent row cropping at the edges.
|
|
return ParsedLengthStyle.inPixels(this.layoutHeight.val - this.getVerticalLayerGroupPadding());
|
|
} else {
|
|
return ParsedLengthStyle.forScalar(1);
|
|
}
|
|
}
|
|
|
|
get fontSize(): ParsedLengthStyle {
|
|
if (!this._fontSize) {
|
|
this._fontSize = new ParsedLengthStyle('1em');
|
|
}
|
|
return this._fontSize;
|
|
}
|
|
|
|
set fontSize(value: ParsedLengthStyle) {
|
|
this._fontSize = value;
|
|
this.kbdDiv.style.fontSize = value.styleString;
|
|
}
|
|
|
|
/**
|
|
* Uses fixed scaling for widths of internal elements, rather than relative,
|
|
* percent-based scaling.
|
|
*/
|
|
public get usesFixedWidthScaling(): boolean {
|
|
return this._fixedWidthScaling;
|
|
}
|
|
|
|
public set usesFixedWidthScaling(val: boolean) {
|
|
this._fixedWidthScaling = val;
|
|
}
|
|
|
|
/**
|
|
* Uses fixed scaling for heights of internal elements, rather than relative,
|
|
* percent-based scaling.
|
|
*/
|
|
public get usesFixedHeightScaling(): boolean {
|
|
return this._fixedHeightScaling;
|
|
}
|
|
|
|
public set usesFixedHeightScaling(val: boolean) {
|
|
this._fixedHeightScaling = val;
|
|
}
|
|
|
|
/**
|
|
* Denotes if the VisualKeyboard or its containing OSKView / OSKManager uses
|
|
* fixed positioning.
|
|
*/
|
|
public get usesFixedPositioning(): boolean {
|
|
let node: HTMLElement = this.element;
|
|
while (node) {
|
|
if (getComputedStyle(node).position == 'fixed') {
|
|
return true;
|
|
} else {
|
|
node = node.offsetParent as HTMLElement;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Sets & tracks the size of the VisualKeyboard's primary element.
|
|
* @param width
|
|
* @param height
|
|
* @param pending Set to `true` if called during a resizing interaction
|
|
*/
|
|
public setSize(width?: number, height?: number, pending?: boolean) {
|
|
this._width = width;
|
|
this._height = height;
|
|
|
|
if (this.kbdDiv) {
|
|
this.kbdDiv.style.width = width ? this._width + 'px' : '';
|
|
this.kbdDiv.style.height = height ? this._height + 'px' : '';
|
|
|
|
if (!this.device.touchable && height) {
|
|
this.fontSize = new ParsedLengthStyle((this._height / 8) + 'px');
|
|
}
|
|
|
|
if (!pending) {
|
|
this.refreshLayout();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the default properties for a key object, used to construct
|
|
* both a base keyboard key and popup keys
|
|
*
|
|
* @return {Object} An object that contains default key properties
|
|
*/
|
|
getDefaultKeyObject(): OSKKeySpec {
|
|
return new OSKKeySpec(undefined, '', keyboards.ActiveKey.DEFAULT_KEY.width, keyboards.ActiveKey.DEFAULT_KEY.sp as keyboards.ButtonClass,
|
|
null, keyboards.ActiveKey.DEFAULT_KEY.pad);
|
|
};
|
|
//#endregion
|
|
|
|
//#region OSK touch handlers
|
|
getTouchCoordinatesOnKeyboard(input: InputEventCoordinate) {
|
|
let keyman = com.keyman.singleton;
|
|
|
|
// We need to compute the 'local', keyboard-based coordinates for the touch.
|
|
let kbdCoords = keyman.util.getAbsolute(this.kbdDiv as HTMLElement);
|
|
let offsetCoords = { x: input.x - kbdCoords.x, y: input.y - kbdCoords.y };
|
|
|
|
// The layer group's element always has the proper width setting, unlike kbdDiv itself.
|
|
offsetCoords.x /= this.layerGroup.element.offsetWidth;
|
|
offsetCoords.y /= this.kbdDiv.offsetHeight;
|
|
|
|
return offsetCoords;
|
|
}
|
|
|
|
/**
|
|
* Builds the fat-finger distribution used by predictive text as its source for likelihood
|
|
* of alternate keystroke sequences.
|
|
* @param input The input coordinate of the event that led to use of this function
|
|
* @param keySpec The spec of the key directly triggered by the input event. May be for a subkey.
|
|
* @returns
|
|
*/
|
|
getTouchProbabilities(input: InputEventCoordinate, keySpec?: keyboards.ActiveKey): text.KeyDistribution {
|
|
let keyman = com.keyman.singleton;
|
|
if (!keyman.core.languageProcessor.mayCorrect) {
|
|
return null;
|
|
}
|
|
|
|
// Note: if subkeys are active, they will still be displayed at this time.
|
|
// TODO: In such cases, we should build an ActiveLayout (of sorts) for subkey displays,
|
|
// update their geometries to the actual display values, and use the results here.
|
|
let touchKbdPos = this.getTouchCoordinatesOnKeyboard(input);
|
|
let layerGroup = this.layerGroup.element; // Always has proper dimensions, unlike kbdDiv itself.
|
|
let width = layerGroup.offsetWidth, height = this.kbdDiv.offsetHeight;
|
|
// Prevent NaN breakages.
|
|
if (!width || !height) {
|
|
return null;
|
|
}
|
|
|
|
let kbdAspectRatio = layerGroup.offsetWidth / this.kbdDiv.offsetHeight;
|
|
let baseKeyProbabilities = this.kbdLayout.getLayer(this.layerId).getTouchProbabilities(touchKbdPos, kbdAspectRatio);
|
|
|
|
if (!keySpec || !this.subkeyGesture || !this.subkeyGesture.baseKey.key) {
|
|
return baseKeyProbabilities;
|
|
} else {
|
|
// A temp-hack, as this was noted just before 14.0's release.
|
|
// Since a more... comprehensive solution would be way too complex this late in the game,
|
|
// this provides a half-decent stopgap measure.
|
|
//
|
|
// Will not correct to nearby subkeys; only includes the selected subkey and its base keys.
|
|
// Still, better than ignoring them both for whatever base key is beneath the final cursor location.
|
|
let baseMass = 1.0;
|
|
|
|
let baseKeyMass = 1.0;
|
|
let baseKeyID = this.subkeyGesture.baseKey.key.spec.coreID;
|
|
|
|
let popupKeyMass = 0.0;
|
|
let popupKeyID: string = null;
|
|
|
|
popupKeyMass = 3.0;
|
|
popupKeyID = keySpec.coreID;
|
|
|
|
// If the base key appears in the subkey array and was selected, merge the probability masses.
|
|
if (popupKeyID == baseKeyID) {
|
|
baseKeyMass += popupKeyMass;
|
|
popupKeyMass = 0;
|
|
} else {
|
|
// We namespace it so that lookup operations know to find it via its base key.
|
|
popupKeyID = `${baseKeyID}::${popupKeyID}`;
|
|
}
|
|
|
|
// Compute the normalization factor
|
|
let totalMass = baseMass + baseKeyMass + popupKeyMass;
|
|
let scalar = 1.0 / totalMass;
|
|
|
|
// Prevent duplicate entries in the final map & normalize the remaining entries!
|
|
for (let i = 0; i < baseKeyProbabilities.length; i++) {
|
|
let entry = baseKeyProbabilities[i];
|
|
if (entry.keyId == baseKeyID) {
|
|
baseKeyMass += entry.p * scalar;
|
|
baseKeyProbabilities.splice(i, 1);
|
|
i--;
|
|
} else if (entry.keyId == popupKeyID) {
|
|
popupKeyMass = + entry.p * scalar;
|
|
baseKeyProbabilities.splice(i, 1);
|
|
i--;
|
|
} else {
|
|
entry.p *= scalar;
|
|
}
|
|
}
|
|
|
|
let finalArray: { keyId: string, p: number }[] = [];
|
|
|
|
if (popupKeyMass > 0) {
|
|
finalArray.push({ keyId: popupKeyID, p: popupKeyMass * scalar });
|
|
}
|
|
|
|
finalArray.push({ keyId: baseKeyID, p: baseKeyMass * scalar });
|
|
|
|
finalArray = finalArray.concat(baseKeyProbabilities);
|
|
return finalArray;
|
|
}
|
|
}
|
|
|
|
//#region Input handling start
|
|
|
|
/**
|
|
* Determines a "fuzzy boundary" area around the OSK within which active mouse and
|
|
* touch events will be maintained, even if their coordinates lie outside of the OSK's
|
|
* true visual bounds.
|
|
* @returns A `BoundingRect`, in `.pageX` / `.pageY` coordinates.
|
|
*/
|
|
private getInteractiveBoundingRect(): BoundingRect {
|
|
// Determine the important geometric values involved
|
|
let oskX = dom.Utils.getAbsoluteX(this.element);
|
|
let oskY = dom.Utils.getAbsoluteY(this.element);
|
|
|
|
// Determine the out-of-bounds threshold at which touch-cancellation should automatically occur.
|
|
// Assuming square key-squares, we'll use 1/3 the height of a row for bounds detection
|
|
// for both dimensions.
|
|
const rowCount = this.currentLayer.rows.length;
|
|
const buffer = (0.333 * this.height / rowCount);
|
|
|
|
// Determine the OSK's boundaries and the boundaries of the page / view.
|
|
// These values are needed in .pageX / .pageY coordinates for the final calcs.
|
|
let boundingRect: BoundingRect = {
|
|
left: oskX - buffer,
|
|
right: oskX + this.width + buffer,
|
|
top: oskY - buffer,
|
|
bottom: oskY + this.height + buffer
|
|
};
|
|
|
|
return boundingRect;
|
|
}
|
|
|
|
/**
|
|
* Adjusts a potential "interactive boundary" definition by enforcing an
|
|
* "event cancellation zone" near screen boundaries that are not directly adjacent
|
|
* to the ongoing input event's initial coordinate.
|
|
*
|
|
* This facilitates modeling of conventional cancellation gestures where a user would
|
|
* drag the mouse or touch point off the OSK, as mouse and touch event handlers receive
|
|
* no input beyond screen boundaries.
|
|
*
|
|
* @param baseBounds The baseline interactive bounding area to be adjusted
|
|
* @param startCoord The initial coordinate of a currently-ongoing input event
|
|
* @returns
|
|
*/
|
|
private applyScreenMarginBoundsThresholding(baseBounds: BoundingRect,
|
|
startCoord: InputEventCoordinate): BoundingRect {
|
|
// Determine the needed linear translation to screen coordinates.
|
|
const xDelta = window.screenLeft - window.pageXOffset;
|
|
const yDelta = window.screenTop - window.pageYOffset;
|
|
|
|
let adjustedBounds: BoundingRect = { ...baseBounds };
|
|
|
|
// Also translate the initial touch's screen coord, as it affects our bounding box logic.
|
|
const initScreenCoord = new InputEventCoordinate(startCoord.x + xDelta, startCoord.y + yDelta);
|
|
|
|
// Detection: is the OSK aligned with any screen boundaries?
|
|
// If so, create a 'fuzzy' zone around the edges not near the initial touch point that allow
|
|
// move-based cancellation.
|
|
|
|
// If the initial input screen-coord is at least 5 pixels from the screen's left AND
|
|
// the OSK's left boundary is within 2 pixels from the screen's left...
|
|
if (initScreenCoord.x >= 5 && baseBounds.left + xDelta <= 2) {
|
|
adjustedBounds.left = 2 - xDelta; // new `leftBound` is set to 2 pixels from the screen's left.
|
|
}
|
|
|
|
if (initScreenCoord.x <= screen.width - 5 && baseBounds.right + xDelta >= screen.width - 2) {
|
|
adjustedBounds.right = (screen.width - 2) - xDelta; // new `rightBound` 2px from screen's right.
|
|
}
|
|
|
|
if (initScreenCoord.y >= 5 && baseBounds.top + yDelta <= 2) {
|
|
adjustedBounds.top = 2 - yDelta;
|
|
}
|
|
|
|
if (initScreenCoord.y <= screen.height - 5 && baseBounds.bottom + yDelta >= screen.height - 2) {
|
|
adjustedBounds.bottom = (screen.height - 2) - yDelta;
|
|
}
|
|
|
|
return adjustedBounds;
|
|
}
|
|
|
|
detectWithinInteractiveBounds(coord: InputEventCoordinate): boolean {
|
|
// Shortcuts the method during unit testing, as we don't currently
|
|
// provide coordinate values in its synthetic events.
|
|
if (coord.x === null && coord.y === null) {
|
|
return true;
|
|
}
|
|
|
|
const baseBoundingRect = this.getInteractiveBoundingRect();
|
|
let adjustedBoundingRect = baseBoundingRect;
|
|
if(this.initTouchCoord) {
|
|
this.applyScreenMarginBoundsThresholding(baseBoundingRect, this.initTouchCoord);
|
|
}
|
|
|
|
// Now to check where the input coordinate lies in relation to the final bounding box!
|
|
|
|
if (coord.x < adjustedBoundingRect.left || coord.x > adjustedBoundingRect.right) {
|
|
return false;
|
|
} else if (coord.y < adjustedBoundingRect.top || coord.y > adjustedBoundingRect.bottom) {
|
|
return false;
|
|
} else {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The main OSK touch start event handler
|
|
*
|
|
* @param {Event} e touch start event object
|
|
*
|
|
*/
|
|
touch(input: InputEventCoordinate) {
|
|
// Identify the key touched
|
|
var t = <HTMLElement>input.target, key = this.keyTarget(t);
|
|
|
|
// Save the touch point, which is used for quick-display of popup keys (defined in highlightSubKeys)
|
|
this.initTouchCoord = input;
|
|
|
|
// Set the key for the new touch point to be current target, if defined
|
|
this.currentTarget = key;
|
|
|
|
// Clear repeated backspace if active, preventing 'sticky' behavior.
|
|
this.cancelDelete();
|
|
|
|
// Prevent multi-touch if popup displayed
|
|
if (this.subkeyGesture && this.subkeyGesture.isVisible()) {
|
|
return;
|
|
}
|
|
|
|
// Keep track of number of active (unreleased) touch points
|
|
this.touchCount = input.activeInputCount;
|
|
|
|
// Get nearest key if touching a hidden key or the end of a key row
|
|
if ((key && ((key.className.indexOf('key-hidden') >= 0) || (key.className.indexOf('key-blank') >= 0)))
|
|
|| t.className.indexOf('kmw-key-row') >= 0) {
|
|
|
|
// Perform "fudged" selection ops if and only if we're not sure about the precision of the
|
|
// input source. Mouse-based selection IS precise, so no need for "fudging" there.
|
|
if (!input.isFromMouse) {
|
|
key = this.findNearestKey(input, t);
|
|
}
|
|
}
|
|
// Do not do anything if no key identified!
|
|
if (key == null) {
|
|
return;
|
|
}
|
|
|
|
// Get key name (K_...) from element ID
|
|
let keyName = key['keyId'];
|
|
|
|
// Highlight the touched key
|
|
this.highlightKey(key, true);
|
|
|
|
// Special function keys need immediate action
|
|
if (keyName == 'K_LOPT' || keyName == 'K_ROPT') {
|
|
window.setTimeout(function (this: VisualKeyboard) {
|
|
this.modelKeyClick(key);
|
|
// Because we immediately process the key, we need to re-highlight it after the click.
|
|
this.highlightKey(key, true);
|
|
// Highlighting'll be cleared automatically later.
|
|
}.bind(this), 0);
|
|
this.keyPending = null;
|
|
this.touchPending = null;
|
|
|
|
// Also backspace, to allow delete to repeat while key held
|
|
} else if (keyName == 'K_BKSP') {
|
|
// While we could inline the execution of the delete key here, we lose the ability to
|
|
// record the backspace key if we do so.
|
|
this.modelKeyClick(key, input);
|
|
this.deleteKey = key;
|
|
this.deleting = window.setTimeout(this.repeatDelete, 500);
|
|
this.keyPending = null;
|
|
this.touchPending = null;
|
|
} else {
|
|
if (this.keyPending) {
|
|
this.highlightKey(this.keyPending, false);
|
|
|
|
if (this.subkeyGesture && this.subkeyGesture instanceof browser.SubkeyPopup) {
|
|
let subkeyPopup = this.subkeyGesture as browser.SubkeyPopup;
|
|
subkeyPopup.updateTouch(input);
|
|
subkeyPopup.finalize(input);
|
|
} else {
|
|
this.modelKeyClick(this.keyPending, this.touchPending);
|
|
}
|
|
// Decrement the number of unreleased touch points to prevent
|
|
// sending the keystroke again when the key is actually released
|
|
this.touchCount--;
|
|
} else {
|
|
this.initGestures(key, input);
|
|
}
|
|
this.keyPending = key;
|
|
this.touchPending = input;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* OSK touch release event handler
|
|
*
|
|
* @param {Event} e touch release event object
|
|
*
|
|
**/
|
|
release(input: InputEventCoordinate): void {
|
|
// Prevent incorrect multi-touch behaviour if native or device popup visible
|
|
var t = this.currentTarget;
|
|
|
|
// Clear repeated backspace if active, preventing 'sticky' behavior.
|
|
this.cancelDelete();
|
|
|
|
// Multi-Tap
|
|
if (this.pendingMultiTap && this.pendingMultiTap.realized) {
|
|
// Ignore pending key if we've just handled a multitap
|
|
this.pendingMultiTap = null;
|
|
|
|
this.highlightKey(this.keyPending, false);
|
|
this.keyPending = null;
|
|
this.touchPending = null;
|
|
|
|
return;
|
|
}
|
|
|
|
if (this.pendingMultiTap && this.pendingMultiTap.cancelled) {
|
|
this.pendingMultiTap = null;
|
|
}
|
|
|
|
// Longpress
|
|
if ((this.subkeyGesture && this.subkeyGesture.isVisible())) {
|
|
// Ignore release if a multiple touch
|
|
if (input.activeInputCount > 0) {
|
|
return;
|
|
}
|
|
|
|
if (this.subkeyGesture instanceof browser.SubkeyPopup) {
|
|
let subkeyPopup = this.subkeyGesture as browser.SubkeyPopup;
|
|
subkeyPopup.finalize(input);
|
|
}
|
|
this.highlightKey(this.keyPending, false);
|
|
this.keyPending = null;
|
|
this.touchPending = null;
|
|
|
|
return;
|
|
}
|
|
|
|
// Handle menu key release event
|
|
if (t && t.id) {
|
|
this.optionKey(t, t.id, false);
|
|
}
|
|
|
|
// Test if moved off screen (effective release point must be corrected for touch point horizontal speed)
|
|
// This is not completely effective and needs some tweaking, especially on Android
|
|
if (!this.detectWithinInteractiveBounds(input)) {
|
|
this.moveCancel(input);
|
|
this.touchCount--;
|
|
return;
|
|
}
|
|
|
|
// Save then decrement current touch count
|
|
var tc = this.touchCount;
|
|
if (this.touchCount > 0) {
|
|
this.touchCount--;
|
|
}
|
|
|
|
// Process and clear highlighting of pending target
|
|
if (this.keyPending) {
|
|
this.highlightKey(this.keyPending, false);
|
|
// Output character unless moved off key
|
|
if (this.keyPending.className.indexOf('hidden') < 0 && tc > 0) {
|
|
this.modelKeyClick(this.keyPending, input);
|
|
}
|
|
this.clearPopup();
|
|
this.keyPending = null;
|
|
this.touchPending = null;
|
|
// Always clear highlighting of current target on release (multi-touch)
|
|
} else {
|
|
var tt = input;
|
|
t = this.keyTarget(tt.target);
|
|
if (!t) {
|
|
// Operates relative to the viewport, not based on the actual coordinate on the page.
|
|
var t1 = document.elementFromPoint(input.x - window.pageXOffset, input.y - window.pageYOffset);
|
|
t = this.findNearestKey(input, <HTMLElement>t1);
|
|
}
|
|
|
|
this.highlightKey(t, false);
|
|
}
|
|
}
|
|
|
|
moveCancel(input: InputEventCoordinate): void {
|
|
// Do not attempt to support reselection of target key for overlapped keystrokes.
|
|
// Perform _after_ ensuring possible sticky keys have been cancelled.
|
|
if (input.activeInputCount > 1) {
|
|
return;
|
|
}
|
|
|
|
// Update all gesture tracking. The function returns true if further input processing
|
|
// should be blocked. (Keeps the subkey array operating when the input coordinate has
|
|
// moved outside the OSK's boundaries.)
|
|
if (this.updateGestures(null, this.keyPending, input)) {
|
|
return;
|
|
}
|
|
|
|
this.cancelDelete();
|
|
|
|
this.highlightKey(this.keyPending, false);
|
|
this.showKeyTip(null, false);
|
|
this.clearPopup();
|
|
this.keyPending = null;
|
|
this.touchPending = null;
|
|
}
|
|
|
|
/**
|
|
* OSK touch move event handler
|
|
*
|
|
* @param {Event} e touch move event object
|
|
*
|
|
**/
|
|
moveOver(input: InputEventCoordinate): void {
|
|
// Shouldn't be possible, but just in case.
|
|
if (this.touchCount == 0) {
|
|
this.cancelDelete();
|
|
return;
|
|
}
|
|
|
|
// Get touch position
|
|
const x = input.x - window.pageXOffset;
|
|
const y = input.y - window.pageYOffset;
|
|
|
|
// Move target key and highlighting
|
|
this.touchPending = input;
|
|
// Operates on viewport-based coordinates, not page-based.
|
|
var t1 = <HTMLElement>document.elementFromPoint(x, y);
|
|
const key0 = this.keyPending;
|
|
let key1 = this.keyTarget(t1); // Not only gets base keys, but also gets popup keys!
|
|
|
|
// Find the nearest key to the touch point if not on a visible key
|
|
if ((key1 && key1.className.indexOf('key-hidden') >= 0) ||
|
|
(t1 && (!key1) && t1.className.indexOf('key-row') >= 0)) {
|
|
key1 = this.findNearestKey(input, t1);
|
|
}
|
|
|
|
// Cancels BKSP if it's not the key. (Note... could also cancel BKSP if the ongoing
|
|
// input is cancelled, regardless of key, just to be safe.)
|
|
|
|
// Stop repeat if no longer on BKSP key
|
|
if (key1 && (typeof key1.id == 'string') && (key1.id.indexOf('-K_BKSP') < 0)) {
|
|
this.cancelDelete();
|
|
}
|
|
|
|
// Cancels if it's a multitouch attempt.
|
|
|
|
// Do not attempt to support reselection of target key for overlapped keystrokes.
|
|
// Perform _after_ ensuring possible sticky keys have been cancelled.
|
|
if (input.activeInputCount > 1) {
|
|
return;
|
|
}
|
|
|
|
// Gesture-updates should probably be a separate call from other touch-move aspects.
|
|
|
|
// Update all gesture tracking. The function returns true if further input processing
|
|
// should be blocked.
|
|
if (this.updateGestures(key1, key0, input)) {
|
|
return;
|
|
}
|
|
|
|
// Identify current touch position (to manage off-key release)
|
|
this.currentTarget = key1;
|
|
|
|
// Only NOW do we denote the newly-selected key as the currently-focused key.
|
|
|
|
// Replace the target key, if any, by the new target key
|
|
// Do not replace a null target, as that indicates the key has already been released
|
|
if (key1 && this.keyPending) {
|
|
this.highlightKey(key0, false);
|
|
this.keyPending = key1;
|
|
this.touchPending = input;
|
|
}
|
|
|
|
if (key0 && key1 && (key1 != key0) && (key1.id != '')) {
|
|
// While there may not be an active subkey menu, we should probably update which base key
|
|
// is being highlighted by the current touch & start a pending longpress for it.
|
|
this.clearPopup();
|
|
this.initGestures(key1, input);
|
|
}
|
|
|
|
if (this.keyPending) {
|
|
if (key0 != key1 || key1.className.indexOf(OSKKey.HIGHLIGHT_CLASS) < 0) {
|
|
this.highlightKey(key1, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
//#endregion
|
|
|
|
/**
|
|
* Get the current key target from the touch point element within the key
|
|
*
|
|
* @param {Object} t element at touch point
|
|
* @return {Object} the key element (or null)
|
|
**/
|
|
keyTarget(target: HTMLElement | EventTarget): KeyElement {
|
|
let t = <HTMLElement>target;
|
|
|
|
try {
|
|
if (t) {
|
|
if (t.classList.contains('kmw-key')) {
|
|
return getKeyFrom(t);
|
|
}
|
|
if (t.parentNode && (t.parentNode as HTMLElement).classList.contains('kmw-key')) {
|
|
return getKeyFrom(t.parentNode);
|
|
}
|
|
if (t.firstChild && (t.firstChild as HTMLElement).classList.contains('kmw-key')) {
|
|
return getKeyFrom(t.firstChild);
|
|
}
|
|
}
|
|
} catch (ex) { }
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Identify the key nearest to the touch point if at the end of a key row,
|
|
* but return null more than about 0.6 key width from the nearest key.
|
|
*
|
|
* @param {Event} e touch event
|
|
* @param {Object} t HTML object at touch point
|
|
* @return {Object} nearest key to touch point
|
|
*
|
|
**/
|
|
findNearestKey(input: InputEventCoordinate, t: HTMLElement): KeyElement {
|
|
if (!input) {
|
|
return null;
|
|
}
|
|
|
|
// Get touch point on screen
|
|
var x = input.x;
|
|
|
|
// Get key-row beneath touch point
|
|
while (t && t.className !== undefined && t.className.indexOf('key-row') < 0) {
|
|
t = <HTMLElement>t.parentNode;
|
|
}
|
|
if (!t) {
|
|
return null;
|
|
}
|
|
|
|
// Find minimum distance from any key
|
|
var k, k0 = 0, dx, dxMax = 24, dxMin = 100000, x1, x2;
|
|
for (k = 0; k < t.childNodes.length; k++) {
|
|
let keySquare = t.childNodes[k] as HTMLElement; // gets the .kmw-key-square containing a key
|
|
// Find the actual key element.
|
|
let childNode = keySquare.firstChild ? keySquare.firstChild as HTMLElement : keySquare;
|
|
|
|
if (childNode.className !== undefined
|
|
&& (childNode.className.indexOf('key-hidden') >= 0
|
|
|| childNode.className.indexOf('key-blank') >= 0)) {
|
|
continue;
|
|
}
|
|
x1 = keySquare.offsetLeft;
|
|
x2 = x1 + keySquare.offsetWidth;
|
|
if (x >= x1 && x <= x2) {
|
|
// Within the key square
|
|
return <KeyElement>childNode;
|
|
}
|
|
dx = x1 - x;
|
|
if (dx >= 0 && dx < dxMin) {
|
|
// To right of key
|
|
k0 = k; dxMin = dx;
|
|
}
|
|
dx = x - x2;
|
|
if (dx >= 0 && dx < dxMin) {
|
|
// To left of key
|
|
k0 = k; dxMin = dx;
|
|
}
|
|
}
|
|
|
|
if (dxMin < 100000) {
|
|
t = <HTMLElement>t.childNodes[k0];
|
|
x1 = t.offsetLeft;
|
|
x2 = x1 + t.offsetWidth;
|
|
|
|
// Limit extended touch area to the larger of 0.6 of key width and 24 px
|
|
if (t.offsetWidth > 40) {
|
|
dxMax = 0.6 * t.offsetWidth;
|
|
}
|
|
|
|
if (((x1 - x) >= 0 && (x1 - x) < dxMax) || ((x - x2) >= 0 && (x - x2) < dxMax)) {
|
|
return <KeyElement>t.firstChild;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Repeat backspace as long as the backspace key is held down
|
|
**/
|
|
repeatDelete: () => void = function (this: VisualKeyboard) {
|
|
if (this.deleting) {
|
|
this.modelKeyClick(this.deleteKey);
|
|
this.deleting = window.setTimeout(this.repeatDelete, 100);
|
|
}
|
|
}.bind(this);
|
|
|
|
/**
|
|
* Cancels any active repeatDelete() timeouts, ensuring that
|
|
* repeating backspace operations are properly terminated.
|
|
*/
|
|
cancelDelete() {
|
|
// Clears the delete-repeating timeout.
|
|
if (this.deleting) {
|
|
window.clearTimeout(this.deleting);
|
|
}
|
|
this.deleting = 0;
|
|
}
|
|
//#endregion
|
|
|
|
modelKeyClick(e: osk.KeyElement, input?: InputEventCoordinate) {
|
|
let keyEvent = this.initKeyEvent(e, input);
|
|
|
|
// TODO: convert into an actual event, raised by the VisualKeyboard.
|
|
// Its code is intended to lie outside of the OSK-Core library/module.
|
|
PreProcessor.raiseKeyEvent(keyEvent);
|
|
}
|
|
|
|
initKeyEvent(e: osk.KeyElement, input?: InputEventCoordinate) {
|
|
// Turn off key highlighting (or preview)
|
|
this.highlightKey(e, false);
|
|
|
|
// Future note: we need to refactor osk.OSKKeySpec to instead be a 'tag field' for
|
|
// keyboards.ActiveKey. (Prob with generics, allowing the Web-only parts to
|
|
// be fully specified within the tag.)
|
|
//
|
|
// Would avoid the type shenanigans needed here because of our current type-abuse setup
|
|
// for key spec tracking.
|
|
let keySpec = (e['key'] ? e['key'].spec : null) as unknown as keyboards.ActiveKey;
|
|
if (!keySpec) {
|
|
console.error("OSK key with ID '" + e.id + "', keyID '" + e.keyId + "' missing needed specification");
|
|
return null;
|
|
}
|
|
|
|
// Return the event object.
|
|
return this.keyEventFromSpec(keySpec, input);
|
|
}
|
|
|
|
keyEventFromSpec(keySpec: keyboards.ActiveKey, input?: InputEventCoordinate) {
|
|
let core = com.keyman.singleton.core; // only singleton-based ref currently needed here.
|
|
|
|
// Start: mirrors _GetKeyEventProperties
|
|
|
|
// First check the virtual key, and process shift, control, alt or function keys
|
|
let Lkc = keySpec.constructKeyEvent(core.keyboardProcessor, this.device);
|
|
|
|
/* In case of "fun" edge cases caused by JS's single-threadedness & event processing queue.
|
|
*
|
|
* Should a touch occur on an OSK key during active JS execution that results in a change
|
|
* of the active keyboard, it's possible for an OSK key to be evaluated against an
|
|
* unexpected, non-matching keyboard - one that could even be `null`!
|
|
*
|
|
* So, we mark the keyboard backing the OSK as the 'correct' keyboard for this key.
|
|
*/
|
|
Lkc.srcKeyboard = this.layoutKeyboard;
|
|
|
|
// End - mirrors _GetKeyEventProperties
|
|
|
|
if (core.languageProcessor.isActive && input) {
|
|
Lkc.source = input;
|
|
Lkc.keyDistribution = this.getTouchProbabilities(input, keySpec);
|
|
}
|
|
|
|
// Return the event object.
|
|
return Lkc;
|
|
}
|
|
|
|
// cancel = function(e) {} //cancel event is never generated by iOS
|
|
|
|
/**
|
|
* Function _UpdateVKShiftStyle
|
|
* Scope Private
|
|
* @param {string=} layerId
|
|
* Description Updates the OSK's visual style for any toggled state keys
|
|
*/
|
|
_UpdateVKShiftStyle(layerId?: string) {
|
|
var i;
|
|
let core = com.keyman.singleton.core;
|
|
|
|
if (!layerId) {
|
|
layerId = this.layerId;
|
|
}
|
|
|
|
const layer = this.layerGroup.layers[layerId];
|
|
if (!layer) {
|
|
return;
|
|
}
|
|
|
|
// So... through KMW 14, we actually never tracked the capsKey, numKey, and scrollKey
|
|
// properly for keyboard-defined layouts - only _default_, desktop-style layouts.
|
|
//
|
|
// We _could_ remedy this, but then... touch keyboards like khmer_angkor actually
|
|
// repurpose certain state keys, and in an inconsistent manner at that.
|
|
// Considering the potential complexity of touch layouts, with multiple possible
|
|
// layer-shift keys, it's likely best to just leave things as they are for now.
|
|
if (!core.activeKeyboard?.usesDesktopLayoutOnDevice(this.device)) {
|
|
return;
|
|
}
|
|
|
|
// Set the on/off state of any visible state keys.
|
|
const states = ['K_CAPS', 'K_NUMLOCK', 'K_SCROLL'];
|
|
const keys = [layer.capsKey, layer.numKey, layer.scrollKey];
|
|
|
|
for (i = 0; i < keys.length; i++) {
|
|
// Skip any keys not in the OSK!
|
|
if (keys[i] == null) {
|
|
continue;
|
|
}
|
|
|
|
keys[i].setToggleState(core.keyboardProcessor.stateKeys[states[i]]);
|
|
}
|
|
}
|
|
|
|
clearPopup() {
|
|
// Remove the displayed subkey array, if any, and cancel popup request
|
|
if (this.subkeyGesture) {
|
|
this.subkeyGesture.clear();
|
|
this.subkeyGesture = null;
|
|
}
|
|
|
|
if (this.pendingSubkey) {
|
|
this.pendingSubkey.cancel();
|
|
this.pendingSubkey = null;
|
|
}
|
|
}
|
|
|
|
//#endregion
|
|
|
|
/**
|
|
* Indicate the current language and keyboard on the space bar
|
|
**/
|
|
showLanguage() {
|
|
let keyman = com.keyman.singleton;
|
|
|
|
let displayName: string = undefined;
|
|
let activeStub = keyman.keyboardManager.activeStub;
|
|
|
|
if (activeStub) {
|
|
if (activeStub['displayName'] != null) {
|
|
displayName = activeStub['displayName'];
|
|
} else {
|
|
let
|
|
lgName: string = activeStub['KL'],
|
|
kbdName: string = activeStub['KN'];
|
|
kbdName = kbdName.replace(/\s*keyboard\s*/i, '');
|
|
switch (keyman.options['spacebarText']) {
|
|
case SpacebarText.KEYBOARD:
|
|
displayName = kbdName;
|
|
break;
|
|
case SpacebarText.LANGUAGE:
|
|
displayName = lgName;
|
|
break;
|
|
case SpacebarText.LANGUAGE_KEYBOARD:
|
|
displayName = (kbdName == lgName) ? lgName : lgName + ' - ' + kbdName;
|
|
break;
|
|
case SpacebarText.BLANK:
|
|
displayName = '';
|
|
break;
|
|
default:
|
|
displayName = kbdName;
|
|
}
|
|
}
|
|
} else {
|
|
displayName = '(System keyboard)';
|
|
}
|
|
|
|
try {
|
|
var t = <HTMLElement>this.spaceBar.key.label;
|
|
let tParent = <HTMLElement>t.parentNode;
|
|
if (typeof (tParent.className) == 'undefined' || tParent.className == '') {
|
|
tParent.className = 'kmw-spacebar';
|
|
} else if (tParent.className.indexOf('kmw-spacebar') == -1) {
|
|
tParent.className += ' kmw-spacebar';
|
|
}
|
|
|
|
if (t.className != 'kmw-spacebar-caption') {
|
|
t.className = 'kmw-spacebar-caption';
|
|
}
|
|
|
|
// It sounds redundant, but this dramatically cuts down on browser DOM processing;
|
|
// but sometimes innerText is reported empty when it actually isn't, so set it
|
|
// anyway in that case (Safari, iOS 14.4)
|
|
if (t.innerText != displayName || displayName == '') {
|
|
t.innerText = displayName;
|
|
}
|
|
}
|
|
catch (ex) { }
|
|
}
|
|
|
|
/**
|
|
* Add or remove a class from a keyboard key (when touched or clicked)
|
|
* or add a key preview for phone devices
|
|
*
|
|
* @param {Object} key key affected
|
|
* @param {boolean} on add or remove highlighting
|
|
**/
|
|
highlightKey(key: KeyElement, on: boolean) {
|
|
// Do not change element class unless a key
|
|
if (!key || !key.key || (key.className == '') || (key.className.indexOf('kmw-key-row') >= 0)) return;
|
|
|
|
// For phones, use key preview rather than highlighting the key,
|
|
var usePreview = (this.keytip != null) && key.key.allowsKeyTip();
|
|
|
|
if (usePreview) {
|
|
this.showKeyTip(key, on);
|
|
} else {
|
|
// No key tip should be shown. In some cases (e.g. multitap), we
|
|
// may still have a tip visible so let's always hide in that case
|
|
this.showKeyTip(null, false);
|
|
key.key.highlight(on);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Use of `getComputedStyle` is ideal, but in many of our use cases its preconditions are not met.
|
|
* This function allows us to calculate the font size in those situations.
|
|
*/
|
|
getKeyEmFontSize(): number {
|
|
if (!this.fontSize) {
|
|
return 0;
|
|
}
|
|
|
|
if (this.device.formFactor == 'desktop') {
|
|
let keySquareScale = 0.8; // Set in kmwosk.css, is relative.
|
|
return this.fontSize.scaledBy(keySquareScale).val;
|
|
} else {
|
|
let emSizeStr = getComputedStyle(document.body).fontSize;
|
|
let emSize = getFontSizeStyle(emSizeStr).val;
|
|
|
|
var emScale = 1;
|
|
if (!this.isStatic) {
|
|
// Double-check against the font scaling applied to the _Box element.
|
|
if (this.fontSize.absolute) {
|
|
return this.fontSize.val;
|
|
} else {
|
|
emScale = this.fontSize.val;
|
|
}
|
|
}
|
|
return emSize * emScale;
|
|
}
|
|
}
|
|
|
|
updateState() {
|
|
// May happen for desktop-oriented keyboards that neglect to specify a touch layout.
|
|
// See `test_chirality.js` from the unit-test keyboard suite, which tests keystrokes
|
|
// using modifiers that lack corresponding visual-layout representation.
|
|
if (!this.currentLayer) {
|
|
return;
|
|
}
|
|
|
|
var n, b = this.kbdDiv.childNodes[0].childNodes;
|
|
this.nextLayer = this.layerId;
|
|
|
|
if (this.currentLayer.nextlayer) {
|
|
this.nextLayer = this.currentLayer.nextlayer;
|
|
}
|
|
|
|
for (n = 0; n < b.length; n++) {
|
|
let layerElement = <HTMLDivElement>b[n];
|
|
if (layerElement['layer'] == this.layerId) {
|
|
layerElement.style.display = 'block';
|
|
//b[n].style.visibility='visible';
|
|
|
|
// Most functions that call this one often indicate a change in modifier
|
|
// or state key state. Keep it updated!
|
|
this._UpdateVKShiftStyle();
|
|
} else {
|
|
layerElement.style.display = 'none';
|
|
//layerElement.style.visibility='hidden';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Used to refresh the VisualKeyboard's geometric layout and key sizes
|
|
* when needed.
|
|
*/
|
|
refreshLayout() {
|
|
let keyman = com.keyman.singleton;
|
|
let device = this.device;
|
|
|
|
var fs = 1.0;
|
|
// TODO: Logically, this should be needed for Android, too - may need to be changed for the next version!
|
|
if (device.OS == utils.OperatingSystem.iOS && !keyman.isEmbedded) {
|
|
fs = fs / keyman.util.getViewportScale();
|
|
}
|
|
|
|
let paddedHeight: number;
|
|
if (this.height) {
|
|
paddedHeight = this.computedAdjustedOskHeight(this.height);
|
|
}
|
|
|
|
let b = this.layerGroup.element as HTMLElement;
|
|
let gs = this.kbdDiv.style;
|
|
let bs = b.style;
|
|
if (this.usesFixedHeightScaling && this.height) {
|
|
// Sets the layer group to the correct height.
|
|
gs.height = gs.maxHeight = this.height + 'px';
|
|
}
|
|
|
|
// The font-scaling applied on the layer group.
|
|
gs.fontSize = this.fontSize.styleString;
|
|
bs.fontSize = ParsedLengthStyle.forScalar(fs).styleString;
|
|
|
|
// NEW CODE ------
|
|
|
|
// Step 1: have the necessary conditions been met?
|
|
const fixedSize = this.width && this.height;
|
|
const computedStyle = getComputedStyle(this.kbdDiv);
|
|
const isInDOM = computedStyle.height != '' && computedStyle.height != 'auto';
|
|
|
|
// Step 2: determine basic layout geometry
|
|
if (fixedSize) {
|
|
this._computedWidth = this.width;
|
|
this._computedHeight = this.height;
|
|
} else if (isInDOM) {
|
|
this._computedWidth = parseInt(computedStyle.width, 10);
|
|
if (!this._computedWidth) {
|
|
// For touch keyboards, the width _was_ specified on the layer group,
|
|
// not the root element (`kbdDiv`).
|
|
const groupStyle = getComputedStyle(this.kbdDiv.firstElementChild);
|
|
this._computedWidth = parseInt(groupStyle.width, 10);
|
|
}
|
|
this._computedHeight = parseInt(computedStyle.height, 10);
|
|
} else {
|
|
// Cannot perform layout operations!
|
|
return;
|
|
}
|
|
|
|
// Step 3: perform layout operations. (Handled by 'old code' section below.)
|
|
|
|
// END NEW CODE -----------
|
|
|
|
// Needs the refreshed layout info to work correctly.
|
|
if(this.currentLayer) {
|
|
this.currentLayer.refreshLayout(this, this._computedHeight - this.getVerticalLayerGroupPadding());
|
|
}
|
|
}
|
|
|
|
private getVerticalLayerGroupPadding(): number {
|
|
// For touch-based OSK layouts, kmwosk.css may include top & bottom padding on the layer-group element.
|
|
const computedGroupStyle = getComputedStyle(this.layerGroup.element);
|
|
return parseInt(computedGroupStyle.paddingTop, 10) + parseInt(computedGroupStyle.paddingBottom, 10);
|
|
}
|
|
|
|
/*private*/ computedAdjustedOskHeight(allottedHeight: number): number {
|
|
if (!this.layerGroup) {
|
|
return allottedHeight;
|
|
}
|
|
|
|
const layers = this.layerGroup.layers;
|
|
let oskHeight = 0;
|
|
|
|
// In case the keyboard's layers have differing row counts, we check them all for the maximum needed oskHeight.
|
|
for (const layerID in layers) {
|
|
const layer = layers[layerID];
|
|
let nRows = layer.rows.length;
|
|
let rowHeight = Math.floor(allottedHeight / (nRows == 0 ? 1 : nRows));
|
|
let layerHeight = nRows * rowHeight;
|
|
|
|
if (layerHeight > oskHeight) {
|
|
oskHeight = layerHeight;
|
|
}
|
|
}
|
|
|
|
// This isn't set anywhere else; it's a legacy part of the original methods.
|
|
const oskPad = 0;
|
|
let oskPaddedHeight = oskHeight + oskPad;
|
|
|
|
return oskPaddedHeight;
|
|
}
|
|
|
|
/**
|
|
* Append a style sheet for the current keyboard if needed for specifying an embedded font
|
|
* or to re-apply the default element font
|
|
*
|
|
**/
|
|
appendStyleSheet() {
|
|
let keymanweb = com.keyman.singleton;
|
|
let util = keymanweb.util;
|
|
|
|
var activeKeyboard = keymanweb.core.activeKeyboard;
|
|
var activeStub: com.keyman.keyboards.KeyboardStub = keymanweb.keyboardManager.activeStub;
|
|
|
|
// Do not do anything if a null stub
|
|
if (activeStub == null) {
|
|
return;
|
|
}
|
|
|
|
// First remove any existing keyboard style sheet
|
|
if (this.styleSheet) {
|
|
util.removeStyleSheet(this.styleSheet);
|
|
}
|
|
|
|
var i, kfd = activeStub['KFont'], ofd = activeStub['KOskFont'];
|
|
|
|
// Add style sheets for embedded fonts if necessary (each font-face style will only be added once)
|
|
util.addFontFaceStyleSheet(kfd);
|
|
util.addFontFaceStyleSheet(ofd);
|
|
|
|
// Build the style string and append (or replace) the font style sheet
|
|
// Note: Some browsers do not download the font-face font until it is applied,
|
|
// so must apply style before testing for font availability
|
|
// Extended to allow keyboard-specific custom styles for Build 360
|
|
var customStyle = this.addFontStyle(kfd, ofd);
|
|
if (activeKeyboard != null && typeof (activeKeyboard.oskStyling) == 'string') // KMEW-129
|
|
customStyle = customStyle + activeKeyboard.oskStyling;
|
|
|
|
this.styleSheet = util.addStyleSheet(customStyle); //Build 360
|
|
}
|
|
|
|
/**
|
|
* Add or replace the style sheet used to set the font for input elements and OSK
|
|
*
|
|
* @param {Object} kfd KFont font descriptor
|
|
* @param {Object} ofd OSK font descriptor (if any)
|
|
* @return {string}
|
|
*
|
|
**/
|
|
addFontStyle(kfd, ofd): string {
|
|
let keymanweb = com.keyman.singleton;
|
|
|
|
// Get name of font to be applied
|
|
var fn = keymanweb.baseFont;
|
|
if (typeof (kfd) != 'undefined' && typeof (kfd['family']) != 'undefined') {
|
|
fn = kfd['family'];
|
|
}
|
|
|
|
// Unquote font name in base font (if quoted)
|
|
fn = fn.replace(/\u0022/g, '');
|
|
|
|
// Set font family chain for mapped elements and remove any double quotes
|
|
var rx = new RegExp('\\s?' + fn + ',?'), ff = keymanweb.appliedFont.replace(/\u0022/g, '');
|
|
|
|
// Remove base font name from chain if present
|
|
ff = ff.replace(rx, '');
|
|
ff = ff.replace(/,$/, '');
|
|
|
|
// Then replace it at the head of the chain
|
|
if (ff == '') {
|
|
ff = fn;
|
|
} else {
|
|
ff = fn + ',' + ff;
|
|
}
|
|
|
|
// Re-insert quotes around individual font names
|
|
ff = '"' + ff.replace(/\,\s?/g, '","') + '"';
|
|
|
|
// Add to the stylesheet, quoted, and with !important to override any explicit style
|
|
var s = '.keymanweb-font{\nfont-family:' + ff + ' !important;\n}\n';
|
|
|
|
// Set font family for OSK text
|
|
if (typeof (ofd) != 'undefined') {
|
|
s = s + '.kmw-key-text{\nfont-family:"' + ofd['family'].replace(/\u0022/g, '').replace(/,/g, '","') + '";\n}\n';
|
|
} else if (typeof (kfd) != 'undefined') {
|
|
s = s + '.kmw-key-text{\nfont-family:"' + kfd['family'].replace(/\u0022/g, '').replace(/,/g, '","') + '";\n}\n';
|
|
}
|
|
|
|
// Store the current font chain (with quote-delimited font names)
|
|
keymanweb.appliedFont = ff;
|
|
|
|
// Return the style string
|
|
return s;
|
|
}
|
|
|
|
/**
|
|
* Create copy of the OSK that can be used for embedding in documentation or help
|
|
* The currently active keyboard will be returned if PInternalName is null
|
|
*
|
|
* @param {Object} PKbd the keyboard object to be displayed
|
|
* @param {string=} argFormFactor layout form factor, defaulting to 'desktop'
|
|
* @param {(string|number)=} argLayerId name or index of layer to show, defaulting to 'default'
|
|
* @param {number} height Target height for the rendered keyboard
|
|
* (currently required for legacy reasons)
|
|
* @return {Object} DIV object with filled keyboard layer content
|
|
*/
|
|
static buildDocumentationKeyboard(PKbd: com.keyman.keyboards.Keyboard, argFormFactor, argLayerId, height: number): HTMLElement { // I777
|
|
if (!PKbd) {
|
|
return null;
|
|
}
|
|
|
|
var formFactor = (typeof (argFormFactor) == 'undefined' ? 'desktop' : argFormFactor),
|
|
layerId = (typeof (argLayerId) == 'undefined' ? 'default' : argLayerId),
|
|
device = new Device();
|
|
|
|
// Device emulation for target documentation.
|
|
device.formFactor = formFactor;
|
|
if (formFactor != 'desktop') {
|
|
device.OS = 'iOS';
|
|
device.touchable = true;
|
|
} else {
|
|
device.OS = 'windows';
|
|
device.touchable = false;
|
|
}
|
|
|
|
let layout = PKbd.layout(formFactor);
|
|
|
|
let kbdObj = new VisualKeyboard(PKbd, device.coreSpec, device.coreSpec, true);
|
|
|
|
// The 'documentation' format uses the base element's child as the actual display base.
|
|
// Since there's no backing kmw-osk-frame, we do need the static-class kmw-osk-inner-frame
|
|
// to perform background styling on our behalf. We'll trust the actual, live keyboard rules
|
|
// for the other elements, which in turn needs the non-static variant of the CSS rules.
|
|
kbdObj.layerGroup.element.className = kbdObj.kbdDiv.className + ' ' + device.formFactor
|
|
+ '-static ' + device.OS.toLowerCase();
|
|
|
|
let kbd = kbdObj.kbdDiv.childNodes[0] as HTMLDivElement; // Gets the layer group.
|
|
|
|
// Select the layer to display, and adjust sizes
|
|
if (layout != null) {
|
|
kbdObj.layerId = layerId;
|
|
kbdObj.updateState();
|
|
// This still feels fairly hacky... but something IS needed to constrain the height.
|
|
// There are plans to address related concerns through some of the later aspects of
|
|
// the Web OSK-Core design.
|
|
kbdObj.setSize(800, height); // Probably need something for width, too, rather than
|
|
// assuming 100%.
|
|
kbdObj.refreshLayout(); // Necessary for the row heights to be properly set!
|
|
// Relocates the font size definition from the main VisualKeyboard wrapper, since we don't return the whole thing.
|
|
kbd.style.fontSize = kbdObj.kbdDiv.style.fontSize;
|
|
kbd.style.height = kbdObj.kbdDiv.style.height;
|
|
kbd.style.maxHeight = kbdObj.kbdDiv.style.maxHeight;
|
|
} else {
|
|
kbd.innerHTML = "<p style='color:#c40; font-size:0.5em;margin:10px;'>No " + formFactor + " layout is defined for " + PKbd.name + ".</p>";
|
|
}
|
|
// Add a faint border
|
|
kbd.style.border = '1px solid #ccc';
|
|
|
|
// Once the element is inserted into the DOM, refresh the layout so that proper text scaling may apply.
|
|
const refreshInterval = window.setInterval(function () {
|
|
let computedStyle = getComputedStyle(kbd);
|
|
if (computedStyle.fontSize) {
|
|
if (kbd.style.fontSize) {
|
|
// Preserve the new setting (provided by CSS)
|
|
kbdObj.fontSize = new ParsedLengthStyle(kbd.style.fontSize);
|
|
}
|
|
kbdObj.refreshLayout();
|
|
window.clearInterval(refreshInterval);
|
|
}
|
|
}, 10);
|
|
|
|
return kbd;
|
|
}
|
|
|
|
onHide() {
|
|
// Remove highlighting from hide keyboard key, if applied
|
|
if (this.hkKey) {
|
|
this.highlightKey(this.hkKey, false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts an implementation-specific longpress gesture. Separately implemented for
|
|
* in-browser and embedded modes.
|
|
* @param key The base key of the longpress.
|
|
* @returns
|
|
*/
|
|
startLongpress(key: KeyElement): PendingGesture {
|
|
let _this = this;
|
|
|
|
// First-level object/Promise: will produce a subkey popup when the longpress gesture completes.
|
|
// 'Returns' a second-level object/Promise: resolves when a subkey is selected or is cancelled.
|
|
let pendingLongpress = new browser.PendingLongpress(this, key);
|
|
pendingLongpress.promise.then(function (subkeyPopup) {
|
|
// In-browser-specific handling.
|
|
if (subkeyPopup) {
|
|
// Append the touch-hold (subkey) array to the OSK
|
|
let keyman = com.keyman.singleton;
|
|
keyman.osk._Box.appendChild(subkeyPopup.element);
|
|
keyman.osk._Box.appendChild(subkeyPopup.shim);
|
|
|
|
// Must be placed after its `.element` has been inserted into the DOM.
|
|
subkeyPopup.reposition(_this);
|
|
}
|
|
});
|
|
|
|
return pendingLongpress;
|
|
}
|
|
|
|
/**
|
|
* Initializes all supported gestures given a base key and the triggering touch coordinates.
|
|
* @param key The gesture's base key
|
|
* @param touch The starting touch coordinates for the gesture
|
|
* @returns
|
|
*/
|
|
initGestures(key: KeyElement, input: InputEventCoordinate) {
|
|
|
|
if (this.pendingMultiTap) {
|
|
switch (this.pendingMultiTap.incrementTouch(key)) {
|
|
case PendingMultiTapState.Cancelled:
|
|
this.pendingMultiTap = null;
|
|
break;
|
|
case PendingMultiTapState.Realized:
|
|
// Don't initialize any other gestures if the
|
|
// multi tap is realized; we cleanup on touch
|
|
// release because we need to cancel the base
|
|
// key action
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!this.pendingMultiTap && PendingMultiTap.isValidTarget(this, key)) {
|
|
// We are only going to support double-tap on Shift
|
|
// in Keyman 15, so we pass in the constant count = 2
|
|
this.pendingMultiTap = new PendingMultiTap(this, key, 2);
|
|
this.pendingMultiTap.timeout.then(() => {
|
|
this.pendingMultiTap = null;
|
|
});
|
|
}
|
|
|
|
|
|
if (key['subKeys']) {
|
|
let _this = this;
|
|
|
|
let pendingLongpress = this.startLongpress(key);
|
|
if (pendingLongpress == null) {
|
|
return;
|
|
}
|
|
this.pendingSubkey = pendingLongpress;
|
|
|
|
pendingLongpress.promise.then(function (subkeyPopup) {
|
|
if (_this.pendingSubkey == pendingLongpress) {
|
|
_this.pendingSubkey = null;
|
|
}
|
|
|
|
if (subkeyPopup) {
|
|
// Clear key preview if any
|
|
_this.showKeyTip(null, false);
|
|
|
|
_this.subkeyGesture = subkeyPopup;
|
|
subkeyPopup.promise.then(function (keyEvent: text.KeyEvent) {
|
|
// Allow active cancellation, even if the source should allow passive.
|
|
// It's an easy and cheap null guard.
|
|
if (keyEvent) {
|
|
PreProcessor.raiseKeyEvent(keyEvent);
|
|
}
|
|
_this.clearPopup();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates all currently-pending and activated gestures.
|
|
*
|
|
* @param currentKey The key currently underneath the most recent touch coordinate
|
|
* @param previousKey The previously-selected key
|
|
* @param input The current mouse or touch coordinate for the gesture
|
|
* @returns true if should fully capture input, false if input should 'fall through'.
|
|
*/
|
|
updateGestures(currentKey: KeyElement, previousKey: KeyElement, input: InputEventCoordinate): boolean {
|
|
let key0 = previousKey;
|
|
let key1 = currentKey;
|
|
|
|
if(!currentKey && this.pendingMultiTap) {
|
|
this.pendingMultiTap.cancel();
|
|
this.pendingMultiTap = null;
|
|
}
|
|
|
|
// Clear previous key highlighting, allow subkey controller to highlight as appropriate.
|
|
if (this.subkeyGesture) {
|
|
if (key0) {
|
|
key0.key.highlight(false);
|
|
}
|
|
this.subkeyGesture.updateTouch(input);
|
|
|
|
this.keyPending = null;
|
|
this.touchPending = null;
|
|
|
|
return true;
|
|
}
|
|
|
|
this.currentTarget = null;
|
|
|
|
// If popup is visible, need to move over popup, not over main keyboard
|
|
// Could be turned into a browser-longpress specific implementation within browser.PendingLongpress?
|
|
if (key1 && key1['subKeys'] != null && this.initTouchCoord) {
|
|
if(this.pendingSubkey && this.pendingSubkey instanceof browser.PendingLongpress) {
|
|
// Show popup keys immediately if touch moved up towards key array (KMEW-100, Build 353)
|
|
if (this.initTouchCoord.y - input.y > this.getLongpressFlickThreshold()) {
|
|
this.pendingSubkey.resolve();
|
|
}
|
|
}
|
|
}
|
|
|
|
// If there is an active popup menu (which can occur from the previous block),
|
|
// a subkey popup exists; do not allow base key output.
|
|
if (this.subkeyGesture || this.pendingSubkey) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private getLongpressFlickThreshold(): number {
|
|
const rowHeight = this.currentLayer.rowHeight;
|
|
|
|
// If larger than 5 (and it likely is), new threshold = 1/4 the std. key height.
|
|
const proportionalThreshold = rowHeight / 4;
|
|
|
|
// 5 - the longpress-flick triggering threshold before 15.0.
|
|
return Math.max(proportionalThreshold, 5);
|
|
}
|
|
|
|
optionKey(e: KeyElement, keyName: string, keyDown: boolean) {
|
|
let keyman = com.keyman.singleton;
|
|
let oskManager = keyman.osk;
|
|
if (keyDown) {
|
|
if (keyName.indexOf('K_LOPT') >= 0) {
|
|
oskManager.showLanguageMenu();
|
|
} else if (keyName.indexOf('K_ROPT') >= 0) {
|
|
keyman.uiManager.setActivatingUI(false);
|
|
oskManager.startHide(true);
|
|
keyman.domManager.lastActiveElement = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Add (or remove) the keytip preview (if KeymanWeb on a phone device)
|
|
*
|
|
* @param {Object} key HTML key element
|
|
* @param {boolean} on show or hide
|
|
*/
|
|
showKeyTip(key: KeyElement, on: boolean) {
|
|
var tip = this.keytip;
|
|
|
|
if (tip == null) {
|
|
return;
|
|
}
|
|
|
|
let sk = this.subkeyGesture;
|
|
let popup = (sk && sk.isVisible());
|
|
|
|
// If popup keys are active, do not show the key tip.
|
|
on = popup ? false : on;
|
|
|
|
tip.show(key, on, this);
|
|
};
|
|
|
|
/**
|
|
* Create a key preview element for phone devices
|
|
*/
|
|
createKeyTip() {
|
|
let keyman = com.keyman.singleton;
|
|
|
|
if (this.device.formFactor == 'phone') {
|
|
if (this.keytip == null) {
|
|
// For now, should only be true (in production) when keyman.isEmbedded == true.
|
|
let constrainPopup = keyman.isEmbedded;
|
|
this.keytip = new browser.KeyTip(constrainPopup);
|
|
}
|
|
|
|
// Always append to _Box (since cleared during OSK Load)
|
|
if (this.keytip && this.keytip.element) {
|
|
keyman.osk._Box.appendChild(this.keytip.element);
|
|
}
|
|
}
|
|
};
|
|
|
|
shutdown() {
|
|
let keyman = com.keyman.singleton;
|
|
|
|
// Prevents style-sheet pollution from multiple keyboard swaps.
|
|
if (this.styleSheet) {
|
|
keyman.util.removeStyleSheet(this.styleSheet);
|
|
}
|
|
|
|
if(this.inputEngine) {
|
|
this.inputEngine.unregisterEventHandlers();
|
|
}
|
|
|
|
if(this.deleting) {
|
|
window.clearTimeout(this.deleting);
|
|
}
|
|
|
|
this.keyPending = null;
|
|
this.touchPending = null;
|
|
|
|
this.keytip?.show(null, false, this);
|
|
this.subkeyGesture?.clear();
|
|
this.pendingMultiTap?.cancel();
|
|
this.pendingSubkey?.cancel();
|
|
}
|
|
}
|
|
}
|