Merge pull request #9825 from keymanapp/feat/web/gesture-preview-host

feat(web): previewing gestures - common phone, tablet styling + flick animations 🐵
This commit is contained in:
Joshua Horton 2023-11-14 15:26:12 +07:00 • committed by GitHub
commit 1a7245e409
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 459 additions and 103 deletions

View file

@ -8,8 +8,8 @@ export class ViewportZoneSource implements RecognitionZoneSource {
return DOMRect.fromRect({
y: 0,
x: 0,
height: Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0),
width: Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0)
width: Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0),
height: Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0)
});
}
}

View file

@ -260,8 +260,12 @@ export class ActiveKeyBase {
// And now for generalized type validation. -----------------------------------------
// Object.entries does require Android 54... but we do polyfill within the Android app. Should be 'fine'.
for(const [key, value] of Object.entries(KeyTypesOfKeyMap)) {
// WARNING: Object.values and Object.entries is NOT polyfilled by es6-shim and thus
// is NOT available within the Android app in extremely early APIs.
// Object.entries requires Android 54.
for(const key of Object.keys(KeyTypesOfKeyMap)) {
const value = KeyTypesOfKeyMap[key as keyof typeof KeyTypesOfKeyMap];
switch(value) {
case 'subkeys':
const arr = rawKey[key] as LayoutSubKey[];

View file

@ -306,7 +306,10 @@ export default class KeymanEngine<
}
this._osk = value;
if(value) {
value.activeKeyboard = this.contextManager.activeKeyboard;
// Don't build an OSK if no keyboard is available yet; avoid the extra flash.
if(this.contextManager.activeKeyboard) {
value.activeKeyboard = this.contextManager.activeKeyboard;
}
value.on('keyevent', this.keyEventListener);
this.core.keyboardProcessor.layerStore.handler = value.layerChangeHandler;
}

View file

@ -2,12 +2,60 @@ import { type KeyElement } from '../../../keyElement.js';
import VisualKeyboard from '../../../visualKeyboard.js';
import { ActiveKey, ActiveKeyBase, ActiveSubKey, KeyDistribution } from '@keymanapp/keyboard-processor';
import { ConfigChangeClosure, CumulativePathStats, GestureRecognizerConfiguration, GestureSequence, PaddedZoneSource } from '@keymanapp/gesture-recognizer';
import { ConfigChangeClosure, CumulativePathStats, GestureRecognizerConfiguration, GestureSequence, GestureSource, InputSample, PaddedZoneSource } from '@keymanapp/gesture-recognizer';
import { GestureHandler } from '../gestureHandler.js';
import { distributionFromDistanceMaps } from '@keymanapp/input-processor';
import { DEFAULT_GESTURE_PARAMS, GestureParams } from '../specsForLayout.js';
import { GestureParams } from '../specsForLayout.js';
import { GesturePreviewHost } from '../../../keyboard-layout/gesturePreviewHost.js';
const OrderedFlickDirections = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'] as const;
export const OrderedFlickDirections = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'] as const;
const PI = Math.PI;
export const FlickNameCoordMap = (() => {
const map = new Map<typeof OrderedFlickDirections[number], [number, number]>();
const angleIncrement = PI / 4;
for(let i = 0; i < OrderedFlickDirections.length; i++) {
map.set(OrderedFlickDirections[i], [angleIncrement * i, 1]);
}
return map;
})();
export function buildFlickScroller(
baseSource: GestureSource<KeyElement>,
initialCoord: InputSample<KeyElement>,
previewHost: GesturePreviewHost,
gestureParams: GestureParams
): (coord: InputSample<KeyElement>) => void {
return (coord: InputSample<KeyElement>) => {
baseSource.path.on('step', (coord) => {
const deltaX = coord.targetX - initialCoord.targetX;
const deltaY = coord.targetY - initialCoord.targetY;
const sqDist = deltaX * deltaX + deltaY * deltaY;
/*
* Accomplishes two things:
* 1) Ensures the coordinates for flick-preview scrolling don't overshoot
* the preview key-cap
* 2) While allowing for _undershoot_ if "not quite there yet"
*/
let divisor = Math.sqrt(sqDist);
const FUDGE_FACTOR = 1.1;
const FULL_SCROLL_MAG = FUDGE_FACTOR * gestureParams.flick.triggerDist;
if(divisor < FULL_SCROLL_MAG) {
divisor = FULL_SCROLL_MAG;
}
const previewX = deltaX / divisor;
const previewY = deltaY / divisor;
previewHost?.scrollFlickPreview(previewX, previewY);
});
}
}
/**
* The maximum angle-difference, in radians, allowed before a potential flick
@ -40,17 +88,20 @@ export default class Flick implements GestureHandler {
configChanger: ConfigChangeClosure<KeyElement>,
vkbd: VisualKeyboard,
e: KeyElement,
gestureParams: GestureParams
gestureParams: GestureParams,
previewHost: GesturePreviewHost
) {
this.sequence = sequence;
this.gestureParams = gestureParams;
this.baseSpec = e.key.spec as ActiveKey;
sequence.on('complete', () => previewHost.cancel());
// May be worth a temporary alt config: global roaming, rather than auto-canceling.
this.baseKeyDistances = vkbd.getSimpleTapCorrectionDistances(sequence.stageReports[0].sources[0].path.stats.initialSample, this.baseSpec)
const baseSource = sequence.stageReports[0].sources[0].baseSource;
this.sequence.on('stage', (result) => {
const pathStats = baseSource.path.stats;
this.computedFlickDistribution = this.flickDistribution(pathStats);
@ -69,6 +120,12 @@ export default class Flick implements GestureHandler {
vkbd.raiseKeyEvent(keyEvent, null);
});
const baseCoord = baseSource.path.coords[0];
const flickScroller = buildFlickScroller(baseSource, baseCoord, previewHost, this.gestureParams);
flickScroller(baseSource.currentSample);
baseSource.path.on('step', flickScroller);
// Be sure to extend roaming bounds a bit more than usual for flicks, as they can be quick motions.
const altConfig = this.buildPopupRecognitionConfig(vkbd);
configChanger({
@ -85,16 +142,19 @@ export default class Flick implements GestureHandler {
const roamBounding = new PaddedZoneSource(vkbd.element, [
// top
basePadding * 2, // be extra-loose for the top!
// left, right
basePadding,
// bottom: ensure the recognition zone includes the row of the base key.
// basePadding is already negative, but bottomDistance isn't.
basePadding
]);
let safeBounds = vkbd.gestureEngine.config.safeBounds;
if(vkbd.isEmbedded) {
safeBounds = new PaddedZoneSource(safeBounds, [basePadding, 0, 0]);
}
return {
...vkbd.gestureEngine.config,
maxRoamingBounds: roamBounding
maxRoamingBounds: roamBounding,
safeBounds: safeBounds // if embedded, ensure top boundary extends outside the WebView!
}
}
@ -138,20 +198,12 @@ export default class Flick implements GestureHandler {
coord: [NaN, 0]
}];
const PI = Math.PI;
const angleIncrement = PI / 4;
for(let i = 0; i < OrderedFlickDirections.length; i++) {
const spec = flickSet[OrderedFlickDirections[i]] as ActiveSubKey;
if(spec) {
keys.push({
spec: spec,
// Greatest possible angle difference: Math.PI (180 degrees)
// So we'll scale the distance accordingly.
coord: [angleIncrement * i, 1]
});
}
}
keys = keys.concat(Object.keys(flickSet).map((dir: (typeof OrderedFlickDirections[number])) => {
return {
spec: flickSet[dir] as ActiveSubKey,
coord: FlickNameCoordMap.get(dir)
};
}));
const angle = pathStats.angle;
@ -167,7 +219,7 @@ export default class Flick implements GestureHandler {
const coord = entry.coord;
if(!isNaN(coord[0])) {
const angleDelta1 = angle - coord[0];
const angleDelta2 = 2*PI + coord[0] - angle; // because of angle wrap-around.
const angleDelta2 = 2 * PI + coord[0] - angle; // because of angle wrap-around.
// NOTE: max linear angle dist: PI. (Angles are between 0 and 2*PI.)
angleDist = Math.min(angleDelta1 * angleDelta1, angleDelta2 * angleDelta2);

View file

@ -2,6 +2,7 @@ import OSKBaseKey from '../../../keyboard-layout/oskBaseKey.js';
import { KeyElement } from '../../../keyElement.js';
import KeyTipInterface from '../../../keytip.interface.js';
import VisualKeyboard from '../../../visualKeyboard.js';
import { GesturePreviewHost } from '../../../keyboard-layout/gesturePreviewHost.js';
export default class KeyTip implements KeyTipInterface {
public readonly element: HTMLDivElement;
@ -10,7 +11,7 @@ export default class KeyTip implements KeyTipInterface {
// -----
// | | <-- tip
// | x | <-- label
// | x | <-- preview
// |_ _|
// | |
// | | <-- cap
@ -18,7 +19,8 @@ export default class KeyTip implements KeyTipInterface {
private readonly cap: HTMLDivElement;
private readonly tip: HTMLDivElement;
private readonly label: HTMLSpanElement;
private previewHost: GesturePreviewHost;
private preview: HTMLDivElement;
private readonly constrain: boolean;
@ -38,16 +40,15 @@ export default class KeyTip implements KeyTipInterface {
tipElement.appendChild(this.tip = document.createElement('div'));
tipElement.appendChild(this.cap = document.createElement('div'));
this.tip.appendChild(this.label = document.createElement('span'));
this.tip.appendChild(this.preview = document.createElement('div'));
this.tip.className = 'kmw-keytip-tip';
this.cap.className = 'kmw-keytip-cap';
this.label.className = 'kmw-keytip-label';
this.constrain = constrain;
}
show(key: KeyElement, on: boolean, vkbd: VisualKeyboard) {
show(key: KeyElement, on: boolean, vkbd: VisualKeyboard, previewHost: GesturePreviewHost) {
// Create and display the preview
// If !key.offsetParent, the OSK is probably hidden. Either way, it's a half-
// decent null-guard check.
@ -105,8 +106,6 @@ export default class KeyTip implements KeyTipInterface {
kts.fontSize = key.key.getIdealFontSize(vkbd, key.key.keyText, scaleStyle, true);
}
this.label.textContent = kc.textContent;
// Adjust shape if at edges
var xOverflow = (canvasWidth - xWidth) / 2;
if(xLeft < xOverflow) {
@ -141,8 +140,21 @@ export default class KeyTip implements KeyTipInterface {
}
kts.display = 'block';
const oldHost = this.preview;
this.previewHost = previewHost;
if(previewHost) {
this.preview = this.previewHost.element;
this.tip.replaceChild(this.preview, oldHost);
previewHost.setCancellationHandler(() => this.show(null, false, vkbd, null));
}
} else { // Hide the key preview
this.element.style.display = 'none';
this.previewHost = null;
const oldPreview = this.preview;
this.preview = document.createElement('div');
this.tip.replaceChild(this.preview, oldPreview);
}
// Save the key preview state

View file

@ -110,6 +110,8 @@ export default class Multitap implements GestureHandler {
}
keyEvent.keyDistribution = this.currentStageKeyDistribution(baseDistances);
// TODO for future: multitap previews.
// When _some_ multitap keys support layer-swapping but others don't,
// landing on a non-swap key should preserve the original layer... even
// if no such 'nextLayer' is specified by default.

View file

@ -157,12 +157,13 @@ export default class SubkeyPopup implements GestureHandler {
const subkeyStyle = this.subkeys[0].style;
const subkeyHeight = Number.parseInt(subkeyStyle.height, 10);
const basePadding = -0.666 * subkeyHeight; // extends bounds by the absolute value.
const topScalar = 3;
const bottomDistance = underlyingKeyBounding.bottom - baseBounding.bottom;
const roamBounding = new PaddedZoneSource(this.element, [
// top
basePadding * 2, // be extra-loose for the top!
basePadding * topScalar, // be extra-loose for the top!
// left, right
basePadding,
// bottom: ensure the recognition zone includes the row of the base key.
@ -174,13 +175,19 @@ export default class SubkeyPopup implements GestureHandler {
const topContainerBounding = topContainer.getBoundingClientRect();
// Uses the top boundary from `roamBounding` unless the OSK's main element has a more
// permissive top boundary.
const topPadding = Math.min(baseBounding.top + basePadding - topContainerBounding.top, 0);
const sustainBounding = new PaddedZoneSource(topContainer, [topPadding, 0, 0])
const topPadding = Math.min(baseBounding.top + basePadding * topScalar - topContainerBounding.top, 0);
const sustainBounding = new PaddedZoneSource(topContainer, [topPadding * topScalar, 0, 0]);
let safeBounds = vkbd.gestureEngine.config.safeBounds;
if(vkbd.isEmbedded) {
safeBounds = new PaddedZoneSource(safeBounds, [topPadding * topScalar, 0, 0]);
}
return {
targetRoot: this.element,
inputStartBounds: vkbd.element,
maxRoamingBounds: sustainBounding,
safeBounds: safeBounds, // if embedded, ensure top boundary extends outside the WebView!
itemIdentifier: (coord, target) => {
const roamingRect = roamBounding.getBoundingClientRect();

View file

@ -0,0 +1,128 @@
import { ActiveKey } from "@keymanapp/keyboard-processor";
import { KeyElement } from "../keyElement.js";
import { FlickNameCoordMap, OrderedFlickDirections } from "../input/gestures/browser/flick.js";
/**With edge lengths of 1, to keep flick-text invisible at the start, the
* hypotenuse for an inter-cardinal path is sqrt(2). To keep a perfect circle
* for all flicks, then, requires the straight-edge length for pure cardinal
* paths to match - sqrt(2).
*/
const FLICK_OVERFLOW_OFFSET = 1.4142;
export class GesturePreviewHost {
private readonly div: HTMLDivElement;
private readonly label: HTMLSpanElement;
private readonly previewImgContainer: HTMLDivElement;
private flickPreviews = new Map<string, HTMLDivElement>;
private hintLabel: HTMLDivElement = null;
private flickEdgeLength: number;
private onCancel: () => void;
get element(): HTMLDivElement {
return this.div;
}
constructor(key: KeyElement, isPhone: boolean, edgeLength: number) {
const keySpec = key.key.spec;
this.flickEdgeLength = edgeLength;
const base = this.div = document.createElement('div');
base.className = base.id = 'kmw-gesture-preview';
base.style.pointerEvents='none';
// We want this to be distinct from the base element so that we can scroll it;
// this matters greatly for doing flick things.
const previewImgContainer = this.previewImgContainer = document.createElement('div');
this.previewImgContainer.id = 'kmw-preview-img-container';
const label = this.label = document.createElement('span');
label.className='kmw-gesture-base-label kmw-key-text';
label.id = 'kmw-gesture-base-label';
previewImgContainer.appendChild(label);
// Re-use the text value from the base key's label.
label.textContent = key.key.label.textContent;
this.div.appendChild(this.previewImgContainer);
const width = Number.parseInt(getComputedStyle(this.div).width, 10) || this.flickEdgeLength;
const height = Number.parseInt(getComputedStyle(this.div).height, 10) || this.flickEdgeLength;
if(keySpec.flick) {
const flickSpec = keySpec.flick || {};
Object.keys(flickSpec).forEach((dir: typeof OrderedFlickDirections[number]) => {
const flickPreview = document.createElement('div');
flickPreview.className = 'kmw-flick-preview kmw-key-text';
flickPreview.textContent = flickSpec[dir].text;
const ps /* preview style */ = flickPreview.style;
// is in polar coords, origin toward north, clockwise.
const coords = FlickNameCoordMap.get(dir);
const x = -Math.sin(coords[0]); // Put 'e' flick at left
const y = Math.cos(coords[0]); // Put 'n' flick at bottom
ps.width = width + 'px';
ps.textAlign = 'center';
if(x < 0) {
ps.right = (-x * FLICK_OVERFLOW_OFFSET * edgeLength) + 'px';
} else if(x > 0) {
ps.left = ( x * FLICK_OVERFLOW_OFFSET * edgeLength) + 'px';
} else {
ps.left = '0px';
}
ps.height = height + 'px';
ps.lineHeight = height + 'px';
if(y < 0) {
ps.bottom = (-y * FLICK_OVERFLOW_OFFSET * edgeLength) + 'px';
} else if(y > 0) {
ps.top = ( y * FLICK_OVERFLOW_OFFSET * edgeLength) + 'px';
} else {
ps.top = '0px';
}
this.flickPreviews.set(dir, flickPreview);
previewImgContainer.appendChild(flickPreview);
});
}
}
public cancel() {
this.onCancel?.();
this.onCancel = null;
}
public setCancellationHandler(handler: () => void) {
this.onCancel = handler;
}
public scrollFlickPreview(x: number, y: number) {
const scrollStyle = this.previewImgContainer.style;
const edge = this.flickEdgeLength * FLICK_OVERFLOW_OFFSET;
scrollStyle.marginLeft = `${edge * x}px`;
scrollStyle.marginTop = `${edge * y}px`;
}
// These may not exist like this longterm.
public clearFlick() {
this.previewImgContainer.style.marginTop = '0px';
this.previewImgContainer.style.marginLeft = '0px';
this.previewImgContainer.classList.add('flick-clear');
}
private clearHint() {
this.hintLabel?.classList.add('hint-clear');
}
public clearAll() {
this.clearFlick();
this.clearHint();
}
}

View file

@ -6,10 +6,14 @@ import { KeyData, KeyElement, link } from '../keyElement.js';
import OSKRow from './oskRow.js';
import VisualKeyboard from '../visualKeyboard.js';
import { ParsedLengthStyle } from '../lengthStyle.js';
import { GesturePreviewHost } from './gesturePreviewHost.js';
export default class OSKBaseKey extends OSKKey {
private capLabel: HTMLDivElement;
private previewHost: GesturePreviewHost;
private preview: HTMLDivElement;
public readonly row: OSKRow;
constructor(spec: ActiveKey, layer: string, row: OSKRow) {
@ -122,6 +126,10 @@ export default class OSKBaseKey extends OSKKey {
// Add text to button and button to placeholder div
kDiv.appendChild(btn);
this.preview = document.createElement('div');
this.preview.style.display = 'none';
btn.appendChild(this.preview);
// The 'return value' of this process.
return this.square = kDiv;
}
@ -163,6 +171,25 @@ export default class OSKBaseKey extends OSKKey {
return skIcon;
}
public setPreview(previewHost: GesturePreviewHost) {
const oldPreview = this.preview;
if(previewHost) {
this.previewHost = previewHost;
this.preview = this.previewHost.element;
} else {
this.previewHost = null;
this.preview = document.createElement('div');
this.preview.style.display = 'none';
}
previewHost?.setCancellationHandler(() => {
this.setPreview(null);
});
this.btn.replaceChild(this.preview, oldPreview);
}
public refreshLayout(vkbd: VisualKeyboard) {
let key = this.spec as ActiveKey;
this.square.style.width = vkbd.layoutWidth.scaledBy(key.proportionalWidth).styleString;

View file

@ -1,3 +1,4 @@
import { GesturePreviewHost } from "./keyboard-layout/gesturePreviewHost.js";
import { KeyElement } from "./keyElement.js";
import VisualKeyboard from "./visualKeyboard.js";
@ -6,5 +7,5 @@ export default interface KeyTip {
state: boolean;
element?: HTMLDivElement;
show(key: KeyElement, on: boolean, vkbd: VisualKeyboard);
show(key: KeyElement, on: boolean, vkbd: VisualKeyboard, previewHost: GesturePreviewHost);
}

View file

@ -55,7 +55,9 @@ import SubkeyPopup from './input/gestures/browser/subkeyPopup.js';
import Multitap from './input/gestures/browser/multitap.js';
import { GestureHandler } from './input/gestures/gestureHandler.js';
import Modipress from './input/gestures/browser/modipress.js';
import Flick from './input/gestures/browser/flick.js';
import Flick, { buildFlickScroller } from './input/gestures/browser/flick.js';
import { GesturePreviewHost } from './keyboard-layout/gesturePreviewHost.js';
import OSKBaseKey from './keyboard-layout/oskBaseKey.js';
interface KeyRuleEffects {
contextToken?: number,
@ -210,6 +212,7 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
// Popup key management
keytip: KeyTip;
gesturePreviewHost: GesturePreviewHost;
globeHint: GlobeHint;
activeGestures: GestureHandler[] = [];
@ -392,7 +395,8 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
const sourceTrackingMap: Record<string, {
source: GestureSource<KeyElement, string>,
roamingHighlightHandler: (sample: InputSample<KeyElement, string>) => void,
key: KeyElement
key: KeyElement,
previewHost: GesturePreviewHost
}> = {};
const gestureHandlerMap = new Map<GestureSequence<KeyElement>, GestureHandler[]>();
@ -400,49 +404,77 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
// Now to set up event-handling links.
// This handler should probably vary based on the keyboard: do we allow roaming touches or not?
recognizer.on('inputstart', (source) => {
// Yay for closure-capture mechanics: we can "keep a lock" on this newly-starting
// gesture's highlighted key here.
const previewHost = this.highlightKey(source.currentSample.item, true);
if(previewHost) {
this.gesturePreviewHost?.cancel();
this.gesturePreviewHost = previewHost;
}
// Make sure we're tracking the source and its currently-selected item (the latter, as we're
// highlighting it)
const trackingEntry = sourceTrackingMap[source.identifier] = {
source: source,
roamingHighlightHandler: (sample) => {
// Maintain highlighting
const key = sample.item;
const oldKey = sourceTrackingMap[source.identifier].key;
if(key != oldKey) {
this.highlightKey(oldKey, false);
this.highlightKey(key, true);
sourceTrackingMap[source.identifier].key = key;
}
},
key: source.currentSample.item
roamingHighlightHandler: null,
key: source.currentSample.item,
previewHost: previewHost
}
// Yay for closure-capture mechanics: we can "keep a lock" on this newly-starting
// gesture's highlighted key here.
this.highlightKey(trackingEntry.key, true);
const endHighlighting = () => {
trackingEntry.previewHost?.cancel();
// If we ever allow concurrent previews, check if it exists and matches
// a VisualKeyboard-tracked entry; if so, clear that too.
this.gesturePreviewHost = null;
trackingEntry.previewHost = null;
if(trackingEntry.key) {
this.highlightKey(trackingEntry.key, false);
trackingEntry.key = null;
}
}
// Note: GestureSource does not currently auto-terminate if there are no
// remaining matchable gestures. Though, we shouldn't facilitate roaming
// anyway if we've turned it off.
if(this.kbdLayout.hasFlicks) {
const flickScroller = buildFlickScroller(source, source.path.coords[0], previewHost, DEFAULT_GESTURE_PARAMS);
trackingEntry.roamingHighlightHandler = (sample) => {
if(source.baseItem.key.spec.flick) {
flickScroller(sample);
}
const key = sample.item;
const oldKey = sourceTrackingMap[source.identifier].key;
if(key != oldKey) {
endHighlighting();
}
};
} else {
trackingEntry.roamingHighlightHandler = (sample) => {
// Maintain highlighting
const key = sample.item;
const oldKey = sourceTrackingMap[source.identifier].key;
if(key != oldKey) {
this.highlightKey(oldKey, false);
this.gesturePreviewHost?.cancel();
const previewHost = this.highlightKey(key, true);
if(previewHost) {
this.gesturePreviewHost = previewHost;
}
trackingEntry.previewHost = previewHost;
sourceTrackingMap[source.identifier].key = key;
}
}
}
source.path.on('invalidated', endHighlighting);
source.path.on('complete', endHighlighting);
// TODO: any other 'invalidated' / 'complete' handling needed?
// If so, separate handler - it likely needs to be disabled once the first gesture-component
// match happens, unlike the highlighting part.
source.path.on('step', trackingEntry.roamingHighlightHandler);
source.path.on('step', (sample) => {
// // Do... something based on the potential gesture types that could arise, as appropriate.
// // Should be useful for selecting a hint type, etc.
// source.potentialModelMatchIds
})
});
//
@ -462,13 +494,23 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
// Multitouch does reference tracking data for a source after its completion,
// but only while still permitting new touches. If we're here, that time is over.
for(let id of gestureSequence.allSourceIds) {
// If the original preview host lives on, ensure it's cancelled now.
this.gesturePreviewHost = null;
sourceTrackingMap[id].previewHost?.cancel();
delete sourceTrackingMap[id];
}
});
// This should probably vary based on the type of gesture.
gestureSequence.on('stage', (gestureStage, configChanger) => {
const existingPreviewHost = gestureSequence.allSourceIds.map((id) => {
return sourceTrackingMap[id]?.previewHost;
}).find((obj) => !!obj);
let handlers: GestureHandler[] = gestureHandlerMap.get(gestureSequence);
if(!handlers && existingPreviewHost && !gestureStage.matchedId.includes('flick')) {
existingPreviewHost.clearFlick();
}
// Disable roaming-touch highlighting (and current highlighting) for all
// touchpoints included in a gesture, even newly-included ones as they occur.
@ -555,14 +597,23 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
// Potential long-term idea: only handle the first stage; delegate future stages to
// specialized handlers for the remainder of the sequence.
// Should work for modipresses, too... I think.
if(gestureStage.matchedId == 'special-key-start' && gestureKey.key.spec.baseKeyID == 'K_BKSP') {
// Possible enhancement: maybe update the held location for the backspace if there's movement?
// But... that seems pretty low-priority.
//
// Merely constructing the instance is enough; it'll link into the sequence's events and
// handle everything that remains for the backspace from here.
handlers = [new HeldRepeater(gestureSequence, () => this.modelKeyClick(gestureKey, coord))];
if(gestureStage.matchedId == 'special-key-start') {
if(gestureKey.key.spec.baseKeyID == 'K_BKSP') {
// There shouldn't be a preview host for special keys... but it doesn't hurt to add the check.
existingPreviewHost?.cancel();
// Possible enhancement: maybe update the held location for the backspace if there's movement?
// But... that seems pretty low-priority.
//
// Merely constructing the instance is enough; it'll link into the sequence's events and
// handle everything that remains for the backspace from here.
handlers = [new HeldRepeater(gestureSequence, () => this.modelKeyClick(gestureKey, coord))];
} else if(gestureKey.key.spec.baseKeyID == "K_LOPT") {
gestureSequence.on('complete', () => this.emit('globekey', gestureKey, false));
}
} else if(gestureStage.matchedId.indexOf('longpress') > -1) {
existingPreviewHost?.cancel();
// Matches: 'longpress', 'longpress-reset'.
// Likewise.
handlers = [new SubkeyPopup(
@ -576,6 +627,10 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
// baseItem is sometimes null during a keyboard-swap... for app/browser touch-based language menus.
// not ideal, but it is what it is; just let it pass by for now.
} else if(baseItem?.key.spec.multitap && (gestureStage.matchedId == 'initial-tap' || gestureStage.matchedId == 'multitap' || gestureStage.matchedId == 'modipress-start')) {
// For now, but worth changing later!
// Idea: if the preview weren't hosted by the key, but instead had a key-lookalike overlay.
// Then it would float above any layer, even after layer swaps.
existingPreviewHost?.cancel();
// Likewise - mere construction is enough.
handlers = [new Multitap(gestureSequence, this, baseItem, keyResult.contextToken)];
} else if(gestureStage.matchedId.indexOf('flick') > -1) {
@ -584,11 +639,13 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
configChanger,
this,
gestureSequence.stageReports[0].sources[0].baseItem,
this.gestureParams
this.gestureParams,
existingPreviewHost
)];
}
} else if(gestureStage.matchedId.includes('modipress') && gestureStage.matchedId.includes('-start')) {
// There shouldn't be a preview host for modipress keys... but it doesn't hurt to add the check.
existingPreviewHost?.cancel();
if(gestureStage.matchedId.includes('modipress') && gestureStage.matchedId.includes('-start')) {
if(this.layerLocked) {
console.warn("Unexpected state: modipress start attempt during an active modipress");
} else {
@ -605,6 +662,9 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
handlers.push(modipressHandler);
this.activeModipress = modipressHandler;
}
} else {
// Probably an initial-tap or a simple-tap.
existingPreviewHost?.cancel();
}
if(handlers) {
@ -619,12 +679,9 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
if(handler instanceof Modipress) {
handler.cancel();
}
})
});
});
}
// TODO: depending upon the gesture type, what sort of UI shifts should happen to
// facilitate follow-up stages?
})
});
@ -1049,20 +1106,28 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
* @param {Object} key key affected
* @param {boolean} on add or remove highlighting
**/
highlightKey(key: KeyElement, on: boolean) {
highlightKey(key: KeyElement, on: boolean): GesturePreviewHost {
// 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();
const usePreview = key.key.allowsKeyTip();
const modalVizActive = this.activeGestures.find((handler) => handler.hasModalVisualization);
// If the subkey menu (or a different modal visualization) is active, do not show the key tip -
// even if for a different contact point.
on = modalVizActive ? false : on;
if(!on) {
key.key.highlight(on);
return null;
}
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);
return this.showGesturePreview(key);
} else {
return null;
}
}
@ -1179,7 +1244,7 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
paddingZone.updatePadding([-0.333 * this.currentLayer.rowHeight]);
this.gestureParams.longpress.flickDist = 0.25 * this.currentLayer.rowHeight;
this.gestureParams.flick.startDist = 0.25 * this.currentLayer.rowHeight;
this.gestureParams.flick.startDist = 0.1 * this.currentLayer.rowHeight;
this.gestureParams.flick.triggerDist = 0.75 * this.currentLayer.rowHeight;
// Needs the refreshed layout info to work correctly.
@ -1454,25 +1519,29 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
};
/**
* Add (or remove) the keytip preview (if KeymanWeb on a phone device)
* Add (or remove) the gesture preview (if KeymanWeb on a phone device)
*
* @param {Object} key HTML key element
* @param {boolean} on show or hide
* @returns A GesturePreviewHost instance usable for visualizing a gesture.
*/
showKeyTip(key: KeyElement, on: boolean) {
var tip = this.keytip;
showGesturePreview(key: KeyElement) {
const tip = this.keytip;
const keyCS = getComputedStyle(key);
const parsedHeight = Number.parseInt(keyCS.height, 10);
const parsedWidth = Number.parseInt(keyCS.width, 10);
const previewHost = new GesturePreviewHost(key, !!tip, Math.max(parsedWidth, parsedHeight));
if (tip == null) {
return;
const baseKey = key.key as OSKBaseKey;
baseKey.setPreview(previewHost);
return previewHost;
} else {
tip.show(key, true, this, previewHost);
}
const modalVizActive = this.activeGestures.find((handler) => handler.hasModalVisualization);
// If the subkey menu (or a different modal visualization) is active, do not show the key tip -
// even if for a different contact point.
on = modalVizActive ? false : on;
tip.show(key, on, this);
return previewHost;
};
/**
@ -1517,7 +1586,7 @@ export default class VisualKeyboard extends EventEmitter<EventMap> implements Ke
window.clearTimeout(this.deleting);
}
this.keytip?.show(null, false, this);
this.keytip?.show(null, false, this, null);
}
lockLayer(enable: boolean) {

View file

@ -3,6 +3,7 @@
"compilerOptions": {
"baseUrl": "./",
"downlevelIteration": true,
"outDir": "../../../build/engine/osk/obj/",
"tsBuildInfoFile": "../../../build/engine/osk/obj/tsconfig.tsbuildinfo",
"rootDir": "./src"

View file

@ -345,6 +345,54 @@ body div.kmw-key-shift-on span.kmw-key-text {font-family:SpecialOSK !important;f
z-index: 10002;
}
#kmw-gesture-preview {
position: absolute;
display: block;
z-index: 1;
border-radius: inherit;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
/* Clip anything that 'scrolls' past the preview's boundaries.*/
overflow: hidden;
/* Hides the base key entirely; this prevents artifacting should positioning vary a bit. */
background-color: inherit;
}
#kmw-gesture-base-label {
top: 50%;
transform: translateY(-50%);
position: absolute;
display: block;
width: 100%;
}
.kmw-flick-preview {
position: absolute;
}
#kmw-preview-img-container {
position: absolute;
/* Facilitates scrolling animation to match a flick */
height: 100%;
width: 100%;
transition: 0.5s linear border;
}
#kmw-preview-img-container.flick-clear {
transition: margin 0.25s linear ease-in-out;
margin-left: 0px !important;
margin-top: 0px !important;
}
.kmw-key-popup-icon.hint-clear {
color: transparent;
transition: 0.5s linear all;
}
/* Key preview styles */
div.ios div.kmw-keytip, div.android div.kmw-keytip {
@ -402,6 +450,8 @@ div.android div.kmw-keytip {
div.android div.kmw-keytip-tip {
border-radius: 6px;
background: #999;
/* Needed in order to properly 'anchor' the gesture-preview */
position: relative;
}
div.android div.kmw-keytip-cap {