spiegel-keyman/web/source/osk/embedded/globeHint.ts
2022-11-18 10:45:02 +07:00

188 lines
No EOL
6.5 KiB
TypeScript

///<reference path="singleInputCapture.ts" />
namespace com.keyman.osk.embedded {
/**
* The GlobeHint is a specialized key tip that is presented above
* the globe key on first use of Keyman, to show the user how to
* switch input methods. The label is localizable text.
*/
export class GlobeHint implements osk.GlobeHint {
public readonly element: HTMLDivElement;
public state: boolean = false;
// -----
// | | <-- tip
// | x | <-- label
// |_ _|
// \ / <-- cap
// v
private readonly cap: HTMLDivElement;
private readonly tip: HTMLDivElement;
private readonly label: HTMLSpanElement;
private readonly vkbd: VisualKeyboard;
private docInputCatch?: SingleInputCapture;
private tipInputTrap?: SingleInputCapture;
private delayedDismissTimerHandle: number;
constructor(vkbd: VisualKeyboard) {
this.vkbd = vkbd;
let tipElement = this.element=document.createElement('div');
tipElement.className = 'kmw-globehint';
tipElement.id = 'kmw-globehint';
// The following style is critical, so do not rely on external CSS
tipElement.style.pointerEvents = 'none';
tipElement.style.display = 'none';
tipElement.appendChild(this.tip = document.createElement('div'));
tipElement.appendChild(this.cap = document.createElement('div'));
this.tip.appendChild(this.label = document.createElement('span'));
this.tip.className = 'kmw-globehint-tip';
this.cap.className = 'kmw-globehint-cap';
this.label.className = 'kmw-globehint-label';
this.text = "Tap here to change keyboard.";
}
get text(): string {
return this.label.innerText;
}
set text(val: string) {
this.label.innerText = val;
}
private _show(key: KeyElement, on: boolean) {
let keyman = com.keyman.singleton;
// Create and display the preview
// If !key.offsetParent, the OSK is probably hidden. Either way, it's a half-
// decent null-guard check.
if(!on || !key.offsetParent) {
this.element.style.display = 'none';
} else {
// The key element is positioned relative to its key-square, which is,
// in turn, relative to its row. Rows take 100% width, so this is sufficient.
//
let rowElement = (key.key as OSKBaseKey).row.element;
// May need adjustment for borders if ever enabled for the desktop form-factor target.
let rkey = key.getClientRects()[0], rrow = rowElement.getClientRects()[0];
let xLeft = rkey.left - rrow.left,
xWidth = rkey.width,
xHeight = rkey.height;
let center = rkey.left + rkey.width/2;
let bubbleStyle = this.element.style;
// Roughly matches how the subkey positioning is set.
const _Box = this.vkbd.element.parentNode as HTMLDivElement;
const _BoxRect = _Box.getBoundingClientRect();
const keyRect = key.getBoundingClientRect();
let y = (keyRect.bottom - _BoxRect.top + 1);
// Width dimensions must be set explicitly to prevent clipping.
// We'll assume that the globe key is always positioned on the bottom row.
let oskBaseFontSize = new ParsedLengthStyle(keyman.osk.baseFontSize);
let bubbleWidth = Math.ceil(xWidth * 3);
bubbleStyle.bottom = Math.floor(keyman.osk.computedHeight - y) + 'px';
// CSS already defines transform: translateX(-50%) - this centers the element.
bubbleStyle.left = center + 'px';
this.tip.style.bottom = (rrow.height - 1) + 'px';
this.tip.style.width = bubbleWidth + 'px';
this.tip.style.pointerEvents = on ? 'auto' : 'none';
// Adjust shape if at edges
// how much width lies outside the range of the base key, per side
const xOverflow = (bubbleWidth - xWidth) / 2;
const capWidth = xWidth * 2 / 3;
const leftEdgeMargin = xLeft;
const rightEdgeMargin = window.innerWidth - (xLeft + xWidth);
if(leftEdgeMargin < xOverflow) {
// The overflow would be clipped by the left edge when centered,
// so we "offset" this part to keep it within screen bounds.
this.tip.style.transform = 'translateX(' + (xOverflow - leftEdgeMargin + 1) + 'px)';
} else if(rightEdgeMargin < xOverflow) {
// The overflow would be clipped by the right edge when centered,
// so we "offset" this part to keep it within screen bounds.
this.tip.style.transform = 'translateX(-' + (xOverflow - rightEdgeMargin + 1) + 'px)';
}
if(!oskBaseFontSize.absolute) {
this.element.style.fontSize = keyman.osk.baseFontSize;
}
let capHeight = xHeight / 3;
this.cap.style.bottom = (rrow.height - capHeight) + 'px';
this.cap.style.width = '0px';
this.cap.style.height = '0px';
this.cap.style.borderLeftWidth = (capWidth / 2) + 'px';
this.cap.style.borderRightWidth = (capWidth / 2) + 'px';
this.cap.style.borderTopWidth = capHeight + 'px';
bubbleStyle.display = 'block';
}
// Save the key preview state
this.state = on;
}
private clearCatchers() {
// disable input-capture system
if(this.docInputCatch) {
this.docInputCatch.cancel();
this.docInputCatch = null;
}
if(this.tipInputTrap) {
this.tipInputTrap.cancel();
this.tipInputTrap = null;
}
}
public show(key: KeyElement, onDismiss?: () => void) {
this._show(key, true);
const captureHandler = () => {
// Prevent duplicated dismissal calls that'd otherwise result when the tip itself receives a touch.
window.clearTimeout(this.delayedDismissTimerHandle);
this.hide(key);
onDismiss();
};
if(onDismiss) {
// enable input-capture system
this.tipInputTrap = new SingleInputCapture(this.tip, true, captureHandler);
this.docInputCatch = new SingleInputCapture(document.body, false, () => {
this.delayedDismissTimerHandle = window.setTimeout(() => {
captureHandler();
}, 1); // we need to delay processing so that events directly on the tip get priority first!
});
} else {
// disable any previously-set input-captures as the incoming
// (thus, most-recent) call didn't request one.
this.clearCatchers();
}
}
public hide(key: KeyElement) {
this._show(key, false);
this.clearCatchers();
}
}
}