mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-25 17:17:43 +00:00
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:
commit
1a7245e409
13 changed files with 459 additions and 103 deletions
|
|
@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
128
web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts
Normal file
128
web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"downlevelIteration": true,
|
||||
"outDir": "../../../build/engine/osk/obj/",
|
||||
"tsBuildInfoFile": "../../../build/engine/osk/obj/tsconfig.tsbuildinfo",
|
||||
"rootDir": "./src"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue