From 4f19b4cd1e7d1aeb701ee14ceb4bcc71285e76f2 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 30 Nov 2023 13:01:10 +0700 Subject: [PATCH] change(web): floating tablet key-previews, hosted by layer-independent element --- .../osk/src/input/gestures/browser/keytip.ts | 9 +- .../input/gestures/browser/tabletPreview.ts | 114 ++++++++++++++++++ .../src/keyboard-layout/gesturePreviewHost.ts | 25 ++++ web/src/engine/osk/src/visualKeyboard.ts | 13 +- web/src/resources/osk/kmwosk.css | 8 ++ 5 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 web/src/engine/osk/src/input/gestures/browser/tabletPreview.ts 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 02a397aba2..45885d0e4f 100644 --- a/web/src/engine/osk/src/input/gestures/browser/keytip.ts +++ b/web/src/engine/osk/src/input/gestures/browser/keytip.ts @@ -5,7 +5,7 @@ import VisualKeyboard from '../../../visualKeyboard.js'; import { GesturePreviewHost } from '../../../keyboard-layout/gesturePreviewHost.js'; const CSS_PREFIX = 'kmw-'; -const DEFAULT_TIP_ORIENTATION = 'top'; +const DEFAULT_TIP_ORIENTATION: PhoneKeyTipOrientation = 'top'; export type PhoneKeyTipOrientation = 'top' | 'bottom'; @@ -127,8 +127,13 @@ export default class KeyTip implements KeyTipInterface { kts.width = canvasWidth+'px'; kts.height = canvasHeight+'px'; + // Some keyboards (such as `balochi_scientific`) do not _package_ a font but + // specify an extremely common one, such as Arial. In such cases, .kmw-key-text + // custom styling doesn't exist, relying on the layer object to simply specify + // the font-family. + const layerFontFamily = this.vkbd.currentLayer.element.style.fontFamily; const ckts = getComputedStyle(vkbd.element); - kts.fontFamily = ckts.fontFamily; + kts.fontFamily = key.key.spec.font || layerFontFamily || ckts.fontFamily; var px=parseInt(ckts.fontSize,10); if(px == Number.NaN) { diff --git a/web/src/engine/osk/src/input/gestures/browser/tabletPreview.ts b/web/src/engine/osk/src/input/gestures/browser/tabletPreview.ts new file mode 100644 index 0000000000..d19b87c3bd --- /dev/null +++ b/web/src/engine/osk/src/input/gestures/browser/tabletPreview.ts @@ -0,0 +1,114 @@ +import { KeyElement } from '../../../keyElement.js'; +import KeyTipInterface from '../../../keytip.interface.js'; +import VisualKeyboard from '../../../visualKeyboard.js'; +import { GesturePreviewHost } from '../../../keyboard-layout/gesturePreviewHost.js'; + +const BASE_CLASS = 'kmw-keypreview'; +const OVERLAY_CLASS = 'kmw-preview-overlay'; +const BASE_ID = 'kmw-keytip'; + +export class TabletKeyTip implements KeyTipInterface { + public readonly element: HTMLDivElement; + public key: KeyElement; + public state: boolean = false; + + private previewHost: GesturePreviewHost; + private preview: HTMLDivElement; + private readonly vkbd: VisualKeyboard; + + /** + * + * @param constrain keep the keytip within the bounds of the overall OSK. + * Will probably be handled via function in a later pass. + */ + constructor(vkbd: VisualKeyboard) { + this.vkbd = vkbd; + const base = this.element=document.createElement('div'); + base.className=BASE_CLASS; + base.id = 'kmw-keytip'; + + // The following style is critical, so do not rely on external CSS + base.style.pointerEvents='none'; + base.style.display='none'; + + this.preview = document.createElement('div'); + base.appendChild(this.preview); + } + + show(key: KeyElement, on: boolean, previewHost: GesturePreviewHost) { + const vkbd = this.vkbd; + const keyLayer = key?.key.spec.displayLayer; + + // During quick input sequences - especially during a multitap-modipress - it's possible + // for a user to request a preview for a key from a layer that is currently active, but + // currently not visible due to need previously-requested layout calcs for a different layer. + if(on) { + // Necessary for `key.offsetParent` and client-rect methods referenced below. + // Will not unnecessarily force reflow if the layer is already in proper document flow, + // but otherwise restores it. + vkbd.layerGroup.blinkLayer(keyLayer); + } + + // Create and display the preview + // If !key.offsetParent, the OSK is probably hidden. Either way, it's a half- + // decent null-guard check. + if(on && key.offsetParent) { + // May need adjustment for borders if ever enabled for the desktop form-factor target. + // const _Box = this.vkbd.topContainer; + const hostRect = this.vkbd.element.getBoundingClientRect(); + const keyRect = key.getBoundingClientRect(); + + // Used to apply box-shadow overlay styling when the preview is for a key on a layer not + // currently active. This is done in case the layers don't have perfect alignment for + // all keys. + const conditionalOverlayStyle = (keyLayer != vkbd.layerId) ? OVERLAY_CLASS : ''; + this.element.className = `${BASE_CLASS} ${key.className} ${conditionalOverlayStyle}`; + + // Some keyboards use custom CSS styling based on partial-matching the key ID + // (like sil_cameroon_azerty); this lets us map the custom styles onto the tablet + // preview, too. + this.element.id = `${BASE_ID}-${key.id}`; + + const kts = this.element.style; + + // Some keyboards (such as `balochi_scientific`) do not _package_ a font but + // specify an extremely common one, such as Arial. In such cases, .kmw-key-text + // custom styling doesn't exist, relying on the layer object to simply specify + // the font-family. + const fontFamily = this.vkbd.currentLayer.element.style.fontFamily; + kts.fontFamily = key.key.spec.font || fontFamily; + + kts.left = (keyRect.left - hostRect.left) + 'px'; + kts.top = (keyRect.top - hostRect.top) + 'px'; + kts.width = keyRect.width + 'px'; + kts.height = keyRect.height + 'px'; + + this.element.style.display = 'block'; + + if(this.previewHost == previewHost) { + return; + } + + const oldHost = this.preview; + this.previewHost = previewHost; + + if(previewHost) { + this.preview = this.previewHost.element; + this.element.replaceChild(this.preview, oldHost); + previewHost.setCancellationHandler(() => this.show(null, false, null)); + } + } else { // Hide the key preview + this.element.style.display = 'none'; + this.element.className = `${BASE_CLASS}`; + + this.previewHost = null; + const oldPreview = this.preview; + this.preview = document.createElement('div'); + this.element.replaceChild(this.preview, oldPreview); + } + + // Save the key preview state + this.key = key; + this.state = on; + } +} \ No newline at end of file diff --git a/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts b/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts index 0e9e50c67d..842de87b7c 100644 --- a/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts +++ b/web/src/engine/osk/src/keyboard-layout/gesturePreviewHost.ts @@ -19,6 +19,7 @@ interface EventMap { export class GesturePreviewHost extends EventEmitter { private readonly div: HTMLDivElement; private readonly label: HTMLSpanElement; + private readonly hintLabel: HTMLSpanElement; private readonly previewImgContainer: HTMLDivElement; private flickPreviews = new Map; @@ -98,6 +99,24 @@ export class GesturePreviewHost extends EventEmitter { previewImgContainer.appendChild(flickPreview); }); } + + const hintLabel = this.hintLabel = document.createElement('div'); + hintLabel.className='kmw-key-popup-icon'; + + if(!isPhone) { + hintLabel.textContent = keySpec == keySpec.hintSrc ? keySpec.hint : keySpec.hintSrc?.text; + hintLabel.style.fontWeight= hintLabel.textContent == '\u2022' ? 'bold' : ''; + } + + // Default positioning puts it far too close to the flick-preview bit. + let yAdjustment = 0; + hintLabel.style.marginTop = `-${yAdjustment}px`; + + // b/c multitap's border forces position shifting + let xAdjustment = 0; + hintLabel.style.marginRight = `-${xAdjustment}px`; + + base.appendChild(hintLabel); } public refreshLayout() { @@ -119,6 +138,8 @@ export class GesturePreviewHost extends EventEmitter { } public scrollFlickPreview(x: number, y: number) { + this.clearHint(); + const scrollStyle = this.previewImgContainer.style; const edge = this.flickEdgeLength * FLICK_OVERFLOW_OFFSET; @@ -140,6 +161,10 @@ export class GesturePreviewHost extends EventEmitter { this.previewImgContainer.classList.add('flick-clear'); } + public clearHint() { + this.hintLabel.classList.add('hint-clear'); + } + public clearAll() { this.clearFlick(); } diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index ad3819c9d9..b5e5f1eeb3 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -44,7 +44,8 @@ import OSKLayer from './keyboard-layout/oskLayer.js'; import OSKLayerGroup from './keyboard-layout/oskLayerGroup.js'; import { LengthStyle, ParsedLengthStyle } from './lengthStyle.js'; import { defaultFontSize, getFontSizeStyle } from './fontSizeUtils.js'; -import InternalKeyTip from './input/gestures/browser/keytip.js'; +import PhoneKeyTip from './input/gestures/browser/keytip.js'; +import { TabletKeyTip } from './input/gestures/browser/tabletPreview.js'; import CommonConfiguration from './config/commonConfiguration.js'; import { DEFAULT_GESTURE_PARAMS, GestureParams, gestureSetForLayout } from './input/gestures/specsForLayout.js'; @@ -1533,7 +1534,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke const keyCS = getComputedStyle(key); const parsedHeight = Number.parseInt(keyCS.height, 10); const parsedWidth = Number.parseInt(keyCS.width, 10); - const previewHost = new GesturePreviewHost(key, !!tip, parsedWidth, parsedHeight); + const previewHost = new GesturePreviewHost(key, this.device.formFactor == 'phone', parsedWidth, parsedHeight); if (tip == null) { const baseKey = key.key as OSKBaseKey; @@ -1551,11 +1552,13 @@ export default class VisualKeyboard extends EventEmitter implements Ke * Create a key preview element for phone devices */ createKeyTip() { - if(this.device.formFactor == 'phone') { - if (this.keytip == null) { + if (this.keytip == null) { + if(this.device.formFactor == 'phone') { // For now, should only be true (in production) when keyman.isEmbedded == true. let constrainPopup = this.isEmbedded; - this.keytip = new InternalKeyTip(this, constrainPopup); + this.keytip = new PhoneKeyTip(this, constrainPopup); + } else { + this.keytip = new TabletKeyTip(this); } } diff --git a/web/src/resources/osk/kmwosk.css b/web/src/resources/osk/kmwosk.css index 2147bb8037..662921c819 100644 --- a/web/src/resources/osk/kmwosk.css +++ b/web/src/resources/osk/kmwosk.css @@ -201,6 +201,7 @@ } .tablet.ios .kmw-key-layer-group {background-color: #cfd3d9} +.tablet.ios .kmw-key.kmw-keypreview.kmw-preview-overlay { box-shadow: 0 0 5px 3px #cfd3d9 } .tablet.ios .kmw-key {border: none; border-bottom: solid 1px #8a8d90; box-shadow:none; border-radius: 5px;} .tablet.ios .kmw-key.kmw-key-default {color:#000;background-color:#fdfdfe;} .tablet.ios .kmw-key.kmw-key-shift, @@ -214,6 +215,7 @@ /* Probably best to make this its own CSS that can be optionally included? */ @media (prefers-color-scheme: dark) { .tablet.ios .kmw-key-layer-group {background-color: #0f1319} + .tablet.ios .kmw-key.kmw-keypreview.kmw-preview-overlay { box-shadow: 0 0 5px 3px #0f1319 } .tablet.ios .kmw-key.kmw-key-default {color:#fff;background-color:#3d3d3e} .tablet.ios .kmw-key.kmw-key-shift, .tablet.ios .kmw-key.kmw-key-special {color:#fff;background-color:#595c62;} @@ -226,6 +228,7 @@ .tablet .kmw-key-row {-webkit-touch-callout:none;-webkit-user-select:none;-ms-user-select:none;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);} .tablet.android .kmw-key-layer-group {background-color: #333;} +.tablet.android .kmw-key.kmw-keypreview.kmw-preview-overlay { box-shadow: 0 0 5px 3px #333 } .tablet.android .kmw-key {border: none; border-bottom: solid 1px #8a8d90; box-shadow:none; border-radius: 5px;} .tablet.android .kmw-key.kmw-key-default {color:#fff;background-color:#777;} .tablet.android .kmw-key.kmw-key-shift, @@ -346,6 +349,11 @@ body div.kmw-key-shift-on span.kmw-key-text {font-family:SpecialOSK !important;f z-index: 10002; } +.kmw-keypreview { + z-index: 10002; + position: absolute; +} + #kmw-gesture-preview { position: absolute; display: block;