From 68cad104ccb4ea90edba6499c67577b9998c289d Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 30 Nov 2023 13:42:00 +0700 Subject: [PATCH 1/4] feat(web): core multitap preview implementation --- .../src/input/gestures/browser/multitap.ts | 14 ++++-- .../src/keyboard-layout/gesturePreviewHost.ts | 9 +++- web/src/engine/osk/src/visualKeyboard.ts | 46 +++++++++++-------- 3 files changed, 46 insertions(+), 23 deletions(-) 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 3f1d304df3d..33e4cce7fff 100644 --- a/web/src/engine/osk/src/input/gestures/browser/multitap.ts +++ b/web/src/engine/osk/src/input/gestures/browser/multitap.ts @@ -7,6 +7,7 @@ import { GestureHandler } from '../gestureHandler.js'; import { distributionFromDistanceMaps } from '@keymanapp/input-processor'; import Modipress from './modipress.js'; import { keySupportsModipress } from '../specsForLayout.js'; +import { GesturePreviewHost } from '../../../keyboard-layout/gesturePreviewHost.js'; /** * Represents a potential multitap gesture's implementation within KeymanWeb. @@ -35,7 +36,8 @@ export default class Multitap implements GestureHandler { source: GestureSequence, vkbd: VisualKeyboard, e: KeyElement, - contextToken: number + contextToken: number, + previewHost: GesturePreviewHost ) { this.baseKey = e; this.baseContextToken = contextToken; @@ -55,9 +57,6 @@ export default class Multitap implements GestureHandler { this.originalLayer = vkbd.layerId; source.on('complete', () => { - if(source.stageReports.length > 1) { - } - this.modipress?.cancel(); this.clear(); }); @@ -93,6 +92,9 @@ export default class Multitap implements GestureHandler { // For rota-style behavior this.tapIndex = (this.tapIndex + 1) % this.multitaps.length; const selection = this.multitaps[this.tapIndex]; + const nextSel = (this.tapIndex == this.multitaps.length - 1) ? this.multitaps[0] : this.multitaps[this.tapIndex+1]; + + previewHost?.setMultitapHint(selection.text, nextSel.text); const keyEvent = vkbd.keyEventFromSpec(selection); keyEvent.baseTranscriptionToken = this.baseContextToken; @@ -135,6 +137,10 @@ export default class Multitap implements GestureHandler { startModipress(source.stageReports[0]); } + // For this specific instance, we'll go ahead and directly maintain the preview; + // a touch just ended, and all other updates occur on the start of a new touch. + previewHost?.setMultitapHint(this.multitaps[0].text, this.multitaps[1].text); + /* In theory, setting up a specialized recognizer config limited to the base key's surface area * would be pretty ideal - it'd provide automatic cancellation if anywhere else were touched. * diff --git a/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts b/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts index 842de87b7c4..83a724c990c 100644 --- a/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts +++ b/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts @@ -1,4 +1,4 @@ -import { ActiveKey } from "@keymanapp/keyboard-processor"; +import { ActiveKey, ActiveKeyBase } from "@keymanapp/keyboard-processor"; import EventEmitter from "eventemitter3"; import { KeyElement } from "../keyElement.js"; @@ -137,6 +137,13 @@ export class GesturePreviewHost extends EventEmitter { this.onCancel = handler; } + public setMultitapHint(current: string, next: string) { + this.label.textContent = current; + this.hintLabel.textContent = next; + + this.clearFlick(); + } + public scrollFlickPreview(x: number, y: number) { this.clearHint(); diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index b5e5f1eeb37..9d575154b4c 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -428,17 +428,22 @@ export default class VisualKeyboard extends EventEmitter implements Ke } 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. - if(previewHost) { - this.gesturePreviewHost = null; - trackingEntry.previewHost = null; - } - if(trackingEntry.key) { - this.highlightKey(trackingEntry.key, false); - trackingEntry.key = null; - } + // The base call will occur before our "is this a multitap?" check otherwise. + timedPromise(0).then(() => { + const previewHost = trackingEntry.previewHost; + + // If we ever allow concurrent previews, check if it exists and matches + // a VisualKeyboard-tracked entry; if so, clear that too. + if(previewHost) { + previewHost.cancel(); + this.gesturePreviewHost = null; + trackingEntry.previewHost = null; + } + if(trackingEntry.key) { + this.highlightKey(trackingEntry.key, false); + trackingEntry.key = null; + } + }) } // Fix: if flicks enabled, no roaming. @@ -510,6 +515,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke existingPreviewHost.clearFlick(); } + let trackingEntry: typeof sourceTrackingMap[string]; // Disable roaming-touch highlighting (and current highlighting) for all // touchpoints included in a gesture, even newly-included ones as they occur. for(let id of gestureStage.allSourceIds) { @@ -522,7 +528,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke trackingEntry.source.path.off('step', trackingEntry.roamingHighlightHandler); } - const trackingEntry = sourceTrackingMap[id]; + trackingEntry = sourceTrackingMap[id]; if(trackingEntry) { clearRoaming(trackingEntry); @@ -625,12 +631,16 @@ 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)]; + // Detach the lifetime of the preview from the current touch. + trackingEntry.previewHost = null; + + gestureSequence.on('complete', () => { + existingPreviewHost.cancel(); + this.gesturePreviewHost = null; + }) + + // Past that, mere construction of the class for delegation is enough. + handlers = [new Multitap(gestureSequence, this, baseItem, keyResult.contextToken, existingPreviewHost)]; } else if(gestureStage.matchedId.indexOf('flick') > -1) { handlers = [new Flick( gestureSequence, From 3f911edf3dcb81a03edf79d8623877a656c85841 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 30 Nov 2023 13:42:35 +0700 Subject: [PATCH 2/4] feat(web): multitap preview fade animation --- .../engine/osk/src/input/gestures/browser/keytip.ts | 7 +++++++ .../osk/src/input/gestures/browser/tabletPreview.ts | 7 +++++++ .../osk/src/keyboard-layout/gesturePreviewHost.ts | 3 +++ web/src/engine/osk/src/visualKeyboard.ts | 1 + web/src/resources/osk/kmwosk.css | 10 ++++++++++ 5 files changed, 28 insertions(+) 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 45885d0e4fd..a679093db02 100644 --- a/web/src/engine/osk/src/input/gestures/browser/keytip.ts +++ b/web/src/engine/osk/src/input/gestures/browser/keytip.ts @@ -214,6 +214,12 @@ export default class KeyTip implements KeyTipInterface { this.preview = this.previewHost.element; this.tip.replaceChild(this.preview, oldHost); previewHost.setCancellationHandler(() => this.show(null, false, null)); + previewHost.on('startFade', () => { + this.element.classList.remove('kmw-preview-fade'); + // Note: a reflow is needed to reset the transition animation. + this.element.offsetWidth; + this.element.classList.add('kmw-preview-fade'); + }); } } else { // Hide the key preview this.element.style.display = 'none'; @@ -222,6 +228,7 @@ export default class KeyTip implements KeyTipInterface { const oldPreview = this.preview; this.preview = document.createElement('div'); this.tip.replaceChild(this.preview, oldPreview); + this.element.classList.remove('kmw-preview-fade'); this.orientation = DEFAULT_TIP_ORIENTATION; } diff --git a/web/src/engine/osk/src/input/gestures/browser/tabletPreview.ts b/web/src/engine/osk/src/input/gestures/browser/tabletPreview.ts index d19b87c3bd0..655e46c7e47 100644 --- a/web/src/engine/osk/src/input/gestures/browser/tabletPreview.ts +++ b/web/src/engine/osk/src/input/gestures/browser/tabletPreview.ts @@ -96,6 +96,12 @@ export class TabletKeyTip implements KeyTipInterface { this.preview = this.previewHost.element; this.element.replaceChild(this.preview, oldHost); previewHost.setCancellationHandler(() => this.show(null, false, null)); + previewHost.on('startFade', () => { + this.element.classList.remove('kmw-preview-fade'); + // Note: a reflow is needed to reset the transition animation. + this.element.offsetWidth; + this.element.classList.add('kmw-preview-fade'); + }); } } else { // Hide the key preview this.element.style.display = 'none'; @@ -105,6 +111,7 @@ export class TabletKeyTip implements KeyTipInterface { const oldPreview = this.preview; this.preview = document.createElement('div'); this.element.replaceChild(this.preview, oldPreview); + this.element.classList.remove('kmw-preview-fade'); } // Save the key preview state diff --git a/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts b/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts index 83a724c990c..201f3843732 100644 --- a/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts +++ b/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts @@ -14,6 +14,7 @@ const FLICK_OVERFLOW_OFFSET = 1.4142; interface EventMap { preferredOrientation: (orientation: PhoneKeyTipOrientation) => void; + startFade: () => void; } export class GesturePreviewHost extends EventEmitter { @@ -141,6 +142,8 @@ export class GesturePreviewHost extends EventEmitter { this.label.textContent = current; this.hintLabel.textContent = next; + this.emit('startFade'); + this.clearFlick(); } diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index 9d575154b4c..e870e8eebb3 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -429,6 +429,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke const endHighlighting = () => { // The base call will occur before our "is this a multitap?" check otherwise. + // That check will unset the field so that it's unaffected by this check. timedPromise(0).then(() => { const previewHost = trackingEntry.previewHost; diff --git a/web/src/resources/osk/kmwosk.css b/web/src/resources/osk/kmwosk.css index 662921c8192..67cf84d610e 100644 --- a/web/src/resources/osk/kmwosk.css +++ b/web/src/resources/osk/kmwosk.css @@ -349,11 +349,21 @@ body div.kmw-key-shift-on span.kmw-key-text {font-family:SpecialOSK !important;f z-index: 10002; } +#kmw-keytip.kmw-preview-fade { + opacity: 0; + transition: 0.15s cubic-bezier(.42,0,.58,1) all 0.35s; +} + .kmw-keypreview { z-index: 10002; position: absolute; } +.kmw-keypreview.kmw-preview-fade { + opacity: 0; + transition: 0.15s cubic-bezier(.42,0,.58,1) all 0.35s; +} + #kmw-gesture-preview { position: absolute; display: block; From 720347968525c75ddb9c801bc348602ccdff28a4 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 30 Nov 2023 14:01:57 +0700 Subject: [PATCH 3/4] refactor(web): mild refactor of bits from last commit --- .../osk/src/input/gestures/browser/multitap.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 33e4cce7fff..b907b24f9c6 100644 --- a/web/src/engine/osk/src/input/gestures/browser/multitap.ts +++ b/web/src/engine/osk/src/input/gestures/browser/multitap.ts @@ -56,6 +56,12 @@ export default class Multitap implements GestureHandler { this.originalLayer = vkbd.layerId; + const tapLookahead = (offset) => (this.tapIndex + offset) % this.multitaps.length; + + const updatePreview = () => { + previewHost?.setMultitapHint(this.multitaps[tapLookahead(0)].text, this.multitaps[tapLookahead(1)].text); + } + source.on('complete', () => { this.modipress?.cancel(); this.clear(); @@ -90,11 +96,9 @@ export default class Multitap implements GestureHandler { } // For rota-style behavior - this.tapIndex = (this.tapIndex + 1) % this.multitaps.length; + this.tapIndex = tapLookahead(1); const selection = this.multitaps[this.tapIndex]; - const nextSel = (this.tapIndex == this.multitaps.length - 1) ? this.multitaps[0] : this.multitaps[this.tapIndex+1]; - - previewHost?.setMultitapHint(selection.text, nextSel.text); + updatePreview(); const keyEvent = vkbd.keyEventFromSpec(selection); keyEvent.baseTranscriptionToken = this.baseContextToken; @@ -139,7 +143,7 @@ export default class Multitap implements GestureHandler { // For this specific instance, we'll go ahead and directly maintain the preview; // a touch just ended, and all other updates occur on the start of a new touch. - previewHost?.setMultitapHint(this.multitaps[0].text, this.multitaps[1].text); + updatePreview(); /* In theory, setting up a specialized recognizer config limited to the base key's surface area * would be pretty ideal - it'd provide automatic cancellation if anywhere else were touched. From a475fda323d151569090120d783a91f9e01bc4c7 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 30 Nov 2023 14:21:32 +0700 Subject: [PATCH 4/4] fix(web): right, modipressable multitaps don't get previews; null-guard needed --- web/src/engine/osk/src/visualKeyboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index e870e8eebb3..50d88f38eb5 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -636,7 +636,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke trackingEntry.previewHost = null; gestureSequence.on('complete', () => { - existingPreviewHost.cancel(); + existingPreviewHost?.cancel(); this.gesturePreviewHost = null; })