diff --git a/common/web/gesture-recognizer/src/engine/configuration/viewportZoneSource.ts b/common/web/gesture-recognizer/src/engine/configuration/viewportZoneSource.ts index 4a9b6b3cd1..c558a2aaf7 100644 --- a/common/web/gesture-recognizer/src/engine/configuration/viewportZoneSource.ts +++ b/common/web/gesture-recognizer/src/engine/configuration/viewportZoneSource.ts @@ -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) }); } } \ No newline at end of file diff --git a/common/web/keyboard-processor/src/keyboards/activeLayout.ts b/common/web/keyboard-processor/src/keyboards/activeLayout.ts index 928263fb18..ddd1c6dc83 100644 --- a/common/web/keyboard-processor/src/keyboards/activeLayout.ts +++ b/common/web/keyboard-processor/src/keyboards/activeLayout.ts @@ -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[]; diff --git a/web/src/engine/main/src/keymanEngine.ts b/web/src/engine/main/src/keymanEngine.ts index fe38de72ee..4337c17d95 100644 --- a/web/src/engine/main/src/keymanEngine.ts +++ b/web/src/engine/main/src/keymanEngine.ts @@ -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; } diff --git a/web/src/engine/osk/src/input/gestures/browser/flick.ts b/web/src/engine/osk/src/input/gestures/browser/flick.ts index b640aec99e..52b29ba253 100644 --- a/web/src/engine/osk/src/input/gestures/browser/flick.ts +++ b/web/src/engine/osk/src/input/gestures/browser/flick.ts @@ -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(); + + 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, + initialCoord: InputSample, + previewHost: GesturePreviewHost, + gestureParams: GestureParams +): (coord: InputSample) => void { + return (coord: InputSample) => { + 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, 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); diff --git a/web/src/engine/osk/src/input/gestures/browser/keytip.ts b/web/src/engine/osk/src/input/gestures/browser/keytip.ts index 54eab1ee2c..94d2c9da29 100644 --- a/web/src/engine/osk/src/input/gestures/browser/keytip.ts +++ b/web/src/engine/osk/src/input/gestures/browser/keytip.ts @@ -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 diff --git a/web/src/engine/osk/src/input/gestures/browser/multitap.ts b/web/src/engine/osk/src/input/gestures/browser/multitap.ts index e132349351..02f88ef330 100644 --- a/web/src/engine/osk/src/input/gestures/browser/multitap.ts +++ b/web/src/engine/osk/src/input/gestures/browser/multitap.ts @@ -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. diff --git a/web/src/engine/osk/src/input/gestures/browser/subkeyPopup.ts b/web/src/engine/osk/src/input/gestures/browser/subkeyPopup.ts index 431d0bf04b..8cd25869cd 100644 --- a/web/src/engine/osk/src/input/gestures/browser/subkeyPopup.ts +++ b/web/src/engine/osk/src/input/gestures/browser/subkeyPopup.ts @@ -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(); diff --git a/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts b/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts new file mode 100644 index 0000000000..786ad2f0ed --- /dev/null +++ b/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts @@ -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; + 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(); + } +} \ No newline at end of file diff --git a/web/src/engine/osk/src/keyboard-layout/oskBaseKey.ts b/web/src/engine/osk/src/keyboard-layout/oskBaseKey.ts index 9c1fb1a019..7e0902ca97 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskBaseKey.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskBaseKey.ts @@ -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; diff --git a/web/src/engine/osk/src/keytip.interface.ts b/web/src/engine/osk/src/keytip.interface.ts index d3bb630d8b..f21eb74d63 100644 --- a/web/src/engine/osk/src/keytip.interface.ts +++ b/web/src/engine/osk/src/keytip.interface.ts @@ -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); } diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index f07f595177..b820247c0e 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -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 implements Ke // Popup key management keytip: KeyTip; + gesturePreviewHost: GesturePreviewHost; globeHint: GlobeHint; activeGestures: GestureHandler[] = []; @@ -392,7 +395,8 @@ export default class VisualKeyboard extends EventEmitter implements Ke const sourceTrackingMap: Record, roamingHighlightHandler: (sample: InputSample) => void, - key: KeyElement + key: KeyElement, + previewHost: GesturePreviewHost }> = {}; const gestureHandlerMap = new Map, GestureHandler[]>(); @@ -400,49 +404,77 @@ export default class VisualKeyboard extends EventEmitter 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 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 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 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 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 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 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 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 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 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 implements Ke window.clearTimeout(this.deleting); } - this.keytip?.show(null, false, this); + this.keytip?.show(null, false, this, null); } lockLayer(enable: boolean) { diff --git a/web/src/engine/osk/tsconfig.json b/web/src/engine/osk/tsconfig.json index a8cfdcacae..325dae0d98 100644 --- a/web/src/engine/osk/tsconfig.json +++ b/web/src/engine/osk/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "baseUrl": "./", + "downlevelIteration": true, "outDir": "../../../build/engine/osk/obj/", "tsBuildInfoFile": "../../../build/engine/osk/obj/tsconfig.tsbuildinfo", "rootDir": "./src" diff --git a/web/src/resources/osk/kmwosk.css b/web/src/resources/osk/kmwosk.css index 9177f0b272..960576145f 100644 --- a/web/src/resources/osk/kmwosk.css +++ b/web/src/resources/osk/kmwosk.css @@ -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 {