From 257262972382f87da305ca07a55eaeafdf8401a6 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 21 Dec 2022 10:12:28 +0700 Subject: [PATCH 01/23] feat(web): suggestion banner itself scrolls --- web/src/engine/osk/src/banner/banner.ts | 12 +++- web/src/engine/osk/src/banner/bannerView.ts | 47 ++++--------- .../engine/osk/src/banner/suggestionBanner.ts | 67 +++++++++++++------ .../event-interpreter/uiTouchHandlerBase.ts | 17 +++-- web/src/engine/osk/src/views/oskView.ts | 1 + web/src/resources/osk/kmwosk.css | 29 ++++++++ 6 files changed, 114 insertions(+), 59 deletions(-) diff --git a/web/src/engine/osk/src/banner/banner.ts b/web/src/engine/osk/src/banner/banner.ts index 3394efc4382..b63262211b2 100644 --- a/web/src/engine/osk/src/banner/banner.ts +++ b/web/src/engine/osk/src/banner/banner.ts @@ -5,6 +5,7 @@ import { createUnselectableElement } from 'keyman/engine/dom-utils'; export abstract class Banner { private _height: number; // pixels + private _width: number; // pixels private div: HTMLDivElement; public static DEFAULT_HEIGHT: number = 37; // pixels; embedded apps can modify @@ -35,6 +36,15 @@ export abstract class Banner { this.update(); } + public get width(): number { + return this._width; + } + + public set width(width: number) { + this._width = width; + this.update(); + } + /** * Function update * @return {boolean} true if the banner styling changed @@ -76,7 +86,7 @@ export abstract class Banner { * Function getDiv * Scope Public * @returns {HTMLElement} Base element of the banner - * Description Returns the HTMLElelemnt of the banner + * Description Returns the HTMLElement of the banner */ public getDiv(): HTMLElement { return this.div; diff --git a/web/src/engine/osk/src/banner/bannerView.ts b/web/src/engine/osk/src/banner/bannerView.ts index b291f7454e0..de7d25a7e87 100644 --- a/web/src/engine/osk/src/banner/bannerView.ts +++ b/web/src/engine/osk/src/banner/bannerView.ts @@ -24,38 +24,8 @@ interface BannerViewEventMap { } /** - * The `BannerManager` module is designed to serve as a manager for the - * different `Banner` types. - * To facilitate this, it will provide a root element property that serves - * as a container for any active `Banner`, helping KMW to avoid needless - * DOM element shuffling. - * - * Goals for the `BannerManager`: - * - * * It will be exposed as `keyman.osk.banner` and will provide the following API: - * * `getOptions`, `setOptions` - refer to the `BannerOptions` class for details. - * * This provides a persistent point that the web page designers and our - * model apps can utilize and can communicate with. - * * These API functions are designed for live use and will allow - * _hot-swapping_ the `Banner` instance; they're not initialization-only. - * * Disabling the `Banner` (even for suggestions) outright with - * `enablePredictions == false` will auto-unload any loaded predictive model - * from `ModelManager` and setting it to `true` will revert this. - * * This should help to avoid wasting computational resources. - * * It will listen to ModelManager events and automatically swap Banner - * instances as appropriate: - * * The option `persistentBanner == true` is designed to replicate current - * iOS system keyboard behavior. - * * When true, an `ImageBanner` will be displayed. - * * If false, it will be replaced with a `BlankBanner` of zero height, - * corresponding to our current default lack of banner. - * * It will not automatically set `persistentBanner == true`; - * this must be set by the iOS app, and only under the following conditions: - * * `keyman.isEmbedded == true` - * * `device.OS == 'ios'` - * * Keyman is being used as the system keyboard within an app that - * needs to reserve this space (i.e: Keyman for iOS), - * rather than as its standalone app. + * The `BannerView` module is designed to serve as the hot-swap container for the + * different `Banner` types, helping KMW to avoid needless DOM element shuffling. */ export class BannerView implements OSKViewComponent { private bannerContainer: HTMLDivElement; @@ -161,5 +131,16 @@ export class BannerView implements OSKViewComponent { return ParsedLengthStyle.inPixels(this.height); } - public refreshLayout() {}; + public get width(): number | undefined { + return this.currentBanner?.width; + } + + public set width(w: number) { + if(this.currentBanner) { + this.currentBanner.width = w; + } + } + + public refreshLayout() { + } } \ No newline at end of file diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index c5ff7255569..9f97617489b 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -12,6 +12,7 @@ import EventEmitter from 'eventemitter3'; export class BannerSuggestion { div: HTMLDivElement; + container: HTMLDivElement; private display: HTMLSpanElement; private fontFamily?: string; private rtl: boolean = false; @@ -31,7 +32,8 @@ export class BannerSuggestion { // Provides an empty, base SPAN for text display. We'll swap these out regularly; // `Suggestion`s will have varying length and may need different styling. let display = this.display = createUnselectableElement('span'); - this.div.appendChild(display); + display.className = 'kmw-suggestion-text'; + this.container.appendChild(display); } private constructRoot() { @@ -40,13 +42,19 @@ export class BannerSuggestion { div.className = "kmw-suggest-option"; div.id = BannerSuggestion.BASE_ID + this.index; - // Ensures that a reasonable width % is set. + this.div['suggestion'] = this; + + let container = this.container = document.createElement('div'); + container.className = "kmw-suggestion-container"; + + // Ensures that a reasonable default width, based on % is set. (Since it's not yet in the DOM, we may not yet have actual width info.) let usableWidth = 100 - SuggestionBanner.MARGIN * (SuggestionBanner.SUGGESTION_LIMIT - 1); - let widthpc = usableWidth / SuggestionBanner.SUGGESTION_LIMIT; - ds.width = widthpc + '%'; + // The `/ 2` part: Ensures that the full banner is double-wide, which is useful for demoing scrolling. + let widthpc = usableWidth / (SuggestionBanner.SUGGESTION_LIMIT / 2); + container.style.minWidth = widthpc + '%'; - this.div['suggestion'] = this; + div.appendChild(container); } public matchKeyboardProperties(keyboardProperties: KeyboardProperties) { @@ -82,7 +90,7 @@ export class BannerSuggestion { private updateText() { let display = this.generateSuggestionText(this.rtl); - this.div.replaceChild(display, this.display); + this.container.replaceChild(display, this.display); this.display = display; } @@ -130,7 +138,7 @@ export class BannerSuggestion { * Description Display lexical model suggestions in the banner */ export class SuggestionBanner extends Banner { - public static readonly SUGGESTION_LIMIT: number = 3; + public static readonly SUGGESTION_LIMIT: number = 6; public static readonly MARGIN = 1; public readonly events: EventEmitter; @@ -141,6 +149,7 @@ export class SuggestionBanner extends Banner { private hostDevice: DeviceSpec; private manager: SuggestionInputManager; + private readonly container: HTMLElement; readonly type = 'suggestion'; @@ -148,6 +157,7 @@ export class SuggestionBanner extends Banner { static readonly TOUCHED_CLASS: string = 'kmw-suggest-touched'; static readonly BANNER_CLASS: string = 'kmw-suggest-banner'; + static readonly BANNER_SCROLLER_CLASS = 'kmw-suggest-banner-scroller'; constructor(hostDevice: DeviceSpec, height?: number) { super(height || Banner.DEFAULT_HEIGHT); @@ -155,9 +165,14 @@ export class SuggestionBanner extends Banner { this.getDiv().className = this.getDiv().className + ' ' + SuggestionBanner.BANNER_CLASS; + this.container = document.createElement('div'); + this.container.className = SuggestionBanner.BANNER_SCROLLER_CLASS; + this.getDiv().appendChild(this.container); + // TODO: additional styling for the banner scroll container? + this.buildInternals(false); - this.manager = new SuggestionInputManager(this.getDiv()); + this.manager = new SuggestionInputManager(this.container, this.container); this.events = this.manager.events; this.setupInputHandling(); @@ -181,18 +196,18 @@ export class SuggestionBanner extends Banner { */ for (var i=0; i { findTargetFrom(e: HTMLElement): HTMLDivElement { try { if(e) { - if(e.classList.contains('kmw-suggest-option')) { - return e as HTMLDivElement; + const parent = e.parentElement; + if(!parent) { + return null; } - if(e.parentElement && e.parentElement.classList.contains('kmw-suggest-option')) { - return e.parentElement as HTMLDivElement; + + if(parent.classList.contains('kmw-suggest-option')) { + return parent as HTMLDivElement; } + + const grandparent = parent.parentElement; + if(!grandparent) { + return null; + } + + if(grandparent.classList.contains('kmw-suggest-option')) { + return grandparent as HTMLDivElement; + } + // if(e.firstChild && util.hasClass( e.firstChild,'kmw-suggest-option')) { // return e.firstChild as HTMLDivElement; // } @@ -418,8 +445,8 @@ class SuggestionInputManager extends UITouchHandlerBase { }) } - constructor(div: HTMLElement) { + constructor(div: HTMLElement, scroller: HTMLElement) { // TODO: Determine appropriate CSS styling names, etc. - super(div, Banner.BANNER_CLASS, SuggestionBanner.TOUCHED_CLASS); + super(div, scroller, Banner.BANNER_CLASS, SuggestionBanner.TOUCHED_CLASS); } } diff --git a/web/src/engine/osk/src/input/event-interpreter/uiTouchHandlerBase.ts b/web/src/engine/osk/src/input/event-interpreter/uiTouchHandlerBase.ts index 1fea7fab118..0b4956280d7 100644 --- a/web/src/engine/osk/src/input/event-interpreter/uiTouchHandlerBase.ts +++ b/web/src/engine/osk/src/input/event-interpreter/uiTouchHandlerBase.ts @@ -43,7 +43,9 @@ class ScrollState { export default abstract class UITouchHandlerBase { private rowClassMatch: string; private selectedTargetMatch: string; + private baseElement: HTMLElement; + private scroller: HTMLElement; private touchX: number; private touchY: number; @@ -54,10 +56,12 @@ export default abstract class UITouchHandlerBase { private scrollTouchState: ScrollState; private pendingTarget: Target; - constructor(baseElement: HTMLElement, rowClassMatch: string, selectedTargetMatch: string) { + constructor(baseElement: HTMLElement, scroller: HTMLElement, rowClassMatch: string, selectedTargetMatch: string) { this.baseElement = baseElement; this.rowClassMatch = rowClassMatch; this.selectedTargetMatch = selectedTargetMatch; + + this.scroller = scroller || null; } /** @@ -241,8 +245,7 @@ export default abstract class UITouchHandlerBase { } // Establish scroll tracking. - let shouldScroll = (this.currentTarget.clientWidth < this.currentTarget.scrollWidth); - this.scrollTouchState = shouldScroll ? new ScrollState(coord) : null; + this.scrollTouchState = new ScrollState(coord); // Alright, Target acquired! Now to use it: @@ -334,9 +337,13 @@ export default abstract class UITouchHandlerBase { return; } - if(this.currentTarget && this.scrollTouchState != null) { + if(this.scrollTouchState != null) { + // TODO: Work on smoothing this out; looks like subpixel scroll info gets rounded out, + // and this results in a mild desync. let deltaX = this.scrollTouchState.updateTo(coord).deltaX; - this.currentTarget.scrollLeft -= window.devicePixelRatio * deltaX; + if(this.scroller) { + this.scroller.scrollLeft -= deltaX; + } return; } diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index 0399991fe08..068bb35bd2f 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -625,6 +625,7 @@ export default abstract class OSKView extends EventEmitter implements if(!pending) { this.headerView?.refreshLayout(); this.bannerView.refreshLayout(); + this.bannerView.width = this.computedWidth; this.footerView?.refreshLayout(); } diff --git a/web/src/resources/osk/kmwosk.css b/web/src/resources/osk/kmwosk.css index 029a0f74665..fe8990f8fe6 100644 --- a/web/src/resources/osk/kmwosk.css +++ b/web/src/resources/osk/kmwosk.css @@ -66,6 +66,35 @@ font-size: 0.75em; } +.kmw-suggestion-text { + padding-left: 4px; + padding-right: 4px; +} + +.kmw-suggest-banner-scroller { + overflow-x: hidden; + width: 100%; + height: 100%; + scrollbar-width: none; /* Firefox scrollbar prevention */ +} + +.kmw-suggest-banner-scroller::-webkit-scrollbar { + display: none; /* Safari + Chrome scrollbar prevention */ +} + +.kmw-suggest-option { + overflow: hidden; +} + +.kmw-suggestion-container { + height: 100%; + transition: all 0.25s; +} + +.kmw-suggest-option.kmw-suggest-touched .kmw-suggestion-container { + margin-left: 0px !important /* Overrides 'collapse' styling, which is accomplished via negative margin-left */ +} + .phone.windows .kmw-key-row{max-width:80%;} .phone .kmw-5rows {padding-top: 0;} From b99ca9e7724401e449b0795f6eaf75c7af6517d5 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 21 Dec 2022 10:20:56 +0700 Subject: [PATCH 02/23] feat(web): spins getTextMetrics off as importable method, implements suggestion expansion structures --- .../engine/osk/src/banner/suggestionBanner.ts | 58 +++++-- .../osk/src/keyboard-layout/getTextMetrics.ts | 50 ++++++ .../engine/osk/src/keyboard-layout/oskKey.ts | 52 +----- web/src/resources/osk/kmwosk.css | 148 +++++++++++++++--- 4 files changed, 231 insertions(+), 77 deletions(-) create mode 100644 web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 9f97617489b..fd2530735e5 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -9,6 +9,9 @@ import UITouchHandlerBase from '../input/event-interpreter/uiTouchHandlerBase.js import { DeviceSpec, Keyboard, KeyboardProperties } from '@keymanapp/keyboard-processor'; import { Banner } from './banner.js'; import EventEmitter from 'eventemitter3'; +import { ParsedLengthStyle } from '../lengthStyle.js'; +import { getFontSizeStyle } from '../fontSizeUtils.js'; +import { getTextMetrics } from '../keyboard-layout/getTextMetrics.js'; export class BannerSuggestion { div: HTMLDivElement; @@ -81,17 +84,45 @@ export class BannerSuggestion { * Function update * @param {string} id Element ID for the suggestion span * @param {Suggestion} suggestion Suggestion from the lexical model + * @param fontStyle The CSS styling expected for the suggestion text + * @param emSize The font size represented by 1em (in px, as from getComputedStyle on document.body) + * @param targetWidth + * @param collapsedTargetWidth * Description Update the ID and text of the BannerSuggestionSpec */ - public update(suggestion: Suggestion) { + public update( + suggestion: Suggestion, + fontStyle: CSSStyleDeclaration, + emSize: number, + targetWidth: number, + collapsedTargetWidth: number + ) { this._suggestion = suggestion; - this.updateText(); - } - private updateText() { + // TODO: if the option is highlighted, maybe don't disable transitions? + this.container.style.transition = 'none'; // temporarily disable transition effects. + let display = this.generateSuggestionText(this.rtl); this.container.replaceChild(display, this.display); this.display = display; + + // Compute the raw text-width of the suggestion and determine specs for the default (collapsed) styling. + const optionCollapseStyle = this.container.style; + + const rawMetrics = getTextMetrics(suggestion.displayAs, emSize, fontStyle); + const rawTextWidth = rawMetrics.width; + optionCollapseStyle.minWidth = `${targetWidth}px`; + + if(rawTextWidth > collapsedTargetWidth) { + optionCollapseStyle.marginLeft = `${collapsedTargetWidth - rawTextWidth}px`; + } else { + optionCollapseStyle.marginLeft = '0px'; + } + + this.container.offsetWidth; // To 'flush' the changes before re-enabling transition animations. + this.container.offsetLeft; + + this.container.style.transition = ''; // Re-enable them (it's set on the element's class) } public isEmpty(): boolean { @@ -310,12 +341,21 @@ export class SuggestionBanner extends Banner { public onSuggestionUpdate = (suggestions: Suggestion[]): void => { this.currentSuggestions = suggestions; + const fontStyle = getComputedStyle(this.options[0].div); + const emSizeStr = getComputedStyle(document.body).fontSize; + const emSize = getFontSizeStyle(emSizeStr).val; + + const textStyle = getComputedStyle(this.options[0].container.firstChild as HTMLSpanElement); + + // TODO: polish up; do a calculation that leaves perfect, clean edges when displaying exactly three options. + const targetWidth = this.width / 3; // Not fancy; it'll leave rough edges. But... it'll do for a demo. + const textLeftPad = new ParsedLengthStyle(textStyle.paddingLeft || '2px'); // computedStyle will fail if the element's not in the DOM yet. + const textRightPad = new ParsedLengthStyle(textStyle.paddingRight || '2px'); + + const collapsedTargetWidth = targetWidth - textLeftPad.val - textRightPad.val; // Assumes fixed px padding. + this.options.forEach((option: BannerSuggestion, i: number) => { - if(i < suggestions.length) { - option.update(suggestions[i]); - } else { - option.update(null); - } + option.update(i < suggestions.length ? suggestions[i] : null, fontStyle, emSize, targetWidth, collapsedTargetWidth); }); } } diff --git a/web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts b/web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts new file mode 100644 index 00000000000..d7856ec2321 --- /dev/null +++ b/web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts @@ -0,0 +1,50 @@ +import { getFontSizeStyle } from "../fontSizeUtils.js"; + +let metricsCanvas: HTMLCanvasElement; + +/** + * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. + * + * @param {String} text The text to be rendered. + * @param {String} style The CSSStyleDeclaration for an element to measure against, without modification. + * + * @see https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 + * This version has been substantially modified to work for this particular application. + */ +export function getTextMetrics(text: string, emScale: number, style: {fontFamily?: string, fontSize: string}): TextMetrics { + // Since we may mutate the incoming style, let's make sure to copy it first. + // Only the relevant properties, though. + style = { + fontFamily: style.fontFamily, + fontSize: style.fontSize + }; + + // A final fallback - having the right font selected makes a world of difference. + if(!style.fontFamily) { + style.fontFamily = getComputedStyle(document.body).fontFamily; + } + + if(!style.fontSize || style.fontSize == "") { + style.fontSize = '1em'; + } + + let fontFamily = style.fontFamily; + let fontSpec = getFontSizeStyle(style.fontSize); + + var fontSize: string; + if(fontSpec.absolute) { + // We've already got an exact size - use it! + fontSize = fontSpec.val + 'px'; + } else { + fontSize = fontSpec.val * emScale + 'px'; + } + + // re-use canvas object for better performance + metricsCanvas = metricsCanvas ?? document.createElement("canvas"); + + var context = metricsCanvas.getContext("2d"); + context.font = fontSize + " " + fontFamily; + var metrics = context.measureText(text); + + return metrics; +} \ No newline at end of file diff --git a/web/src/engine/osk/src/keyboard-layout/oskKey.ts b/web/src/engine/osk/src/keyboard-layout/oskKey.ts index 77d8927b60a..56bed368d1c 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskKey.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskKey.ts @@ -9,6 +9,7 @@ import buttonClassNames from '../buttonClassNames.js'; import { KeyElement } from '../keyElement.js'; import VisualKeyboard from '../visualKeyboard.js'; +import { getTextMetrics } from './getTextMetrics.js'; export class OSKKeySpec implements LayoutKey { id: string; @@ -158,53 +159,6 @@ export default abstract class OSKKey { } } - /** - * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. - * - * @param {String} text The text to be rendered. - * @param {String} style The CSSStyleDeclaration for an element to measure against, without modification. - * - * @see https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 - * This version has been substantially modified to work for this particular application. - */ - static getTextMetrics(text: string, emScale: number, style: {fontFamily?: string, fontSize: string}): TextMetrics { - // Since we may mutate the incoming style, let's make sure to copy it first. - // Only the relevant properties, though. - style = { - fontFamily: style.fontFamily, - fontSize: style.fontSize - }; - - // A final fallback - having the right font selected makes a world of difference. - if(!style.fontFamily) { - style.fontFamily = getComputedStyle(document.body).fontFamily; - } - - if(!style.fontSize || style.fontSize == "") { - style.fontSize = '1em'; - } - - let fontFamily = style.fontFamily; - let fontSpec = getFontSizeStyle(style.fontSize); - - var fontSize: string; - if(fontSpec.absolute) { - // We've already got an exact size - use it! - fontSize = fontSpec.val + 'px'; - } else { - fontSize = fontSpec.val * emScale + 'px'; - } - - // re-use canvas object for better performance - var canvas: HTMLCanvasElement = OSKKey.getTextMetrics['canvas'] || - (OSKKey.getTextMetrics['canvas'] = document.createElement("canvas")); - var context = canvas.getContext("2d"); - context.font = fontSize + " " + fontFamily; - var metrics = context.measureText(text); - - return metrics; - } - /** * Calculate the font size required for a key cap, scaling to fit longer text * @param vkbd @@ -234,7 +188,7 @@ export default abstract class OSKKey { } let fontSpec = getFontSizeStyle(style.fontSize || '1em'); - let metrics = OSKKey.getTextMetrics(text, emScale, style); + let metrics = getTextMetrics(text, emScale, style); const MAX_X_PROPORTION = 0.90; const MAX_Y_PROPORTION = 0.90; @@ -378,7 +332,7 @@ export default abstract class OSKKey { // Check the key's display width - does the key visualize well? let emScale = vkbd.getKeyEmFontSize(); - var width: number = OSKKey.getTextMetrics(keyText, emScale, styleSpec).width; + var width: number = getTextMetrics(keyText, emScale, styleSpec).width; if(width == 0 && keyText != '' && keyText != '\xa0') { // Add the Unicode 'empty circle' as a base support for needy diacritics. diff --git a/web/src/resources/osk/kmwosk.css b/web/src/resources/osk/kmwosk.css index fe8990f8fe6..8a0e597095c 100644 --- a/web/src/resources/osk/kmwosk.css +++ b/web/src/resources/osk/kmwosk.css @@ -66,11 +66,6 @@ font-size: 0.75em; } -.kmw-suggestion-text { - padding-left: 4px; - padding-right: 4px; -} - .kmw-suggest-banner-scroller { overflow-x: hidden; width: 100%; @@ -110,7 +105,6 @@ .phone.ios .kmw-key.kmw-key-shift-on, .phone.ios .kmw-key.kmw-key-special-on {color:#fff;background-color:#88f;} .phone.ios .kmw-key.kmw-key-touched {background-color:#447;} -.phone.ios .kmw-suggest-option.kmw-suggest-touched {background-color:#88f;} .phone.ios .kmw-key-deadkey{color:#048204;background-color:#fdfdfe;} /* Probably best to make this its own CSS that can be optionally included? */ @@ -133,6 +127,14 @@ width: 100%; } +.ios .kmw-suggest-option::before { + background: linear-gradient(90deg, #cfd3d9 0%, transparent 100%); +} + +.ios .kmw-suggest-option::after { + background: linear-gradient(90deg, transparent 0%, #cfd3d9 100%); +} + .ios .kmw-banner-bar .kmw-suggest-option { display:inline-block; text-align: center; @@ -147,7 +149,18 @@ color: #000; } -.phone.ios .kmw-suggest-option.kmw-suggest-touched {background-color:#88f;} +.phone.ios .kmw-suggest-option.kmw-suggest-touched, +.tablet.ios .kmw-suggest-option.kmw-suggest-touched {background-color:#88f;} + +.phone.ios .kmw-suggest-option.kmw-suggest-touched::before, +.tablet.ios .kmw-suggest-option.kmw-suggest-touched::before { + background: linear-gradient(90deg, #88f 0%, transparent 100%); +} + +.phone.ios .kmw-suggest-option.kmw-suggest-touched::after, +.tablet.ios .kmw-suggest-option.kmw-suggest-touched::after { + background: linear-gradient(90deg, transparent 0%, #88f 100%); +} .phone.ios.kmw-osk-frame, .tablet.ios.kmw-osk-frame { @@ -165,6 +178,14 @@ background-color: #0f1319; } + .ios .kmw-suggest-option::before { + background: linear-gradient(90deg, #0f1319 0%, transparent 100%); + } + + .ios .kmw-suggest-option::after { + background: linear-gradient(90deg, transparent 0%, #0f1319 100%); + } + .ios .kmw-banner-bar .kmw-banner-separator { border-left: solid 1px #8a8d90 } @@ -203,6 +224,14 @@ width: 100%; } +.phone.android .kmw-suggest-option::before { + background: linear-gradient(90deg, #222 0%, transparent 100%); +} + +.phone.android .kmw-suggest-option::after { + background: linear-gradient(90deg, transparent 0%, #222 100%); +} + .phone.android .kmw-banner-bar .kmw-suggest-option { display:inline-block; text-align: center; @@ -215,6 +244,14 @@ .phone.android .kmw-suggest-option.kmw-suggest-touched {background-color:#bbb;} +.phone.android .kmw-suggest-option.kmw-suggest-touched::before { + background: linear-gradient(90deg, #bbb 0%, transparent 100%); +} + +.phone.android .kmw-suggest-option.kmw-suggest-touched::after { + background: linear-gradient(90deg, transparent 0%, #bbb 100%); +} + .tablet.kmw-osk-frame{left:0;bottom:0;width:100%;height:144px;overflow-y:visible; background-color:rgba(0,0,0,0.8);-webkit-user-select:none;} .tablet .kmw-osk-inner-frame{margin:0;background:transparent;} @@ -235,7 +272,6 @@ .tablet.ios .kmw-key.kmw-key-shift-on, .tablet.ios .kmw-key.kmw-key-special-on {color:#fff;background-color:#88f;} .tablet.ios .kmw-key.kmw-key-touched {background-color:#447;} -.tablet.ios .kmw-suggest-option.kmw-suggest-touched {background-color:#88f;} .tablet.ios .kmw-key-deadkey{color:#048204;background-color:#fdfdfe;} /* Probably best to make this its own CSS that can be optionally included? */ @@ -273,6 +309,14 @@ width: 100%; } +.tablet.android .kmw-suggest-option::before { + background: linear-gradient(90deg, #b4b4b8 0px, transparent 100%); +} + +.tablet.android .kmw-suggest-option::after { + background: linear-gradient(90deg, transparent 0%, #b4b4b8 100%); +} + .tablet.android .kmw-banner-bar .kmw-suggest-option { display:inline-block; text-align: center; @@ -282,11 +326,20 @@ .tablet.android .kmw-suggestion-text { color:#77f; } + .tablet.android .kmw-suggest-option.kmw-suggest-touched {background-color:#447;} +.tablet.android .kmw-suggest-option.kmw-suggest-touched::before { + background: linear-gradient(90deg, #447 0px, transparent 100%); +} + +.tablet.android .kmw-suggest-option.kmw-suggest-touched::after { + background: linear-gradient(90deg, transparent 0%, #447 100%); +} + /* Vertical centering of text labels on keys */ .kmw-key {text-align:center; white-space:nowrap;} -.kmw-key:before {content:'.'; display:inline-block; height:100%; vertical-align:middle; max-width:0px; visibility:hidden;} +.kmw-key::before {content:'.'; display:inline-block; height:100%; vertical-align:middle; max-width:0px; visibility:hidden;} .kmw-key span {display:inline-block} @@ -300,7 +353,7 @@ .desktop .kmw-key-label{position:absolute;left:2px;top:2px;font:0.5em Arial;color:#888;background-color:transparent;} /* Popup icon style (and content)*/ -.kmw-key-popup-icon:before{content:'\2022';} +.kmw-key-popup-icon::before{content:'\2022';} .kmw-key-popup-icon{position:absolute;display:block;visibility:visible;right:4%;top:1%;/*width:8px;height:8px;*/ font:bold 0.5em Arial;color:#aaa;} @@ -321,22 +374,79 @@ .kmw-footer-caption{color:#fff;font:0.7em Arial;margin:0 0 0 4px;} .kmw-banner-bar{height:100%; width:100%; margin:0; background-color:darkorange; display: inline-block; white-space: nowrap;} + +/* Creates a gradient to fade text at the borders, providing visual indication of overflow */ +/* Make sure the non-transparent color of the gradient matches .kmw-banner-bar's background-color. */ +.kmw-suggest-option::before, +.kmw-suggest-option::after { + position:absolute; + + /* Set scrollable-suggestion fade width here. Make sure to also set .kmw-suggestion-text + * padding-left and padding-right accordingly! + */ + width: 4px; + height: 100%; + content: ''; + top: 0; + z-index:10999; /* z-indexes this _behind_ the 'option' element that hosts the scrollable zone. */ + user-select: none; + pointer-events: none; /* Ensures click-through! But apparently not touch-through. */ + touch-action: none; /* Doesn't seem to allow touch-through, though - even with touch-action: none */ + /* https://stackoverflow.com/q/21474722 - poster never could find a solution, and settled*/ + /* on the same workaround: a 'before' and 'after' piece instead of a single overlay.*/ +} + +.kmw-suggest-option::before { + background: linear-gradient(90deg, darkorange 0%, transparent 100%); + left: 0; +} + +.kmw-suggest-option::after { + background: linear-gradient(90deg, transparent 0%, darkorange 100%); + right: 0; +} + +/* Fallback suggestion-selection highlighting */ +.kmw-suggest-option.kmw-suggest-touched { + background: #bbb; +} + +/* Creates a gradient to fade text at the borders, providing visual indication of overflow */ +/* Make sure the non-transparent color of the gradient matches .kmw-banner-bar's background-color. */ +.kmw-suggest-option.kmw-suggest-touched::before { + background: linear-gradient(90deg, #bbb 0%, transparent 100%); +} + +.kmw-suggest-option.kmw-suggest-touched::after { + background: linear-gradient(90deg, transparent 0%, #bbb 100%); +} + .kmw-banner-bar .kmw-banner-separator {border-left: solid 1px #8a8d90; width: 0px; vertical-align: middle; height: 45%; display: inline-block;} -.kmw-banner-bar .kmw-suggest-option {display:inline-block; text-align: center; height: 85%; overflow-x:hidden} -.kmw-suggestion-text{color:#fff; line-height: normal; position: relative; vertical-align: middle;} +.kmw-banner-bar .kmw-suggest-option {display:inline-block; text-align: center; height: 85%; position: relative; z-index: 11000} +.kmw-suggestion-text { + color:#fff; + line-height: normal; + position: relative; + vertical-align: middle; + padding-left: 4px; /* To prevent start & end of suggestion from being affected by gradient effects */ + padding-right: 4px; /* Set these to match scrollable-suggestion fade width set above. */ + width: max-content; /* Ensure the text span acts like it contains its text */ + min-width: calc(100% - 8px); /* To ensure the span stays centered; also adjusts for scrollable-suggestion fade width. */ + white-space: nowrap; +} .kmw-footer-resize{cursor:se-resize;position:absolute;right:2px;bottom:2px;width:16px;height:16px;overflow:hidden; font-family:SpecialOSK;color:white;} .kmw-footer-resize:hover{font-weight:bold;} -.kmw-footer-resize:before {content:'\e023';} +.kmw-footer-resize::before {content:'\e023';} .kmw-title-bar-image {cursor: default; float:right; padding: 2px 2px 0 0; width:16px; height:16px; font-family:SpecialOSK; color:white;} .kmw-title-bar-image:hover{font-weight:bold;} -#kmw-pin-image:before{content:'\e024';} -#kmw-config-image:before{content:'\e030';} -#kmw-help-image:before{content:'\e042';} -#kmw-close-button:before {content:'\e025';} +#kmw-pin-image::before{content:'\e024';} +#kmw-config-image::before{content:'\e030';} +#kmw-help-image::before{content:'\e042';} +#kmw-close-button::before {content:'\e025';} /* Common key appearance styles (can override with form-factor styles if necessary) */ .kmw-key-default{color:#000;background-color:#eee;} @@ -461,7 +571,7 @@ div.android div.kmw-keytip-cap { /* Box styles for keyboard-specific OSK (e.g. EuroLatin) and if no keyboard active (desktop only) */ .kmw-osk-static, .kmw-osk-none{text-align:left;font:12px sans-serif;border:solid 1px #ad4a28;color:blue;background-color:white;} .kmw-osk-none{padding:4px 6px 6px} -.kmw-osk-none:before{content:'Installing keyboard...';} +.kmw-osk-none::before{content:'Installing keyboard...';} /* OSK language menu styles */ #kmw-language-menu{position:fixed;left:0;width:232px;max-width:232px;z-index:10004;background-color:rgba(128,128,128,1); @@ -549,7 +659,7 @@ div.android div.kmw-keytip-cap { border:3px solid #ad4a28;border-radius:8px;text-align:center;padding:0px;background:white;} .kmw-alert-close{float:right; height:24px; width:24px; font:1em bold Arial,sans-serif;color:#ad4a28;} /*.kmw-alert-close{float:right; height:24px; width:24px; font:2em bold Arial,sans-serif;color:#ad4a28;} */ -.kmw-alert-close:before{content:'\00d7'} +.kmw-alert-close::before{content:'\00d7'} /*.kmw-alert-close{float:right;background:url('icons.gif') no-repeat -30px 0; height:13px; width:15px;}*/ .kmw-wait-text{clear:both; margin:4px;white-space:nowrap;} .kmw-wait-graphic{width:100%;min-height:19px;background:url('ajax-loader.gif') no-repeat;background-position:center top;} From 7177a495cf67bbf4e6a0d6cef424f0a0b8aa07cd Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 22 Dec 2022 11:06:30 +0700 Subject: [PATCH 03/23] feat(web): variable-width suggestions, padding-fill if total width too narrow --- .../engine/osk/src/banner/suggestionBanner.ts | 159 +++++++++++++++--- 1 file changed, 136 insertions(+), 23 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index fd2530735e5..89004b584d1 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -13,10 +13,25 @@ import { ParsedLengthStyle } from '../lengthStyle.js'; import { getFontSizeStyle } from '../fontSizeUtils.js'; import { getTextMetrics } from '../keyboard-layout/getTextMetrics.js'; +// TODO: finalize + document +interface OptionFormatSpec { + minWidth?: number; + paddingWidth: number, + emSize: number, + styleForFont: CSSStyleDeclaration + + collapsedWidth?: number +} export class BannerSuggestion { div: HTMLDivElement; container: HTMLDivElement; private display: HTMLSpanElement; + + private _collapsedWidth: number; + private _textWidth: number; + private _minWidth: number; + private _paddingWidth: number; + private fontFamily?: string; private rtl: boolean = false; @@ -90,41 +105,95 @@ export class BannerSuggestion { * @param collapsedTargetWidth * Description Update the ID and text of the BannerSuggestionSpec */ - public update( - suggestion: Suggestion, - fontStyle: CSSStyleDeclaration, - emSize: number, - targetWidth: number, - collapsedTargetWidth: number - ) { + public update(suggestion: Suggestion, format: OptionFormatSpec) { this._suggestion = suggestion; - // TODO: if the option is highlighted, maybe don't disable transitions? - this.container.style.transition = 'none'; // temporarily disable transition effects. - let display = this.generateSuggestionText(this.rtl); this.container.replaceChild(display, this.display); this.display = display; - // Compute the raw text-width of the suggestion and determine specs for the default (collapsed) styling. - const optionCollapseStyle = this.container.style; + // Set internal properties for use in format calculations. + if(format.minWidth !== undefined) { + this._minWidth = format.minWidth; + } + + this._paddingWidth = format.paddingWidth; + this._collapsedWidth = format.collapsedWidth; - const rawMetrics = getTextMetrics(suggestion.displayAs, emSize, fontStyle); - const rawTextWidth = rawMetrics.width; - optionCollapseStyle.minWidth = `${targetWidth}px`; + if(suggestion && suggestion.displayAs) { + const rawMetrics = getTextMetrics(suggestion.displayAs, format.emSize, format.styleForFont); + this._textWidth = rawMetrics.width; + } else { + this._textWidth = 0; + } - if(rawTextWidth > collapsedTargetWidth) { - optionCollapseStyle.marginLeft = `${collapsedTargetWidth - rawTextWidth}px`; + this.updateLayout(); + } + + public updateLayout() { + if(!this.suggestion && this.index != 0) { + this.div.style.width='0px'; + return; } else { - optionCollapseStyle.marginLeft = '0px'; + this.div.style.width=''; } + // TODO: if the option is highlighted, maybe don't disable transitions? + this.container.style.transition = 'none'; // temporarily disable transition effects. + + const collapserStyle = this.container.style; + collapserStyle.minWidth = this.collapsedWidth + 'px'; + collapserStyle.marginLeft = (this.collapsedWidth - this.expandedWidth) + 'px'; + this.container.offsetWidth; // To 'flush' the changes before re-enabling transition animations. this.container.offsetLeft; this.container.style.transition = ''; // Re-enable them (it's set on the element's class) } + + + public get targetCollapsedWidth(): number { + return this._collapsedWidth; + } + + public get textWidth(): number { + return this._textWidth; + } + + public get paddingWidth(): number { + return this._paddingWidth; + } + + public get minWidth(): number { + return this._minWidth; + } + + public set minWidth(val: number) { + this._minWidth = val; + } + + public get expandedWidth(): number { + // minWidth must be defined AND greater for the conditional to return this.minWidth. + return this.minWidth > this.spanWidth ? this.minWidth : this.spanWidth; + } + + public get spanWidth(): number { + let spanWidth = this.textWidth ?? 0; + if(spanWidth) { + spanWidth += this.paddingWidth ?? 0; + } + + return spanWidth; + } + + public get collapsedWidth(): number { + let maxWidth = this.targetCollapsedWidth < this.expandedWidth ? this.targetCollapsedWidth : this.expandedWidth; + + // Will return maxWidth if this.minWidth is undefined. + return (this.minWidth > maxWidth ? this.minWidth : maxWidth); + } + public isEmpty(): boolean { return !this._suggestion; } @@ -177,6 +246,8 @@ export class SuggestionBanner extends Banner { private currentSuggestions: Suggestion[] = []; private options : BannerSuggestion[] = []; + private separators: HTMLElement[] = []; + private hostDevice: DeviceSpec; private manager: SuggestionInputManager; @@ -211,8 +282,10 @@ export class SuggestionBanner extends Banner { buildInternals(rtl: boolean) { if(this.options.length > 0) { - this.options.splice(0, this.options.length); // Clear the array. + this.options = []; + this.separators = []; } + for (var i=0; i { - option.update(i < suggestions.length ? suggestions[i] : null, fontStyle, emSize, targetWidth, collapsedTargetWidth); - }); + let totalWidth = 0; + let displayCount = 0; + + for (let i=0; i i) { + const suggestion = suggestions[i]; + d.update(suggestion, optionFormat); + + totalWidth += d.collapsedWidth; + displayCount++; + } else { + d.update(null, optionFormat); + } + } + + // Ensure one suggestion is always displayed, even if empty. (Keep the separators out) + displayCount = displayCount || 1; + + if(totalWidth < this.width) { + let separatorWidth = (this.width * 0.01 * (displayCount-1)); + let fillPadding = (this.width - totalWidth - separatorWidth) / displayCount; + + for(let i=0; i < displayCount; i++) { + const d = this.options[i]; + + d.minWidth = d.collapsedWidth + fillPadding; + d.updateLayout(); + } + } + + // Hide any separators beyond the final displayed suggestion + for(let i=0; i < SuggestionBanner.SUGGESTION_LIMIT - 1; i++) { + this.separators[i].style.display = i < displayCount - 1 ? '' : 'none'; + } } } From 870e7341c0bf4d66e480ad9f85601120a73c3cbc Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 23 Dec 2022 13:41:08 +0700 Subject: [PATCH 04/23] fix(web): basic rtl handling for new features --- web/src/engine/osk/src/banner/suggestionBanner.ts | 7 ++++++- web/src/resources/osk/kmwosk.css | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 89004b584d1..357bc11b332 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -143,7 +143,12 @@ export class BannerSuggestion { const collapserStyle = this.container.style; collapserStyle.minWidth = this.collapsedWidth + 'px'; - collapserStyle.marginLeft = (this.collapsedWidth - this.expandedWidth) + 'px'; + + if(this.rtl) { + collapserStyle.marginRight = (this.collapsedWidth - this.expandedWidth) + 'px'; + } else { + collapserStyle.marginLeft = (this.collapsedWidth - this.expandedWidth) + 'px'; + } this.container.offsetWidth; // To 'flush' the changes before re-enabling transition animations. this.container.offsetLeft; diff --git a/web/src/resources/osk/kmwosk.css b/web/src/resources/osk/kmwosk.css index 8a0e597095c..ccec96d798b 100644 --- a/web/src/resources/osk/kmwosk.css +++ b/web/src/resources/osk/kmwosk.css @@ -87,7 +87,8 @@ } .kmw-suggest-option.kmw-suggest-touched .kmw-suggestion-container { - margin-left: 0px !important /* Overrides 'collapse' styling, which is accomplished via negative margin-left */ + margin-left: 0px !important; /* Overrides 'collapse' styling, which is accomplished via negative margin-left */ + margin-right: 0px !important; /* The same, but for RTL languages */ } .phone.windows .kmw-key-row{max-width:80%;} From b76b8714760996d24c03a7a3d21e7954a5843bb1 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 4 Jan 2023 14:40:41 +0700 Subject: [PATCH 05/23] change(web): js-side animation, LTR counterscrolling on suggestion expansion --- .../engine/osk/src/banner/suggestionBanner.ts | 207 +++++++++++++++++- .../event-interpreter/uiTouchHandlerBase.ts | 40 ++-- web/src/resources/osk/kmwosk.css | 6 - 3 files changed, 230 insertions(+), 23 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 357bc11b332..123b5201c34 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -156,8 +156,6 @@ export class BannerSuggestion { this.container.style.transition = ''; // Re-enable them (it's set on the element's class) } - - public get targetCollapsedWidth(): number { return this._collapsedWidth; } @@ -199,6 +197,20 @@ export class BannerSuggestion { return (this.minWidth > maxWidth ? this.minWidth : maxWidth); } + public get currentWidth(): number { + return this.div.offsetWidth; + } + + public set currentWidth(val: number) { + // TODO: probably should set up errors or something here... + if(val < this.collapsedWidth) { + val = this.collapsedWidth; + } else if(val > this.expandedWidth) { + val = this.expandedWidth; + } + this.container.style.marginLeft = `${val - this.expandedWidth}px`; + } + public isEmpty(): boolean { return !this._suggestion; } @@ -257,6 +269,7 @@ export class SuggestionBanner extends Banner { private manager: SuggestionInputManager; private readonly container: HTMLElement; + private highlightAnimation: SuggestionExpandContractAnimation; readonly type = 'suggestion'; @@ -307,6 +320,11 @@ export class SuggestionBanner extends Banner { let indexToInsert = rtl ? SuggestionBanner.SUGGESTION_LIMIT - i -1 : i; this.container.appendChild(this.options[indexToInsert].div); + // RTL should start right-aligned, thus @ max scroll. + if(rtl) { + this.container.scrollLeft = this.container.scrollWidth; + } + if(i != SuggestionBanner.SUGGESTION_LIMIT - 1) { // Adds a 'separator' div element for UI purposes. let separatorDiv = createUnselectableElement('div'); @@ -340,8 +358,24 @@ export class SuggestionBanner extends Banner { if(on && classes.indexOf(cs) < 0) { elem.className=classes+cs; + if(this.highlightAnimation) { + this.highlightAnimation.decouple(); + } + + this.highlightAnimation = new SuggestionExpandContractAnimation(this.container, suggestion, false); + this.highlightAnimation.expand(); } else { elem.className=classes.replace(cs,''); + if(!this.highlightAnimation) { + this.highlightAnimation = new SuggestionExpandContractAnimation(this.container, suggestion, false); + } + this.highlightAnimation.collapse(); + } + }); + + this.manager.events.on('scrollLeft', (val) => { + if(this.highlightAnimation) { + this.highlightAnimation.setBaseScroll(val); } }); @@ -482,6 +516,171 @@ interface SuggestionInputEventMap { highlight: (bannerSuggestion: BannerSuggestion, state: boolean) => void, apply: (bannerSuggestion: BannerSuggestion) => void; hold: (bannerSuggestion: BannerSuggestion) => void; + scrollLeft: (val: number) => void; +} + + +class SuggestionExpandContractAnimation { + private scrollContainer: HTMLElement | null; + private option: BannerSuggestion; + + private collapsedScrollLeft: number; + + private startTimestamp: number; + private pendingAnimation: number; + + private static TRANSITION_TIME = 250; // in ms. + + constructor(scrollContainer: HTMLElement, option: BannerSuggestion, forRTL: boolean) { + this.scrollContainer = scrollContainer; + this.option = option; + this.collapsedScrollLeft = scrollContainer.scrollLeft; + } + + public setBaseScroll(val: number) { + this.collapsedScrollLeft = val; + + // Attempt to sync the banner-scroller's offset update with that of the + // animation for expansion and collapsing. + window.requestAnimationFrame(this.setOffsetScroll); + + // this.setOffsetScroll(); + } + + // the "fun", top-level banner part. + private setOffsetScroll = () => { + // If we've been 'decoupled', a different instance (likely for a different suggestion) + // is responsible for counter-scrolling. + if(!this.scrollContainer) { + return; + } + const baseScrollOffset = this.option.currentWidth - this.option.collapsedWidth; + + // TODO: clamping logic + + let finalTargetScrollLeft = this.collapsedScrollLeft + baseScrollOffset; + this.scrollContainer.scrollLeft = finalTargetScrollLeft; + + // Prevent "jitters" during counterscroll that occur on expansion / collapse animation. + // A one-frame "error correction" effect at the end of animation is far less jarring. + if(this.pendingAnimation) { + // scrollLeft doesn't work well with fractional values, unlike marginLeft / marginRight + let fractionalOffset = this.scrollContainer.scrollLeft - finalTargetScrollLeft + // So we put the fractional difference into marginLeft to force it to sync. + this.option.currentWidth += fractionalOffset; + } + } + + public decouple() { + this.scrollContainer = null; + } + + private clear() { + this.startTimestamp = null; + window.cancelAnimationFrame(this.pendingAnimation); + this.pendingAnimation = null; + } + + public expand() { + // Cancel any prior iterating animation-frame commands. + this.clear(); + + // set timestamp, adjusting the current time based on intermediate progress + this.startTimestamp = performance.now(); + + let progress = this.option.currentWidth - this.option.collapsedWidth; + let expansionDiff = this.option.expandedWidth - this.option.collapsedWidth; + + if(progress != 0) { + // Offset the timestamp by noting what start time would have given rise to + // the current position, keeping related animations smooth. + this.startTimestamp -= (progress / expansionDiff) * SuggestionExpandContractAnimation.TRANSITION_TIME; + } + + this.pendingAnimation = window.requestAnimationFrame(this._expand); + } + + private _expand = (timestamp: number) => { + if(this.startTimestamp === undefined) { + return; // No active expand op exists. May have been cancelled via `clear`. + } + + let progressTime = timestamp - this.startTimestamp; + let fin = progressTime > SuggestionExpandContractAnimation.TRANSITION_TIME; + + if(fin) { + progressTime = SuggestionExpandContractAnimation.TRANSITION_TIME; + } + + // -- Part 1: handle option expand / collapse state -- + let expansionDiff = this.option.expandedWidth - this.option.collapsedWidth; + let expansionRatio = progressTime / SuggestionExpandContractAnimation.TRANSITION_TIME; + + // expansionDiff * expansionRatio: the total adjustment from 'collapsed' width, in px. + const expansionPx = expansionDiff * expansionRatio; + this.option.currentWidth = expansionPx + this.option.collapsedWidth; + + // Part 2: trigger the next animation frame. + if(!fin) { + this.pendingAnimation = window.requestAnimationFrame(this._expand); + } else { + this.clear(); + } + + // Part 3: perform any needed counter-scrolling, scroll clamping, etc + // Existence of a followup animation frame is part of the logic, so keep this 'after'! + this.setOffsetScroll(); + }; + + public collapse() { + // Cancel any prior iterating animation-frame commands. + this.clear(); + + // set timestamp, adjusting the current time based on intermediate progress + this.startTimestamp = performance.now(); + + let progress = this.option.expandedWidth - this.option.currentWidth; + let expansionDiff = this.option.expandedWidth - this.option.collapsedWidth; + + if(progress != 0) { + // Offset the timestamp by noting what start time would have given rise to + // the current position, keeping related animations smooth. + this.startTimestamp -= (progress / expansionDiff) * SuggestionExpandContractAnimation.TRANSITION_TIME; + } + + this.pendingAnimation = window.requestAnimationFrame(this._collapse); + } + + private _collapse = (timestamp: number) => { + if(this.startTimestamp === undefined) { + return; // No active collapse op exists. May have been cancelled via `clear`. + } + + let progressTime = timestamp - this.startTimestamp; + let fin = progressTime > SuggestionExpandContractAnimation.TRANSITION_TIME; + if(fin) { + progressTime = SuggestionExpandContractAnimation.TRANSITION_TIME; + } + + // -- Part 1: handle option expand / collapse state -- + let expansionDiff = this.option.expandedWidth - this.option.collapsedWidth; + let expansionRatio = 1 - progressTime / SuggestionExpandContractAnimation.TRANSITION_TIME; + + // expansionDiff * expansionRatio: the total adjustment from 'collapsed' width, in px. + const expansionPx = expansionDiff * expansionRatio; + this.option.currentWidth = expansionPx + this.option.collapsedWidth; + + // Part 2: trigger the next animation frame. + if(!fin) { + this.pendingAnimation = window.requestAnimationFrame(this._collapse); + } else { + this.clear(); + } + + // Part 3: perform any needed counter-scrolling, scroll clamping, etc + // Existence of a followup animation frame is part of the logic, so keep this 'after'! + this.setOffsetScroll(); + }; } class SuggestionInputManager extends UITouchHandlerBase { @@ -521,6 +720,10 @@ class SuggestionInputManager extends UITouchHandlerBase { return null; } + protected onScrollLeftUpdate(val: number): void { + this.events.emit('scrollLeft', val); + } + protected highlight(t: HTMLDivElement, on: boolean): void { let suggestion = t['suggestion'] as BannerSuggestion; diff --git a/web/src/engine/osk/src/input/event-interpreter/uiTouchHandlerBase.ts b/web/src/engine/osk/src/input/event-interpreter/uiTouchHandlerBase.ts index 0b4956280d7..c1e73c62484 100644 --- a/web/src/engine/osk/src/input/event-interpreter/uiTouchHandlerBase.ts +++ b/web/src/engine/osk/src/input/event-interpreter/uiTouchHandlerBase.ts @@ -9,27 +9,33 @@ import { getAbsoluteY } from 'keyman/engine/dom-utils'; * same method blocks native handling of overflow scrolling for touch browsers. */ class ScrollState { - // While we don't currently track y-coordinates here, the class is designed - // to permit tracking them with minimal extra effort if we ever decide to do so. - x: number; totalLength = 0; + baseCoord: InputEventCoordinate; + curCoord: InputEventCoordinate; + baseScrollLeft: number; + // The amount of coordinate 'noise' allowed during a scroll-enabled touch allowed // before interpreting the currently-ongoing touch command as having scrolled. static readonly HAS_SCROLLED_FUDGE_FACTOR = 10; - constructor(coord: InputEventCoordinate) { - this.x = coord.x; + constructor(coord: InputEventCoordinate, baseScrollLeft: number) { + this.baseCoord = coord; + this.curCoord = coord; + this.baseScrollLeft = baseScrollLeft; this.totalLength = 0; } - updateTo(coord: InputEventCoordinate): {deltaX: number} { - let x = this.x; - this.x = coord.x; + updateTo(coord: InputEventCoordinate): {scrollLeft: number} { + let prevCoord = this.curCoord; + this.curCoord = coord; - let deltas = {deltaX: this.x - x}; - this.totalLength += Math.abs(deltas.deltaX); + let deltas = { + scrollLeft: this.baseCoord.x - this.curCoord.x + this.baseScrollLeft + }; + // Track the total amount of scrolling used, even if just a pixel-wide back and forth wiggle. + this.totalLength += Math.abs(this.curCoord.x - prevCoord.x); return deltas; } @@ -230,6 +236,10 @@ export default abstract class UITouchHandlerBase { return false; } + protected onScrollLeftUpdate(val: number) { + this.scroller.scrollLeft = val; + } + touchStart(coord: InputEventCoordinate) { // Determine the selected Target, manage state. this.currentTarget = this.findBestTarget(coord); @@ -245,7 +255,9 @@ export default abstract class UITouchHandlerBase { } // Establish scroll tracking. - this.scrollTouchState = new ScrollState(coord); + if(this.scroller) { + this.scrollTouchState = new ScrollState(coord, this.scroller.scrollLeft); + } // Alright, Target acquired! Now to use it: @@ -338,11 +350,9 @@ export default abstract class UITouchHandlerBase { } if(this.scrollTouchState != null) { - // TODO: Work on smoothing this out; looks like subpixel scroll info gets rounded out, - // and this results in a mild desync. - let deltaX = this.scrollTouchState.updateTo(coord).deltaX; if(this.scroller) { - this.scroller.scrollLeft -= deltaX; + const scrollUpdate = this.scrollTouchState.updateTo(coord); + this.onScrollLeftUpdate(scrollUpdate.scrollLeft); } return; diff --git a/web/src/resources/osk/kmwosk.css b/web/src/resources/osk/kmwosk.css index ccec96d798b..c608030fe5d 100644 --- a/web/src/resources/osk/kmwosk.css +++ b/web/src/resources/osk/kmwosk.css @@ -83,12 +83,6 @@ .kmw-suggestion-container { height: 100%; - transition: all 0.25s; -} - -.kmw-suggest-option.kmw-suggest-touched .kmw-suggestion-container { - margin-left: 0px !important; /* Overrides 'collapse' styling, which is accomplished via negative margin-left */ - margin-right: 0px !important; /* The same, but for RTL languages */ } .phone.windows .kmw-key-row{max-width:80%;} From a46b203e3d70ed6180bb946d0e199b0d85c5a26a Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 6 Jan 2023 08:56:51 +0700 Subject: [PATCH 06/23] feat(web): scroll offset to promote expanded option visibility (LTR only) --- .../engine/osk/src/banner/suggestionBanner.ts | 57 +++++++++++++++++-- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 123b5201c34..08cb0dc34e6 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -525,6 +525,7 @@ class SuggestionExpandContractAnimation { private option: BannerSuggestion; private collapsedScrollLeft: number; + private originalScrollLeft: number; private startTimestamp: number; private pendingAnimation: number; @@ -535,6 +536,7 @@ class SuggestionExpandContractAnimation { this.scrollContainer = scrollContainer; this.option = option; this.collapsedScrollLeft = scrollContainer.scrollLeft; + this.originalScrollLeft = scrollContainer.scrollLeft; } public setBaseScroll(val: number) { @@ -554,18 +556,61 @@ class SuggestionExpandContractAnimation { if(!this.scrollContainer) { return; } - const baseScrollOffset = this.option.currentWidth - this.option.collapsedWidth; - // TODO: clamping logic + // -- Clamping logic -- - let finalTargetScrollLeft = this.collapsedScrollLeft + baseScrollOffset; - this.scrollContainer.scrollLeft = finalTargetScrollLeft; + // As currently written / defined below, "clamping" refers to alterations to scroll-positioned mapping designed + // to keep as much of the expanded option visible as possible via the offsets below while not pushing + // already-obscured parts of the expanded option into visible range. + // + // In essence, it's an extra offset we apply that is dynamically adjusted depending on scroll position as it changes. + // This offset may be decreased when it is no longer needed to make parts of the element visible. + + // The amount of extra space being taken by a partially or completely expanded suggestion. + const maxWidthToCounterscroll = this.option.currentWidth - this.option.collapsedWidth; + + // How much space existed to the left of the collapsed option in its original position. May be negative. + const originalCounterscrollBuffer = this.option.div.offsetLeft - this.originalScrollLeft; + // TODO: RTL version + + // Only allow a negative buffer in the final positioning if it already existed. + // And only as much as originally existed. + const srcCounterscrollOverflow = -Math.min(originalCounterscrollBuffer, 0); // positive offset into overflow-land. + + // Base position for scrollLeft clamped within std element scroll bounds, including: + // - an adjustment to cover the extra width from expansion + // - preserving the base expected overflow levels + const unclampedExpandingScrollOffset = Math.max(this.collapsedScrollLeft + maxWidthToCounterscroll, 0) - srcCounterscrollOverflow; + const srcUnclampedExpandingScrollOffset = Math.max(this.originalScrollLeft + maxWidthToCounterscroll, 0) - srcCounterscrollOverflow; + // TODO: RTL versions / calculations + logic + + // // Huh - first bugless version didn't actually end up using this. + // const collapsedScrollLeftDelta = this.originalScrollLeft - (unclampedExpandingScrollOffset - maxWidthToCounterscroll + srcCounterscrollOverflow); // neg if touchpoint moving left, + // // pos if touchpoint moving right + // // - scroll moves opposite ("natural") + // // TODO: May need a similar thing for RTL handling. + + // Do not shift an element clipped by the screen border further than its original scroll starting point. + const elementLeftOffsetForClamping = Math.min(unclampedExpandingScrollOffset, srcUnclampedExpandingScrollOffset); + + // Based on the scroll point selected, determine how far to offset scrolls to keep the option in visible range. + // Higher .scrollLeft values make this non-zero and reflect when scroll has begun clipping the element. + const elementLeftOffsetFromBorder = Math.max(elementLeftOffsetForClamping - this.option.div.offsetLeft, 0); + + const clampedExpandingScrollOffset = Math.min(maxWidthToCounterscroll, elementLeftOffsetFromBorder); + + const clampedScrollLeft = unclampedExpandingScrollOffset // base scroll-coordinate transform mapping based on extra width from element expansion + - clampedExpandingScrollOffset // offset to scroll to put word-start border against the corresponding screen border, fully visible + + srcCounterscrollOverflow; // offset to maintain original overflow past that border if it existed + + // -- Final step: Apply & fine-tune the final scroll positioning -- + this.scrollContainer.scrollLeft = clampedScrollLeft; - // Prevent "jitters" during counterscroll that occur on expansion / collapse animation. + // Prevent "jitters" during counterscroll that occur on expansion / collapse animation. // A one-frame "error correction" effect at the end of animation is far less jarring. if(this.pendingAnimation) { // scrollLeft doesn't work well with fractional values, unlike marginLeft / marginRight - let fractionalOffset = this.scrollContainer.scrollLeft - finalTargetScrollLeft + const fractionalOffset = this.scrollContainer.scrollLeft - clampedScrollLeft; // So we put the fractional difference into marginLeft to force it to sync. this.option.currentWidth += fractionalOffset; } From 6d54e08d7baee8a2fef0da66ce4f4e99a0e1d495 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 6 Jan 2023 09:06:40 +0700 Subject: [PATCH 07/23] feat(web): visibility offset cancellation after manual scroll to corresponding area --- .../engine/osk/src/banner/suggestionBanner.ts | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 08cb0dc34e6..dc5b02261e0 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -33,7 +33,7 @@ export class BannerSuggestion { private _paddingWidth: number; private fontFamily?: string; - private rtl: boolean = false; + public readonly rtl: boolean; private _suggestion: Suggestion; @@ -43,7 +43,7 @@ export class BannerSuggestion { constructor(index: number, isRTL: boolean) { this.index = index; - this.rtl = isRTL; + this.rtl = isRTL ?? false; this.constructRoot(); @@ -524,8 +524,8 @@ class SuggestionExpandContractAnimation { private scrollContainer: HTMLElement | null; private option: BannerSuggestion; - private collapsedScrollLeft: number; - private originalScrollLeft: number; + private collapsedScrollOffset: number; + private rootScrollOffset: number; private startTimestamp: number; private pendingAnimation: number; @@ -535,12 +535,20 @@ class SuggestionExpandContractAnimation { constructor(scrollContainer: HTMLElement, option: BannerSuggestion, forRTL: boolean) { this.scrollContainer = scrollContainer; this.option = option; - this.collapsedScrollLeft = scrollContainer.scrollLeft; - this.originalScrollLeft = scrollContainer.scrollLeft; + this.collapsedScrollOffset = scrollContainer.scrollLeft; + this.rootScrollOffset = scrollContainer.scrollLeft; } public setBaseScroll(val: number) { - this.collapsedScrollLeft = val; + this.collapsedScrollOffset = val; + + // If the user has shifted right to make more of the element visible, we can remove part of the corresponding + // scrolling offset permanently; the user's taken action to view that area. + if(!this.option.rtl) { + if(val < this.rootScrollOffset) { + this.rootScrollOffset = val; + } + } // TODO: else for the RTL adjustment instead. // Attempt to sync the banner-scroller's offset update with that of the // animation for expansion and collapsing. @@ -570,7 +578,7 @@ class SuggestionExpandContractAnimation { const maxWidthToCounterscroll = this.option.currentWidth - this.option.collapsedWidth; // How much space existed to the left of the collapsed option in its original position. May be negative. - const originalCounterscrollBuffer = this.option.div.offsetLeft - this.originalScrollLeft; + const originalCounterscrollBuffer = this.option.div.offsetLeft - this.rootScrollOffset; // TODO: RTL version // Only allow a negative buffer in the final positioning if it already existed. @@ -580,8 +588,8 @@ class SuggestionExpandContractAnimation { // Base position for scrollLeft clamped within std element scroll bounds, including: // - an adjustment to cover the extra width from expansion // - preserving the base expected overflow levels - const unclampedExpandingScrollOffset = Math.max(this.collapsedScrollLeft + maxWidthToCounterscroll, 0) - srcCounterscrollOverflow; - const srcUnclampedExpandingScrollOffset = Math.max(this.originalScrollLeft + maxWidthToCounterscroll, 0) - srcCounterscrollOverflow; + const unclampedExpandingScrollOffset = Math.max(this.collapsedScrollOffset + maxWidthToCounterscroll, 0) - srcCounterscrollOverflow; + const srcUnclampedExpandingScrollOffset = Math.max(this.rootScrollOffset + maxWidthToCounterscroll, 0) - srcCounterscrollOverflow; // TODO: RTL versions / calculations + logic // // Huh - first bugless version didn't actually end up using this. From 40488693e8737115b6ff61c4871828a8ec98f6e9 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 6 Jan 2023 11:11:12 +0700 Subject: [PATCH 08/23] feat(web): similar handling for RTL --- .../engine/osk/src/banner/suggestionBanner.ts | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index dc5b02261e0..4c3271a0507 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -208,7 +208,12 @@ export class BannerSuggestion { } else if(val > this.expandedWidth) { val = this.expandedWidth; } - this.container.style.marginLeft = `${val - this.expandedWidth}px`; + + if(this.rtl) { + this.container.style.marginRight = `${val - this.expandedWidth}px`; + } else { + this.container.style.marginLeft = `${val - this.expandedWidth}px`; + } } public isEmpty(): boolean { @@ -542,9 +547,15 @@ class SuggestionExpandContractAnimation { public setBaseScroll(val: number) { this.collapsedScrollOffset = val; - // If the user has shifted right to make more of the element visible, we can remove part of the corresponding - // scrolling offset permanently; the user's taken action to view that area. - if(!this.option.rtl) { + // If the user has shifted the scroll position to make more of the element visible, we can remove part + // of the corresponding scrolling offset permanently; the user's taken action to view that area. + if(this.option.rtl) { + // A higher scrollLeft (scrolling right) will reveal more of an initially-clipped suggestion. + if(val > this.rootScrollOffset) { + this.rootScrollOffset = val; + } + } else { + // Here, a lower scrollLeft (scrolling left). if(val < this.rootScrollOffset) { this.rootScrollOffset = val; } @@ -576,40 +587,38 @@ class SuggestionExpandContractAnimation { // The amount of extra space being taken by a partially or completely expanded suggestion. const maxWidthToCounterscroll = this.option.currentWidth - this.option.collapsedWidth; + const rtl = this.option.rtl; - // How much space existed to the left of the collapsed option in its original position. May be negative. - const originalCounterscrollBuffer = this.option.div.offsetLeft - this.rootScrollOffset; - // TODO: RTL version + // If non-zero, indicates the pixel-width of the collapsed form of the suggestion clipped by the relevant screen border. + const ltrOverflow = Math.max(this.rootScrollOffset - this.option.div.offsetLeft, 0); + const rtlOverflow = Math.max(this.option.div.offsetLeft + this.option.collapsedWidth - (this.rootScrollOffset + this.scrollContainer.offsetWidth)); - // Only allow a negative buffer in the final positioning if it already existed. - // And only as much as originally existed. - const srcCounterscrollOverflow = -Math.min(originalCounterscrollBuffer, 0); // positive offset into overflow-land. + const srcCounterscrollOverflow = Math.max(rtl ? rtlOverflow : ltrOverflow, 0); // positive offset into overflow-land. // Base position for scrollLeft clamped within std element scroll bounds, including: // - an adjustment to cover the extra width from expansion // - preserving the base expected overflow levels - const unclampedExpandingScrollOffset = Math.max(this.collapsedScrollOffset + maxWidthToCounterscroll, 0) - srcCounterscrollOverflow; - const srcUnclampedExpandingScrollOffset = Math.max(this.rootScrollOffset + maxWidthToCounterscroll, 0) - srcCounterscrollOverflow; - // TODO: RTL versions / calculations + logic - - // // Huh - first bugless version didn't actually end up using this. - // const collapsedScrollLeftDelta = this.originalScrollLeft - (unclampedExpandingScrollOffset - maxWidthToCounterscroll + srcCounterscrollOverflow); // neg if touchpoint moving left, - // // pos if touchpoint moving right - // // - scroll moves opposite ("natural") - // // TODO: May need a similar thing for RTL handling. + const unclampedExpandingScrollOffset = Math.max(this.collapsedScrollOffset + (rtl ? 0 : 1) * maxWidthToCounterscroll, 0) + (rtl ? 0 : -1) * srcCounterscrollOverflow; + const srcUnclampedExpandingScrollOffset = Math.max(this.rootScrollOffset + (rtl ? 0 : 1) * maxWidthToCounterscroll, 0) + (rtl ? 0 : -1) * srcCounterscrollOverflow; // Do not shift an element clipped by the screen border further than its original scroll starting point. - const elementLeftOffsetForClamping = Math.min(unclampedExpandingScrollOffset, srcUnclampedExpandingScrollOffset); + const elementOffsetForClamping = rtl + ? Math.max(unclampedExpandingScrollOffset, srcUnclampedExpandingScrollOffset) + : Math.min(unclampedExpandingScrollOffset, srcUnclampedExpandingScrollOffset); // Based on the scroll point selected, determine how far to offset scrolls to keep the option in visible range. // Higher .scrollLeft values make this non-zero and reflect when scroll has begun clipping the element. - const elementLeftOffsetFromBorder = Math.max(elementLeftOffsetForClamping - this.option.div.offsetLeft, 0); + const elementOffsetFromBorder = rtl + // RTL offset: "offsetRight" based on "scrollRight" + ? Math.max(this.option.div.offsetLeft + this.option.currentWidth - (elementOffsetForClamping + this.scrollContainer.offsetWidth), 0) // double-check this one. + // LTR: based on scrollLeft offsetLeft + : Math.max(elementOffsetForClamping - this.option.div.offsetLeft, 0); - const clampedExpandingScrollOffset = Math.min(maxWidthToCounterscroll, elementLeftOffsetFromBorder); + const clampedExpandingScrollOffset = Math.min(maxWidthToCounterscroll, elementOffsetFromBorder); const clampedScrollLeft = unclampedExpandingScrollOffset // base scroll-coordinate transform mapping based on extra width from element expansion - - clampedExpandingScrollOffset // offset to scroll to put word-start border against the corresponding screen border, fully visible - + srcCounterscrollOverflow; // offset to maintain original overflow past that border if it existed + + (rtl ? 1 : -1) * clampedExpandingScrollOffset // offset to scroll to put word-start border against the corresponding screen border, fully visible + + (rtl ? 0 : 1) * srcCounterscrollOverflow; // offset to maintain original overflow past that border if it existed // -- Final step: Apply & fine-tune the final scroll positioning -- this.scrollContainer.scrollLeft = clampedScrollLeft; From f592c5094ddf01ac38af3d4800e5d1d1ed099b6c Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 6 Jan 2023 13:53:45 +0700 Subject: [PATCH 09/23] feat(web): bonus round - variable-width suggestions --- .../engine/osk/src/banner/suggestionBanner.ts | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 4c3271a0507..275bf4aad2a 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -66,10 +66,9 @@ export class BannerSuggestion { container.className = "kmw-suggestion-container"; // Ensures that a reasonable default width, based on % is set. (Since it's not yet in the DOM, we may not yet have actual width info.) - let usableWidth = 100 - SuggestionBanner.MARGIN * (SuggestionBanner.SUGGESTION_LIMIT - 1); + let usableWidth = 100 - SuggestionBanner.MARGIN * (SuggestionBanner.LONG_SUGGESTION_DISPLAY_LIMIT - 1); - // The `/ 2` part: Ensures that the full banner is double-wide, which is useful for demoing scrolling. - let widthpc = usableWidth / (SuggestionBanner.SUGGESTION_LIMIT / 2); + let widthpc = usableWidth / (SuggestionBanner.LONG_SUGGESTION_DISPLAY_LIMIT); container.style.minWidth = widthpc + '%'; div.appendChild(container); @@ -191,7 +190,11 @@ export class BannerSuggestion { } public get collapsedWidth(): number { - let maxWidth = this.targetCollapsedWidth < this.expandedWidth ? this.targetCollapsedWidth : this.expandedWidth; + // Allow shrinking a suggestion's width if it has excess whitespace. + let utilizedWidth = this.spanWidth < this.targetCollapsedWidth ? this.spanWidth : this.targetCollapsedWidth; + // If a minimum width has been specified, enforce that minimum. + let maxWidth = utilizedWidth < this.expandedWidth ? utilizedWidth : this.expandedWidth; + // Will return maxWidth if this.minWidth is undefined. return (this.minWidth > maxWidth ? this.minWidth : maxWidth); @@ -260,7 +263,8 @@ export class BannerSuggestion { * Description Display lexical model suggestions in the banner */ export class SuggestionBanner extends Banner { - public static readonly SUGGESTION_LIMIT: number = 6; + public static readonly SUGGESTION_LIMIT: number = 8; + public static readonly LONG_SUGGESTION_DISPLAY_LIMIT: number = 3; public static readonly MARGIN = 1; public readonly events: EventEmitter; @@ -322,7 +326,7 @@ export class SuggestionBanner extends Banner { * for visuals/UI while still being internally LTR. */ for (var i=0; i i) { const suggestion = suggestions[i]; d.update(suggestion, optionFormat); + if(d.collapsedWidth < d.expandedWidth) { + collapsedOptions.push(d); + } totalWidth += d.collapsedWidth; displayCount++; @@ -500,6 +508,28 @@ export class SuggestionBanner extends Banner { if(totalWidth < this.width) { let separatorWidth = (this.width * 0.01 * (displayCount-1)); + // Prioritize adding padding to suggestions that actually need it. + // Use equal measure for each so long as it still could use extra display space. + while(totalWidth < this.width && collapsedOptions.length > 0) { + let maxFillPadding = (this.width - totalWidth - separatorWidth) / collapsedOptions.length; + collapsedOptions.sort((a, b) => a.expandedWidth - b.expandedWidth); + + let shortestCollapsed = collapsedOptions[0]; + let neededWidth = shortestCollapsed.expandedWidth - shortestCollapsed.collapsedWidth; + + let padding = Math.min(neededWidth, maxFillPadding); + + // Check: it is possible that two elements were matched for equal length, thus the second loop's takes no additional padding. + // No need to trigger re-layout ops for that case. + if(padding > 0) { + collapsedOptions.forEach((a) => a.minWidth = a.collapsedWidth + padding); + totalWidth += padding * collapsedOptions.length; // don't forget to record that we added the padding! + } + + collapsedOptions.splice(0, 1); // discard the element we based our judgment upon; we need not consider it any longer. + } + + // If there's STILL leftover padding to distribute, let's do that now. let fillPadding = (this.width - totalWidth - separatorWidth) / displayCount; for(let i=0; i < displayCount; i++) { From 9f7c6c9754f0f17740c3525e1ccf2de112456e35 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 6 Jan 2023 14:37:27 +0700 Subject: [PATCH 10/23] chore(web): ez-pz bits of cleanup --- web/src/engine/osk/src/banner/suggestionBanner.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 275bf4aad2a..bb4b6cbcec2 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -137,9 +137,6 @@ export class BannerSuggestion { this.div.style.width=''; } - // TODO: if the option is highlighted, maybe don't disable transitions? - this.container.style.transition = 'none'; // temporarily disable transition effects. - const collapserStyle = this.container.style; collapserStyle.minWidth = this.collapsedWidth + 'px'; @@ -148,11 +145,6 @@ export class BannerSuggestion { } else { collapserStyle.marginLeft = (this.collapsedWidth - this.expandedWidth) + 'px'; } - - this.container.offsetWidth; // To 'flush' the changes before re-enabling transition animations. - this.container.offsetLeft; - - this.container.style.transition = ''; // Re-enable them (it's set on the element's class) } public get targetCollapsedWidth(): number { @@ -297,7 +289,6 @@ export class SuggestionBanner extends Banner { this.container = document.createElement('div'); this.container.className = SuggestionBanner.BANNER_SCROLLER_CLASS; this.getDiv().appendChild(this.container); - // TODO: additional styling for the banner scroll container? this.buildInternals(false); @@ -589,7 +580,7 @@ class SuggestionExpandContractAnimation { if(val < this.rootScrollOffset) { this.rootScrollOffset = val; } - } // TODO: else for the RTL adjustment instead. + } // Attempt to sync the banner-scroller's offset update with that of the // animation for expansion and collapsing. From 1534c6516e817796ca5a66d0e961e4bc931e77da Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 6 Jan 2023 15:45:39 +0700 Subject: [PATCH 11/23] docs(web): documentation of new internal interface, clarifying renames --- .../engine/osk/src/banner/suggestionBanner.ts | 147 ++++++++++++++---- 1 file changed, 120 insertions(+), 27 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index bb4b6cbcec2..b09705609a3 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -13,15 +13,41 @@ import { ParsedLengthStyle } from '../lengthStyle.js'; import { getFontSizeStyle } from '../fontSizeUtils.js'; import { getTextMetrics } from '../keyboard-layout/getTextMetrics.js'; -// TODO: finalize + document -interface OptionFormatSpec { +/** + * Defines various parameters used by `BannerSuggestion` instances for layout and formatting. + * This object is designed first and foremost for use with `BannerSuggestion.update()`. + */ +interface BannerSuggestionFormatSpec { + /** + * Sets a minimum width to use for the `BannerSuggestion`'s element; this overrides any + * and all settings that would otherwise result in a narrower final width. + */ minWidth?: number; + + /** + * Sets the width of padding around the text of each suggestion. This should generally match + * the 'width' of class = `.kmw-suggest-option::before` and class = `.kmw-suggest-option::after` + * elements as defined in kmwosk.css. + */ paddingWidth: number, + + /** + * The default font size to use for calculations based on relative font-size specs + */ emSize: number, - styleForFont: CSSStyleDeclaration + /** + * The font style (font-size, font-family) to use for suggestion-banner display text. + */ + styleForFont: CSSStyleDeclaration, + + /** + * Sets a target width to use when 'collapsing' suggestions. Only affects those long + * enough to need said 'collapsing'. + */ collapsedWidth?: number } + export class BannerSuggestion { div: HTMLDivElement; container: HTMLDivElement; @@ -104,7 +130,7 @@ export class BannerSuggestion { * @param collapsedTargetWidth * Description Update the ID and text of the BannerSuggestionSpec */ - public update(suggestion: Suggestion, format: OptionFormatSpec) { + public update(suggestion: Suggestion, format: BannerSuggestionFormatSpec) { this._suggestion = suggestion; let display = this.generateSuggestionText(this.rtl); @@ -147,31 +173,55 @@ export class BannerSuggestion { } } + /** + * Denotes the threshold at which the banner suggestion will no longer gain width + * in its default form, resulting in two separate states: "collapsed" and "expanded". + */ public get targetCollapsedWidth(): number { return this._collapsedWidth; } + /** + * The raw width needed to display the suggestion's display text without triggering overflow. + */ public get textWidth(): number { return this._textWidth; } + /** + * Width of the padding to apply equally on both sides of the suggestion's display text. + * Is the sum of both, rather than the value applied to each side. + */ public get paddingWidth(): number { return this._paddingWidth; } + /** + * The absolute minimum width to allow for the represented suggestion's banner element. + */ public get minWidth(): number { return this._minWidth; } + /** + * The absolute minimum width to allow for the represented suggestion's banner element. + */ public set minWidth(val: number) { this._minWidth = val; } + /** + * The total width taken by the suggestion's banner element when fully expanded. + * This may equal the `collapsed` width for sufficiently short suggestions. + */ public get expandedWidth(): number { // minWidth must be defined AND greater for the conditional to return this.minWidth. return this.minWidth > this.spanWidth ? this.minWidth : this.spanWidth; } + /** + * The total width used by the internal contents of the suggestion's banner element when not obscured. + */ public get spanWidth(): number { let spanWidth = this.textWidth ?? 0; if(spanWidth) { @@ -181,21 +231,32 @@ export class BannerSuggestion { return spanWidth; } + /** + * The actual width to be used for the `BannerSuggestion`'s display element when in the 'collapsed' + * state and not transitioning. + */ public get collapsedWidth(): number { // Allow shrinking a suggestion's width if it has excess whitespace. let utilizedWidth = this.spanWidth < this.targetCollapsedWidth ? this.spanWidth : this.targetCollapsedWidth; // If a minimum width has been specified, enforce that minimum. let maxWidth = utilizedWidth < this.expandedWidth ? utilizedWidth : this.expandedWidth; - // Will return maxWidth if this.minWidth is undefined. return (this.minWidth > maxWidth ? this.minWidth : maxWidth); } + /** + * The actual width currently utilized by the `BannerSuggestion`'s display element, regardless of + * current state. + */ public get currentWidth(): number { return this.div.offsetWidth; } + /** + * The actual width currently utilized by the `BannerSuggestion`'s display element, regardless of + * current state. + */ public set currentWidth(val: number) { // TODO: probably should set up errors or something here... if(val < this.collapsedWidth) { @@ -451,6 +512,11 @@ export class SuggestionBanner extends Banner { } } + /** + * Produces a closure useful for updating the SuggestionBanner's UI to match newly-received + * suggestions, including optimization of the banner's layout. + * @param suggestions + */ public onSuggestionUpdate = (suggestions: Suggestion[]): void => { this.currentSuggestions = suggestions; @@ -464,7 +530,7 @@ export class SuggestionBanner extends Banner { const textLeftPad = new ParsedLengthStyle(textStyle.paddingLeft || '2px'); // computedStyle will fail if the element's not in the DOM yet. const textRightPad = new ParsedLengthStyle(textStyle.paddingRight || '2px'); - let optionFormat: OptionFormatSpec = { + let optionFormat: BannerSuggestionFormatSpec = { paddingWidth: textLeftPad.val + textRightPad.val, // Assumes fixed px padding. emSize: emSize, styleForFont: fontStyle, @@ -582,29 +648,51 @@ class SuggestionExpandContractAnimation { } } - // Attempt to sync the banner-scroller's offset update with that of the + // Synchronize the banner-scroller's offset update with that of the // animation for expansion and collapsing. - window.requestAnimationFrame(this.setOffsetScroll); - - // this.setOffsetScroll(); + window.requestAnimationFrame(this.setScrollOffset); } - // the "fun", top-level banner part. - private setOffsetScroll = () => { + /** + * Performs mapping of the user's touchpoint to properly-offset scroll coordinates based on + * the state of the ongoing scroll operation. + * + * First priority: this function aims to keep all currently-visible parts of a selected + * suggestion visible when first selected. Any currently-clipped parts will remain clipped. + * + * Second priority: all animations should be smooth and continuous; aesthetics do matter to + * users. + * + * Third priority: when possible without violating the first two priorities, this (in tandem with + * adjustments within `setBaseScroll`) will aim to sync the touchpoint with its original + * location on an expanded suggestion. + * - For LTR languages, this means that suggestions will "expand left" if possible. + * - While for RTL languages, they will "expand right" if possible. + * - However, if they would expand outside of the banner's effective viewport, a scroll offset + * will kick in to enforce the "first priority" mentioned above. + * - This "scroll offset" will be progressively removed (because second priority) if and as + * the user manually scrolls to reveal relevant space that was originally outside of the viewport. + * + * @returns + */ + private setScrollOffset = () => { // If we've been 'decoupled', a different instance (likely for a different suggestion) // is responsible for counter-scrolling. if(!this.scrollContainer) { return; } - // -- Clamping logic -- + // -- Clamping / "scroll offset" logic -- - // As currently written / defined below, "clamping" refers to alterations to scroll-positioned mapping designed - // to keep as much of the expanded option visible as possible via the offsets below while not pushing - // already-obscured parts of the expanded option into visible range. + // As currently written / defined below, and used internally within this function, "clamping" + // refers to alterations to scroll-positioned mapping designed to keep as much of the expanded + // option visible as possible via the offsets below (that is, "clamped" to the relevant border) + // while not adding extra discontinuity by pushing already-obscured parts of the expanded option + // into visible range. // - // In essence, it's an extra offset we apply that is dynamically adjusted depending on scroll position as it changes. - // This offset may be decreased when it is no longer needed to make parts of the element visible. + // In essence, it's an extra "scroll offset" we apply that is dynamically adjusted depending on + // scroll position as it changes. This offset may be decreased when it is no longer needed to + // make parts of the element visible. // The amount of extra space being taken by a partially or completely expanded suggestion. const maxWidthToCounterscroll = this.option.currentWidth - this.option.collapsedWidth; @@ -619,13 +707,15 @@ class SuggestionExpandContractAnimation { // Base position for scrollLeft clamped within std element scroll bounds, including: // - an adjustment to cover the extra width from expansion // - preserving the base expected overflow levels + // Does NOT make adjustments to force extra visibility on the element being highlighted/focused. const unclampedExpandingScrollOffset = Math.max(this.collapsedScrollOffset + (rtl ? 0 : 1) * maxWidthToCounterscroll, 0) + (rtl ? 0 : -1) * srcCounterscrollOverflow; - const srcUnclampedExpandingScrollOffset = Math.max(this.rootScrollOffset + (rtl ? 0 : 1) * maxWidthToCounterscroll, 0) + (rtl ? 0 : -1) * srcCounterscrollOverflow; + // The same, but for our 'root scroll coordinate'. + const rootUnclampedExpandingScrollOffset = Math.max(this.rootScrollOffset + (rtl ? 0 : 1) * maxWidthToCounterscroll, 0) + (rtl ? 0 : -1) * srcCounterscrollOverflow; // Do not shift an element clipped by the screen border further than its original scroll starting point. const elementOffsetForClamping = rtl - ? Math.max(unclampedExpandingScrollOffset, srcUnclampedExpandingScrollOffset) - : Math.min(unclampedExpandingScrollOffset, srcUnclampedExpandingScrollOffset); + ? Math.max(unclampedExpandingScrollOffset, rootUnclampedExpandingScrollOffset) + : Math.min(unclampedExpandingScrollOffset, rootUnclampedExpandingScrollOffset); // Based on the scroll point selected, determine how far to offset scrolls to keep the option in visible range. // Higher .scrollLeft values make this non-zero and reflect when scroll has begun clipping the element. @@ -635,20 +725,23 @@ class SuggestionExpandContractAnimation { // LTR: based on scrollLeft offsetLeft : Math.max(elementOffsetForClamping - this.option.div.offsetLeft, 0); + // If the element is close enough to the border, don't offset beyond the element! + // If it is further, do not add excess padding - it'd effectively break scrolling. + // Do maintain any remaining scroll offset that exists, though. const clampedExpandingScrollOffset = Math.min(maxWidthToCounterscroll, elementOffsetFromBorder); - const clampedScrollLeft = unclampedExpandingScrollOffset // base scroll-coordinate transform mapping based on extra width from element expansion + const finalScrollOffset = unclampedExpandingScrollOffset // base scroll-coordinate transform mapping based on extra width from element expansion + (rtl ? 1 : -1) * clampedExpandingScrollOffset // offset to scroll to put word-start border against the corresponding screen border, fully visible + (rtl ? 0 : 1) * srcCounterscrollOverflow; // offset to maintain original overflow past that border if it existed // -- Final step: Apply & fine-tune the final scroll positioning -- - this.scrollContainer.scrollLeft = clampedScrollLeft; + this.scrollContainer.scrollLeft = finalScrollOffset; - // Prevent "jitters" during counterscroll that occur on expansion / collapse animation. + // Prevent "jitters" during counterscroll that occur on expansion / collapse animation. // A one-frame "error correction" effect at the end of animation is far less jarring. if(this.pendingAnimation) { // scrollLeft doesn't work well with fractional values, unlike marginLeft / marginRight - const fractionalOffset = this.scrollContainer.scrollLeft - clampedScrollLeft; + const fractionalOffset = this.scrollContainer.scrollLeft - finalScrollOffset; // So we put the fractional difference into marginLeft to force it to sync. this.option.currentWidth += fractionalOffset; } @@ -712,7 +805,7 @@ class SuggestionExpandContractAnimation { // Part 3: perform any needed counter-scrolling, scroll clamping, etc // Existence of a followup animation frame is part of the logic, so keep this 'after'! - this.setOffsetScroll(); + this.setScrollOffset(); }; public collapse() { @@ -762,7 +855,7 @@ class SuggestionExpandContractAnimation { // Part 3: perform any needed counter-scrolling, scroll clamping, etc // Existence of a followup animation frame is part of the logic, so keep this 'after'! - this.setOffsetScroll(); + this.setScrollOffset(); }; } From 0ab094df439e98e3eeba7923ba546bc04aef1ca3 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 15 Nov 2023 11:38:57 +0700 Subject: [PATCH 12/23] docs(web): adds comment --- web/src/engine/osk/src/banner/suggestionBanner.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index b09705609a3..fdbc8488bbe 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -527,8 +527,11 @@ export class SuggestionBanner extends Banner { const textStyle = getComputedStyle(this.options[0].container.firstChild as HTMLSpanElement); const targetWidth = this.width / SuggestionBanner.LONG_SUGGESTION_DISPLAY_LIMIT; - const textLeftPad = new ParsedLengthStyle(textStyle.paddingLeft || '2px'); // computedStyle will fail if the element's not in the DOM yet. - const textRightPad = new ParsedLengthStyle(textStyle.paddingRight || '2px'); + + // computedStyle will fail if the element's not in the DOM yet. + // Seeks to get the values specified within kmwosk.css. + const textLeftPad = new ParsedLengthStyle(textStyle.paddingLeft || '4px'); + const textRightPad = new ParsedLengthStyle(textStyle.paddingRight || '4px'); let optionFormat: BannerSuggestionFormatSpec = { paddingWidth: textLeftPad.val + textRightPad.val, // Assumes fixed px padding. From 5fd0cebdcb2dc6bf70fc0a8c229e97c9ce055f68 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 1 Dec 2023 09:42:52 +0700 Subject: [PATCH 13/23] fix(web): z-index issues - suggestions were above keytips and subkeys --- web/src/resources/osk/kmwosk.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/resources/osk/kmwosk.css b/web/src/resources/osk/kmwosk.css index f45660b958c..79c9865a97c 100644 --- a/web/src/resources/osk/kmwosk.css +++ b/web/src/resources/osk/kmwosk.css @@ -383,7 +383,7 @@ height: 100%; content: ''; top: 0; - z-index:10999; /* z-indexes this _behind_ the 'option' element that hosts the scrollable zone. */ + z-index:10000; /* z-indexes this _behind_ the 'option' element that hosts the scrollable zone. */ user-select: none; pointer-events: none; /* Ensures click-through! But apparently not touch-through. */ touch-action: none; /* Doesn't seem to allow touch-through, though - even with touch-action: none */ @@ -417,7 +417,7 @@ } .kmw-banner-bar .kmw-banner-separator {border-left: solid 1px #8a8d90; width: 0px; vertical-align: middle; height: 45%; display: inline-block;} -.kmw-banner-bar .kmw-suggest-option {display:inline-block; text-align: center; height: 85%; position: relative; z-index: 11000} +.kmw-banner-bar .kmw-suggest-option {display:inline-block; text-align: center; height: 85%; position: relative; z-index: 10001} .kmw-suggestion-text { color:#fff; line-height: normal; From d720bd9ade39420c09f5e9b13c10629078933369 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 1 Dec 2023 10:59:45 +0700 Subject: [PATCH 14/23] fix(web): font style retrieval for suggestion-width calcs --- .../engine/osk/src/banner/suggestionBanner.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index fdbc8488bbe..94419f444c3 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -39,7 +39,10 @@ interface BannerSuggestionFormatSpec { /** * The font style (font-size, font-family) to use for suggestion-banner display text. */ - styleForFont: CSSStyleDeclaration, + styleForFont: { + fontSize: typeof CSSStyleDeclaration.prototype.fontSize, + fontFamily: typeof CSSStyleDeclaration.prototype.fontFamily + }, /** * Sets a target width to use when 'collapsing' suggestions. Only affects those long @@ -80,6 +83,10 @@ export class BannerSuggestion { this.container.appendChild(display); } + get computedStyle() { + return getComputedStyle(this.display); + } + private constructRoot() { // Add OSK suggestion labels let div = this.div = createUnselectableElement('div'), ds=div.style; @@ -520,7 +527,13 @@ export class SuggestionBanner extends Banner { public onSuggestionUpdate = (suggestions: Suggestion[]): void => { this.currentSuggestions = suggestions; - const fontStyle = getComputedStyle(this.options[0].div); + const fontStyleBase = this.options[0].computedStyle; + // Do NOT just re-use the returned object from the line above; it may spontaneously change + // (in a bad way) when the underlying span is replaced! + const fontStyle = { + fontSize: fontStyleBase.fontSize, + fontFamily: fontStyleBase.fontFamily + } const emSizeStr = getComputedStyle(document.body).fontSize; const emSize = getFontSizeStyle(emSizeStr).val; From 121e24a81e3ca5e07112cbaa8a2cf56e13fb06df Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 1 Dec 2023 13:24:04 +0700 Subject: [PATCH 15/23] fix(web): at min, mitigates non-collapsing option issue --- web/src/engine/osk/src/banner/suggestionBanner.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 94419f444c3..aedd76e4a31 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -159,6 +159,7 @@ export class BannerSuggestion { this._textWidth = 0; } + this.currentWidth = this.collapsedWidth; this.updateLayout(); } @@ -427,6 +428,7 @@ export class SuggestionBanner extends Banner { if(on && classes.indexOf(cs) < 0) { elem.className=classes+cs; if(this.highlightAnimation) { + this.highlightAnimation.cancel(); this.highlightAnimation.decouple(); } @@ -526,6 +528,8 @@ export class SuggestionBanner extends Banner { */ public onSuggestionUpdate = (suggestions: Suggestion[]): void => { this.currentSuggestions = suggestions; + // Immediately stop all animations and reset options accordingly. + this.highlightAnimation?.cancel(); const fontStyleBase = this.options[0].computedStyle; // Do NOT just re-use the returned object from the line above; it may spontaneously change @@ -764,6 +768,7 @@ class SuggestionExpandContractAnimation { } public decouple() { + this.cancel(); this.scrollContainer = null; } @@ -773,6 +778,11 @@ class SuggestionExpandContractAnimation { this.pendingAnimation = null; } + cancel() { + this.clear(); + this.option.currentWidth = this.option.collapsedWidth; + } + public expand() { // Cancel any prior iterating animation-frame commands. this.clear(); From e5e04f1de2b29078d1993165226a1a51ef28b0b7 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 4 Dec 2023 11:01:25 +0700 Subject: [PATCH 16/23] feat(web): restores scroll-state tracker --- .../osk/src/banner/bannerScrollState.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 web/src/engine/osk/src/banner/bannerScrollState.ts diff --git a/web/src/engine/osk/src/banner/bannerScrollState.ts b/web/src/engine/osk/src/banner/bannerScrollState.ts new file mode 100644 index 00000000000..c819f77bb2c --- /dev/null +++ b/web/src/engine/osk/src/banner/bannerScrollState.ts @@ -0,0 +1,50 @@ +import { InputSample } from "@keymanapp/gesture-recognizer"; + +/** + * The amount of coordinate 'noise' allowed during a scroll-enabled touch allowed + * before interpreting the currently-ongoing touch command as having scrolled. + */ +const HAS_SCROLLED_FUDGE_FACTOR = 10; + +/** + * This class was added to facilitate scroll handling for overflow-x elements, though it could + * be extended in the future to accept overflow-y if needed. + * + * This is necessary because of the OSK's need to use `.preventDefault()` for stability; that + * same method blocks native handling of overflow scrolling for touch browsers. + */ +export class BannerScrollState { + totalLength = 0; + + baseCoord: InputSample; + curCoord: InputSample; + baseScrollLeft: number; + + // The amount of coordinate 'noise' allowed during a scroll-enabled touch allowed + // before interpreting the currently-ongoing touch command as having scrolled. + static readonly HAS_SCROLLED_FUDGE_FACTOR = 10; + + constructor(coord: InputSample, baseScrollLeft: number) { + this.baseCoord = coord; + this.curCoord = coord; + this.baseScrollLeft = baseScrollLeft; + + this.totalLength = 0; + } + + updateTo(coord: InputSample): number { + let prevCoord = this.curCoord; + this.curCoord = coord; + + let delta = this.baseCoord.targetX - this.curCoord.targetX + this.baseScrollLeft + // Track the total amount of scrolling used, even if just a pixel-wide back and forth wiggle. + this.totalLength += Math.abs(this.curCoord.targetX - prevCoord.targetX); + + return delta; + } + + public get hasScrolled(): boolean { + // Allow an accidental fudge-factor for overflow element noise during a touch, but not much. + return this.totalLength > HAS_SCROLLED_FUDGE_FACTOR; + } +} \ No newline at end of file From 8465f1f3e236e42f3cd8006c4d9db81fe7791dce Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 4 Dec 2023 12:46:52 +0700 Subject: [PATCH 17/23] chore(web): adjusts pre-gesture banner code to post-gesture --- .../osk/src/banner/bannerScrollState.ts | 2 +- .../engine/osk/src/banner/suggestionBanner.ts | 123 ++++++++++++------ 2 files changed, 87 insertions(+), 38 deletions(-) diff --git a/web/src/engine/osk/src/banner/bannerScrollState.ts b/web/src/engine/osk/src/banner/bannerScrollState.ts index c819f77bb2c..bef4ba7d74a 100644 --- a/web/src/engine/osk/src/banner/bannerScrollState.ts +++ b/web/src/engine/osk/src/banner/bannerScrollState.ts @@ -36,7 +36,7 @@ export class BannerScrollState { let prevCoord = this.curCoord; this.curCoord = coord; - let delta = this.baseCoord.targetX - this.curCoord.targetX + this.baseScrollLeft + let delta = this.baseCoord.targetX - this.curCoord.targetX + this.baseScrollLeft; // Track the total amount of scrolling used, even if just a pixel-wide back and forth wiggle. this.totalLength += Math.abs(this.curCoord.targetX - prevCoord.targetX); diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 63fa765337c..80b0f20ff33 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -7,7 +7,8 @@ import { GestureRecognizerConfiguration, GestureSource, InputSample, - PaddedZoneSource + PaddedZoneSource, + RecognitionZoneSource } from '@keymanapp/gesture-recognizer'; import { BANNER_GESTURE_SET } from './bannerGestureSet.js'; @@ -18,12 +19,14 @@ import EventEmitter from 'eventemitter3'; import { ParsedLengthStyle } from '../lengthStyle.js'; import { getFontSizeStyle } from '../fontSizeUtils.js'; import { getTextMetrics } from '../keyboard-layout/getTextMetrics.js'; - +import { BannerScrollState } from './bannerScrollState.js'; const TOUCHED_CLASS: string = 'kmw-suggest-touched'; const BANNER_CLASS: string = 'kmw-suggest-banner'; const BANNER_SCROLLER_CLASS = 'kmw-suggest-banner-scroller'; +const BANNER_VERT_ROAMING_HEIGHT_RATIO = 0.666; + /** * Defines various parameters used by `BannerSuggestion` instances for layout and formatting. * This object is designed first and foremost for use with `BannerSuggestion.update()`. @@ -293,13 +296,11 @@ export class BannerSuggestion { public highlight(on: boolean) { const elem = this.div; - let classes = elem.className; - let cs = ' ' + TOUCHED_CLASS; - if(on && classes.indexOf(cs) < 0) { - elem.className=classes+cs; + if(on) { + elem.classList.add(TOUCHED_CLASS); } else { - elem.className=classes.replace(cs,''); + elem.classList.remove(TOUCHED_CLASS); } } @@ -362,10 +363,15 @@ export class SuggestionBanner extends Banner { private hostDevice: DeviceSpec; + /** + * The banner 'container', which is also the root element for banner scrolling. + */ private readonly container: HTMLElement; private highlightAnimation: SuggestionExpandContractAnimation; private gestureEngine: GestureRecognizer; + private scrollState: BannerScrollState; + private selectionBounds: RecognitionZoneSource; private _predictionContext: PredictionContext; @@ -443,11 +449,32 @@ export class SuggestionBanner extends Banner { return null; } + // Auto-cancels suggestion-selection if the finger moves too far; having very generous + // safe-zone settings also helps keep scrolls active on demo pages, etc. + const safeBounds = new PaddedZoneSource(this.getDiv(), [-Number.MAX_SAFE_INTEGER]); + this.selectionBounds = new PaddedZoneSource( + this.getDiv(), + [-BANNER_VERT_ROAMING_HEIGHT_RATIO * this.height, -Number.MAX_SAFE_INTEGER] + ); + const config: GestureRecognizerConfiguration = { targetRoot: this.getDiv(), - maxRoamingBounds: new PaddedZoneSource(this.getDiv(), [-0.333 * this.height]), + maxRoamingBounds: safeBounds, + safeBounds: safeBounds, // touchEventRoot: this.element, // is the default itemIdentifier: (sample, target: HTMLElement) => { + const selBounds = this.selectionBounds.getBoundingClientRect(); + + // Step 1: is the coordinate within the range we permit for selecting _anything_? + if(sample.clientX < selBounds.left || sample.clientX > selBounds.right) { + return null; + } + if(sample.clientY < selBounds.top || sample.clientY > selBounds.bottom) { + return null; + } + + // Step 2: find the best-matching selection. + let bestMatch: BannerSuggestion = null; let bestDist = Number.MAX_VALUE; @@ -482,6 +509,25 @@ export class SuggestionBanner extends Banner { suggestion: null }; + const markSelection = (suggestion: BannerSuggestion) => { + suggestion.highlight(true); + if(this.highlightAnimation) { + this.highlightAnimation.cancel(); + this.highlightAnimation.decouple(); + } + + this.highlightAnimation = new SuggestionExpandContractAnimation(this.container, suggestion, false); + this.highlightAnimation.expand(); + } + + const clearSelection = (suggestion: BannerSuggestion) => { + suggestion.highlight(false); + if(!this.highlightAnimation) { + this.highlightAnimation = new SuggestionExpandContractAnimation(this.container, suggestion, false); + } + this.highlightAnimation.collapse(); + } + engine.on('inputstart', (source) => { // The banner does not support multi-touch - if one is still current, block all others. if(sourceTracker.source) { @@ -489,46 +535,45 @@ export class SuggestionBanner extends Banner { return; } + this.scrollState = new BannerScrollState(source.currentSample, this.container.scrollLeft); + const suggestion = source.baseItem; + sourceTracker.source = source; sourceTracker.scrollingHandler = (sample) => { - // Maintain highlighting - const suggestion = sample.item; - - if(suggestion != sourceTracker.suggestion) { - sourceTracker.suggestion?.highlight(false); - sourceTracker.suggestion?.div.classList.remove(TOUCHED_CLASS); - suggestion.highlight(true); - - const elem = suggestion.div; - if(!elem.classList.contains(TOUCHED_CLASS)) { - elem.classList.add(TOUCHED_CLASS); - if(this.highlightAnimation) { - this.highlightAnimation.cancel(); - this.highlightAnimation.decouple(); - } - - this.highlightAnimation = new SuggestionExpandContractAnimation(this.container, suggestion, false); - this.highlightAnimation.expand(); - } else { - elem.classList.remove(TOUCHED_CLASS); - if(!this.highlightAnimation) { - this.highlightAnimation = new SuggestionExpandContractAnimation(this.container, suggestion, false); - } - this.highlightAnimation.collapse(); + const newScrollLeft = this.scrollState.updateTo(sample); + this.highlightAnimation.setBaseScroll(newScrollLeft); + + // Only re-enable the original suggestion, even if the touchpoint finds + // itself over a different suggestion. Might happen if a scroll boundary + // is reached. + const incoming = sample.item ? suggestion : null; + + // It's possible to cancel selection while still scrolling. + if(incoming != sourceTracker.suggestion) { + if(sourceTracker.suggestion) { + clearSelection(sourceTracker.suggestion); } - sourceTracker.suggestion = suggestion; + sourceTracker.suggestion = incoming; + if(incoming) { + markSelection(incoming); + } } }; + sourceTracker.suggestion = source.currentSample.item; + markSelection(sourceTracker.suggestion); source.currentSample.item.highlight(true); const terminationHandler = () => { - sourceTracker.suggestion.highlight(false); + if(sourceTracker.suggestion) { + clearSelection(sourceTracker.suggestion); + sourceTracker.suggestion = null; + } + sourceTracker.source = null; sourceTracker.scrollingHandler = null; - sourceTracker.suggestion = null; } source.path.on('complete', terminationHandler); @@ -540,9 +585,11 @@ export class SuggestionBanner extends Banner { // The actual result comes in via the sequence's `stage` event. sequence.once('stage', (result) => { const suggestion = result.item; // Should also == sourceTracker.suggestion. - if(suggestion) { + if(suggestion && !this.scrollState.hasScrolled) { this.predictionContext.accept(suggestion.suggestion); } + + this.scrollState = null; }); }); @@ -555,7 +602,9 @@ export class SuggestionBanner extends Banner { // Ensure the banner's extended recognition zone is based on proper, up-to-date layout info. // Note: during banner init, `this.gestureEngine` may only be defined after // the first call to this setter! - (this.gestureEngine?.config.maxRoamingBounds as PaddedZoneSource)?.updatePadding([-0.333 * this.height]); + (this.selectionBounds as PaddedZoneSource)?.updatePadding( + [-BANNER_VERT_ROAMING_HEIGHT_RATIO * this.height, -Number.MAX_SAFE_INTEGER] + ); return result; } From fd2f3ca8d11a11cb9e096361f14e32f26da4cc66 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 16 Jan 2024 10:24:59 +0700 Subject: [PATCH 18/23] feat(web): more prominent fade, fade state management --- .../engine/osk/src/banner/suggestionBanner.ts | 33 +++++++++++++++++++ web/src/resources/osk/kmwosk.css | 27 ++++++++++++--- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 80b0f20ff33..3f90f8943c1 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -27,6 +27,12 @@ const BANNER_SCROLLER_CLASS = 'kmw-suggest-banner-scroller'; const BANNER_VERT_ROAMING_HEIGHT_RATIO = 0.666; +/** + * The style to temporarily apply when updating suggestion text in order to prevent + * fade transitions at that time. + */ +const FADE_SWALLOW_STYLE = 'swallow-fade-transition'; + /** * Defines various parameters used by `BannerSuggestion` instances for layout and formatting. * This object is designed first and foremost for use with `BannerSuggestion.update()`. @@ -193,6 +199,33 @@ export class BannerSuggestion { } else { collapserStyle.marginLeft = (this.collapsedWidth - this.expandedWidth) + 'px'; } + + this.updateFade(); + } + + public updateFade() { + // Note: selected suggestion fade transitions are handled purely by CSS. + // We want to prevent them when updating a suggestion, though. + this.div.classList.add(FADE_SWALLOW_STYLE); + // Be sure that our fade-swallow mechanism is able to trigger once; + // we'll remove it after the current animation frame. + window.requestAnimationFrame(() => { + this.div.classList.remove(FADE_SWALLOW_STYLE); + }) + + // Never apply fading to the side that doesn't overflow. + this.div.classList.add(`kmw-hide-fade-${this.rtl ? 'left' : 'right'}`); + + // Matches the side that overflows, depending on if LTR or RTL. + const fadeClass = `kmw-hide-fade-${this.rtl ? 'right' : 'left'}`; + + // Is the suggestion already its ideal width?. + if(!(this.expandedWidth - this.collapsedWidth)) { + // Yes? Don't do any fading. + this.div.classList.add(fadeClass); + } else { + this.div.classList.remove(fadeClass); + } } /** diff --git a/web/src/resources/osk/kmwosk.css b/web/src/resources/osk/kmwosk.css index 4389d93d626..d0b9980d217 100644 --- a/web/src/resources/osk/kmwosk.css +++ b/web/src/resources/osk/kmwosk.css @@ -396,7 +396,7 @@ /* Set scrollable-suggestion fade width here. Make sure to also set .kmw-suggestion-text * padding-left and padding-right accordingly! */ - width: 4px; + width: 32px; height: 100%; content: ''; top: 0; @@ -406,6 +406,12 @@ touch-action: none; /* Doesn't seem to allow touch-through, though - even with touch-action: none */ /* https://stackoverflow.com/q/21474722 - poster never could find a solution, and settled*/ /* on the same workaround: a 'before' and 'after' piece instead of a single overlay.*/ + transition: opacity 0.25s linear; +} + +.kmw-suggest-option.swallow-fade-transition::before, +.kmw-suggest-option.swallow-fade-transition::after { + transition-duration: 0s; } .kmw-suggest-option::before { @@ -413,6 +419,12 @@ left: 0; } +.kmw-suggest-option.kmw-hide-fade-left::before, +.kmw-suggest-option.kmw-hide-fade-right::after { + opacity: 0; + /* visibility: hidden; */ +} + .kmw-suggest-option::after { background: linear-gradient(90deg, transparent 0%, darkorange 100%); right: 0; @@ -423,6 +435,12 @@ background: #bbb; } +.kmw-suggest-option.kmw-suggest-touched::before, +.kmw-suggest-option.kmw-suggest-touched::after { + /* Immediately start hiding the fade styling for touched suggestions. */ + opacity: 0; +} + /* Creates a gradient to fade text at the borders, providing visual indication of overflow */ /* Make sure the non-transparent color of the gradient matches .kmw-banner-bar's background-color. */ .kmw-suggest-option.kmw-suggest-touched::before { @@ -440,10 +458,11 @@ line-height: normal; position: relative; vertical-align: middle; - padding-left: 4px; /* To prevent start & end of suggestion from being affected by gradient effects */ - padding-right: 4px; /* Set these to match scrollable-suggestion fade width set above. */ + /* Contrast with .kmw-suggest-option::before .width styling. */ + padding-left: 8px; /* Keeps a bit of whitespace on the suggestion's side. */ + padding-right: 8px; /* Keeps a bit of whitespace on the suggestion's side. */ width: max-content; /* Ensure the text span acts like it contains its text */ - min-width: calc(100% - 8px); /* To ensure the span stays centered; also adjusts for scrollable-suggestion fade width. */ + min-width: calc(100% - 16px); /* To ensure the span stays centered. */ white-space: nowrap; } From f38af53681b329265c96ef09549cce2c709bb74d Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 16 Jan 2024 14:24:26 +0700 Subject: [PATCH 19/23] fix(web): rtl maintenance --- .../engine/osk/src/banner/bannerController.ts | 21 +++++++++++++++++++ .../engine/osk/src/banner/suggestionBanner.ts | 4 +++- web/src/engine/osk/src/views/oskView.ts | 4 +--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/web/src/engine/osk/src/banner/bannerController.ts b/web/src/engine/osk/src/banner/bannerController.ts index 45ee746bbfa..70925268f57 100644 --- a/web/src/engine/osk/src/banner/bannerController.ts +++ b/web/src/engine/osk/src/banner/bannerController.ts @@ -6,6 +6,7 @@ import { BannerView } from './bannerView.js'; import { Banner } from './banner.js'; import { BlankBanner } from './blankBanner.js'; import { HTMLBanner } from './htmlBanner.js'; +import { Keyboard, KeyboardProperties } from '@keymanapp/keyboard-processor'; export class BannerController { private container: BannerView; @@ -16,6 +17,9 @@ export class BannerController { private _inactiveBanner: Banner; + private keyboard: Keyboard; + private keyboardStub: KeyboardProperties; + /** * Builds a banner for use when predictions are not active, supporting a single image. */ @@ -92,6 +96,23 @@ export class BannerController { selectBanner(state: StateChangeEnum) { // Only display a SuggestionBanner when LanguageProcessor states it is active. this.activateBanner(state == 'active' || state == 'configured'); + + if(this.keyboard) { + this.container.banner.configureForKeyboard(this.keyboard, this.keyboardStub); + } + } + + /** + * Allows banners to adapt based on the active keyboard and related properties, such as + * associated fonts. + * @param keyboard + * @param keyboardProperties + */ + public configureForKeyboard(keyboard: Keyboard, keyboardProperties: KeyboardProperties) { + this.keyboard = keyboard; + this.keyboardStub = keyboardProperties; + + this.container.banner.configureForKeyboard(keyboard, keyboardProperties); } public shutdown() { diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 3f90f8943c1..0a17935b9f6 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -461,7 +461,9 @@ export class SuggestionBanner extends Banner { ds.marginRight = `calc(${(SuggestionBanner.MARGIN / 2)}% - 0.5px)`; this.container.appendChild(separatorDiv); - this.separators.push(separatorDiv); + // Ensure the separators are maintained in the same order as the + // suggestion elements! + this.separators[indexToInsert - (rtl ? 1 : 0)] = separatorDiv; } } } diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index 525985734a6..b1d16d292fc 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -729,9 +729,7 @@ export default abstract class OSKView // Add suggestion banner bar to OSK this._Box.appendChild(this.banner.element); - if(this.bannerView.banner) { - this.banner.banner.configureForKeyboard(this.keyboardData?.keyboard, this.keyboardData?.metadata); - } + this.bannerController?.configureForKeyboard(this.keyboardData?.keyboard, this.keyboardData?.metadata); let kbdView: KeyboardView = this.keyboardView = this._GenerateKeyboardView(this.keyboardData?.keyboard, this.keyboardData?.metadata); this._Box.appendChild(kbdView.element); From 39a4278b8bd9af7900051fc575749757b60f35a4 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 16 Jan 2024 14:24:41 +0700 Subject: [PATCH 20/23] chore(web): unused method removal --- web/src/engine/osk/src/banner/suggestionBanner.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 0a17935b9f6..930b884c844 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -469,21 +469,6 @@ export class SuggestionBanner extends Banner { } private setupInputHandling(): GestureRecognizer { - - const findTargetFrom = (e: HTMLElement): HTMLDivElement => { - try { - if(e) { - if(e.classList.contains('kmw-suggest-option')) { - return e as HTMLDivElement; - } - if(e.parentElement && e.parentElement.classList.contains('kmw-suggest-option')) { - return e.parentElement as HTMLDivElement; - } - } - } catch(ex) {} - return null; - } - // Auto-cancels suggestion-selection if the finger moves too far; having very generous // safe-zone settings also helps keep scrolls active on demo pages, etc. const safeBounds = new PaddedZoneSource(this.getDiv(), [-Number.MAX_SAFE_INTEGER]); From 18a9924b1ba9eaac5db055ef1ba99c5eacbd5264 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 16 Jan 2024 14:32:32 +0700 Subject: [PATCH 21/23] docs(web): PR doc-comment fixes --- web/src/engine/osk/src/banner/bannerScrollState.ts | 6 +----- web/src/engine/osk/src/banner/suggestionBanner.ts | 9 +++------ web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts | 1 + 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/web/src/engine/osk/src/banner/bannerScrollState.ts b/web/src/engine/osk/src/banner/bannerScrollState.ts index bef4ba7d74a..a0741ac6f50 100644 --- a/web/src/engine/osk/src/banner/bannerScrollState.ts +++ b/web/src/engine/osk/src/banner/bannerScrollState.ts @@ -1,7 +1,7 @@ import { InputSample } from "@keymanapp/gesture-recognizer"; /** - * The amount of coordinate 'noise' allowed during a scroll-enabled touch allowed + * The amount of coordinate 'noise' allowed during a scroll-enabled touch * before interpreting the currently-ongoing touch command as having scrolled. */ const HAS_SCROLLED_FUDGE_FACTOR = 10; @@ -20,10 +20,6 @@ export class BannerScrollState { curCoord: InputSample; baseScrollLeft: number; - // The amount of coordinate 'noise' allowed during a scroll-enabled touch allowed - // before interpreting the currently-ongoing touch command as having scrolled. - static readonly HAS_SCROLLED_FUDGE_FACTOR = 10; - constructor(coord: InputSample, baseScrollLeft: number) { this.baseCoord = coord; this.curCoord = coord; diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index 930b884c844..ff5d8298dc6 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -149,13 +149,10 @@ export class BannerSuggestion { /** * Function update - * @param {string} id Element ID for the suggestion span * @param {Suggestion} suggestion Suggestion from the lexical model - * @param fontStyle The CSS styling expected for the suggestion text - * @param emSize The font size represented by 1em (in px, as from getComputedStyle on document.body) - * @param targetWidth - * @param collapsedTargetWidth - * Description Update the ID and text of the BannerSuggestionSpec + * @param {BannerSuggestionFormatSpec} format Formatting metadata to use for the Suggestion + * + * Update the ID and text of the BannerSuggestionSpec */ public update(suggestion: Suggestion, format: BannerSuggestionFormatSpec) { this._suggestion = suggestion; diff --git a/web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts b/web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts index d7856ec2321..bb5db1cbb9e 100644 --- a/web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts +++ b/web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts @@ -6,6 +6,7 @@ let metricsCanvas: HTMLCanvasElement; * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. * * @param {String} text The text to be rendered. + * @param emScale The absolute `px` size expected to match `1em`. * @param {String} style The CSSStyleDeclaration for an element to measure against, without modification. * * @see https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 From 998f781d8f179767868c3ea5709b63ca9b08bf5b Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 16 Jan 2024 15:00:56 +0700 Subject: [PATCH 22/23] fix(web): banner scroll reset after selecting a suggestion --- web/src/engine/osk/src/banner/suggestionBanner.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/src/engine/osk/src/banner/suggestionBanner.ts b/web/src/engine/osk/src/banner/suggestionBanner.ts index ff5d8298dc6..052ca63b2e4 100644 --- a/web/src/engine/osk/src/banner/suggestionBanner.ts +++ b/web/src/engine/osk/src/banner/suggestionBanner.ts @@ -391,6 +391,8 @@ export class SuggestionBanner extends Banner { private options : BannerSuggestion[] = []; private separators: HTMLElement[] = []; + private isRTL: boolean = false; + private hostDevice: DeviceSpec; /** @@ -422,6 +424,7 @@ export class SuggestionBanner extends Banner { } buildInternals(rtl: boolean) { + this.isRTL = rtl; if(this.options.length > 0) { this.options = []; this.separators = []; @@ -603,7 +606,10 @@ export class SuggestionBanner extends Banner { sequence.once('stage', (result) => { const suggestion = result.item; // Should also == sourceTracker.suggestion. if(suggestion && !this.scrollState.hasScrolled) { - this.predictionContext.accept(suggestion.suggestion); + this.predictionContext.accept(suggestion.suggestion).then(() => { + // Reset the scroll state + this.container.scrollLeft = this.isRTL ? this.container.scrollWidth : 0; + }); } this.scrollState = null; From 99170862c60b062ca30e2de2fc8acc0d8781499d Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 19 Jan 2024 08:17:44 +0700 Subject: [PATCH 23/23] fix(web): pred-text test page path to model --- web/src/test/manual/web/prediction-mtnt/index.html | 9 ++++++--- web/src/test/manual/web/prediction-ui/index.html | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/web/src/test/manual/web/prediction-mtnt/index.html b/web/src/test/manual/web/prediction-mtnt/index.html index 10e277cfd6c..34f43f3c5b7 100644 --- a/web/src/test/manual/web/prediction-mtnt/index.html +++ b/web/src/test/manual/web/prediction-mtnt/index.html @@ -48,9 +48,12 @@ kmw.addKeyboards({id:'gesture_prototyping',name:'Gesture prototyping',languages:{id:'en',name:'English'}, filename:('../keyboards/gesture_prototyping/build/gesture_prototyping.js')}); - var pageRef = (window.location.protocol == 'file:') - ? window.location.href.substr(0, window.location.href.lastIndexOf('/')+1) - : window.location.href; + // Ensure the URL we prefix to the page's path is the page's directory, not including + // the actual HTML page itself. + var urlIncludesIndex = window.location.href.lastIndexOf('.html') > 1; + var pageRef = (urlIncludesIndex + ? window.location.href.substr(0, window.location.href.lastIndexOf('/')) + : window.location.href) + '/'; var modelStub = {'id': 'nrc.en.mtnt', languages: ['en'], diff --git a/web/src/test/manual/web/prediction-ui/index.html b/web/src/test/manual/web/prediction-ui/index.html index e7cfc761014..84d08cbb87b 100644 --- a/web/src/test/manual/web/prediction-ui/index.html +++ b/web/src/test/manual/web/prediction-ui/index.html @@ -46,9 +46,12 @@ kmw.addKeyboards({id:'obolo_chwerty_6351',name:'obolo_chwerty_6351',languages:{id:'en',name:'English'}, filename:('../obolo_chwerty_6351.js')}); - var pageRef = (window.location.protocol == 'file:') - ? window.location.href.substr(0, window.location.href.lastIndexOf('/')+1) - : window.location.href; + // Ensure the URL we prefix to the page's path is the page's directory, not including + // the actual HTML page itself. + var urlIncludesIndex = window.location.href.lastIndexOf('.html') > 1; + var pageRef = (urlIncludesIndex + ? window.location.href.substr(0, window.location.href.lastIndexOf('/')) + : window.location.href) + '/'; var modelStub = {'id': 'example.en.trie', languages: ['en'],