diff --git a/web/src/engine/osk/src/banner/banner.ts b/web/src/engine/osk/src/banner/banner.ts index bc4f48632a9..60690e30873 100644 --- a/web/src/engine/osk/src/banner/banner.ts +++ b/web/src/engine/osk/src/banner/banner.ts @@ -17,6 +17,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 @@ -47,6 +48,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 @@ -88,7 +98,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/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/bannerScrollState.ts b/web/src/engine/osk/src/banner/bannerScrollState.ts new file mode 100644 index 00000000000..a0741ac6f50 --- /dev/null +++ b/web/src/engine/osk/src/banner/bannerScrollState.ts @@ -0,0 +1,46 @@ +import { InputSample } from "@keymanapp/gesture-recognizer"; + +/** + * 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; + +/** + * 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; + + 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 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 d09829fd0c5..052ca63b2e4 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'; @@ -15,12 +16,73 @@ import { BANNER_GESTURE_SET } from './bannerGestureSet.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'; +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; + +/** + * 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()`. + */ +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, + + /** + * The font style (font-size, font-family) to use for suggestion-banner display text. + */ + styleForFont: { + fontSize: typeof CSSStyleDeclaration.prototype.fontSize, + fontFamily: typeof CSSStyleDeclaration.prototype.fontFamily + }, + + /** + * 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; private display: HTMLSpanElement; + + private _collapsedWidth: number; + private _textWidth: number; + private _minWidth: number; + private _paddingWidth: number; + private fontFamily?: string; - private rtl: boolean = false; + public readonly rtl: boolean; private _suggestion: Suggestion; @@ -30,14 +92,19 @@ export class BannerSuggestion { constructor(index: number, isRTL: boolean) { this.index = index; - this.rtl = isRTL; + this.rtl = isRTL ?? false; this.constructRoot(); // 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); + } + + get computedStyle() { + return getComputedStyle(this.display); } private constructRoot() { @@ -46,13 +113,18 @@ export class BannerSuggestion { div.className = "kmw-suggest-option"; div.id = BannerSuggestion.BASE_ID + this.index; - // Ensures that a reasonable width % is set. - let usableWidth = 100 - SuggestionBanner.MARGIN * (SuggestionBanner.SUGGESTION_LIMIT - 1); - let widthpc = usableWidth / SuggestionBanner.SUGGESTION_LIMIT; + this.div['suggestion'] = this; - ds.width = widthpc + '%'; + let container = this.container = document.createElement('div'); + container.className = "kmw-suggestion-container"; - this.div['suggestion'] = this; + // 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.LONG_SUGGESTION_DISPLAY_LIMIT - 1); + + let widthpc = usableWidth / (SuggestionBanner.LONG_SUGGESTION_DISPLAY_LIMIT); + container.style.minWidth = widthpc + '%'; + + div.appendChild(container); } public matchKeyboardProperties(keyboardProperties: KeyboardProperties) { @@ -77,30 +149,188 @@ export class BannerSuggestion { /** * Function update - * @param {string} id Element ID for the suggestion span * @param {Suggestion} suggestion Suggestion from the lexical model - * 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) { + public update(suggestion: Suggestion, format: BannerSuggestionFormatSpec) { this._suggestion = suggestion; - this.updateText(); - } - private updateText() { let display = this.generateSuggestionText(this.rtl); - this.div.replaceChild(display, this.display); + this.container.replaceChild(display, this.display); this.display = display; + + // Set internal properties for use in format calculations. + if(format.minWidth !== undefined) { + this._minWidth = format.minWidth; + } + + this._paddingWidth = format.paddingWidth; + this._collapsedWidth = format.collapsedWidth; + + if(suggestion && suggestion.displayAs) { + const rawMetrics = getTextMetrics(suggestion.displayAs, format.emSize, format.styleForFont); + this._textWidth = rawMetrics.width; + } else { + this._textWidth = 0; + } + + this.currentWidth = this.collapsedWidth; + this.updateLayout(); + } + + public updateLayout() { + if(!this.suggestion && this.index != 0) { + this.div.style.width='0px'; + return; + } else { + this.div.style.width=''; + } + + const collapserStyle = this.container.style; + collapserStyle.minWidth = this.collapsedWidth + 'px'; + + if(this.rtl) { + collapserStyle.marginRight = (this.collapsedWidth - this.expandedWidth) + 'px'; + } 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); + } + } + + /** + * 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) { + spanWidth += this.paddingWidth ?? 0; + } + + 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) { + val = this.collapsedWidth; + } else if(val > this.expandedWidth) { + val = this.expandedWidth; + } + + if(this.rtl) { + this.container.style.marginRight = `${val - this.expandedWidth}px`; + } else { + this.container.style.marginLeft = `${val - this.expandedWidth}px`; + } } public highlight(on: boolean) { const elem = this.div; - let classes = elem.className; - let cs = ' ' + SuggestionBanner.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); } } @@ -148,7 +378,8 @@ 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 = 8; + public static readonly LONG_SUGGESTION_DISPLAY_LIMIT: number = 3; public static readonly MARGIN = 1; public readonly type = "suggestion"; @@ -158,20 +389,33 @@ export class SuggestionBanner extends Banner { private currentSuggestions: Suggestion[] = []; private options : BannerSuggestion[] = []; + private separators: HTMLElement[] = []; + + private isRTL: boolean = false; + 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; - static readonly TOUCHED_CLASS: string = 'kmw-suggest-touched'; - static readonly BANNER_CLASS: string = 'kmw-suggest-banner'; - constructor(hostDevice: DeviceSpec, height?: number) { super(height || Banner.DEFAULT_HEIGHT); this.hostDevice = hostDevice; this.getDiv().className = this.getDiv().className + ' ' + SuggestionBanner.BANNER_CLASS; + + this.container = document.createElement('div'); + this.container.className = BANNER_SCROLLER_CLASS; + this.getDiv().appendChild(this.container); this.buildInternals(false); this.events = new EventEmitter(); //this.manager.events; @@ -180,9 +424,12 @@ export class SuggestionBanner extends Banner { } buildInternals(rtl: boolean) { + this.isRTL = rtl; if(this.options.length > 0) { - this.options.splice(0, this.options.length); // Clear the array. + this.options = []; + this.separators = []; } + for (var i=0; i { - - 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]); + 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; @@ -260,14 +521,33 @@ export class SuggestionBanner extends Banner { const sourceTracker: { source: GestureSource, - roamingHighlightHandler: (sample: InputSample) => void, + scrollingHandler: (sample: InputSample) => void, suggestion: BannerSuggestion } = { source: null, - roamingHighlightHandler: null, + scrollingHandler: null, 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) { @@ -275,41 +555,64 @@ export class SuggestionBanner extends Banner { return; } + this.scrollState = new BannerScrollState(source.currentSample, this.container.scrollLeft); + const suggestion = source.baseItem; + sourceTracker.source = source; - sourceTracker.roamingHighlightHandler = (sample) => { - // Maintain highlighting - const suggestion = sample.item; - - if(suggestion != sourceTracker.suggestion) { - sourceTracker.suggestion.highlight(false); - suggestion.highlight(true); - sourceTracker.suggestion = suggestion; + sourceTracker.scrollingHandler = (sample) => { + 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 = source.currentSample.item; + 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.roamingHighlightHandler = null; - sourceTracker.suggestion = null; + sourceTracker.scrollingHandler = null; } source.path.on('complete', terminationHandler); source.path.on('invalidated', terminationHandler); - source.path.on('step', sourceTracker.roamingHighlightHandler); + source.path.on('step', sourceTracker.scrollingHandler); }); engine.on('recognizedgesture', (sequence) => { // 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) { - this.predictionContext.accept(suggestion.suggestion); + if(suggestion && !this.scrollState.hasScrolled) { + this.predictionContext.accept(suggestion.suggestion).then(() => { + // Reset the scroll state + this.container.scrollLeft = this.isRTL ? this.container.scrollWidth : 0; + }); } + + this.scrollState = null; }); }); @@ -322,7 +625,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; } @@ -335,7 +640,7 @@ export class SuggestionBanner extends Banner { // parse incoming HTML. // // Just in case, alternative approaches: https://stackoverflow.com/a/3955238 - this.getDiv().textContent = ''; + this.container.textContent = ''; // Builds new children to match needed RTL properties. this.buildInternals(rtl); @@ -362,16 +667,106 @@ 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; + // 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 + // (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; + + const textStyle = getComputedStyle(this.options[0].container.firstChild as HTMLSpanElement); + + const targetWidth = this.width / SuggestionBanner.LONG_SUGGESTION_DISPLAY_LIMIT; + + // 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. + emSize: emSize, + styleForFont: fontStyle, + collapsedWidth: targetWidth, + minWidth: 0, + } + + let totalWidth = 0; + let displayCount = 0; + + let collapsedOptions: BannerSuggestion[] = []; + + for (let i=0; i { - if(i < suggestions.length) { - option.update(suggestions[i]); + if(suggestions.length > i) { + const suggestion = suggestions[i]; + d.update(suggestion, optionFormat); + if(d.collapsedWidth < d.expandedWidth) { + collapsedOptions.push(d); + } + + totalWidth += d.collapsedWidth; + displayCount++; } else { - option.update(null); + 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)); + // 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++) { + 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'; + } } } @@ -379,4 +774,260 @@ interface SuggestionInputEventMap { highlight: (bannerSuggestion: BannerSuggestion, state: boolean) => void, apply: (bannerSuggestion: BannerSuggestion) => void; hold: (bannerSuggestion: BannerSuggestion) => void; -} \ No newline at end of file + scrollLeft: (val: number) => void; +} + + +class SuggestionExpandContractAnimation { + private scrollContainer: HTMLElement | null; + private option: BannerSuggestion; + + private collapsedScrollOffset: number; + private rootScrollOffset: 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.collapsedScrollOffset = scrollContainer.scrollLeft; + this.rootScrollOffset = scrollContainer.scrollLeft; + } + + public setBaseScroll(val: number) { + this.collapsedScrollOffset = val; + + // 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; + } + } + + // Synchronize the banner-scroller's offset update with that of the + // animation for expansion and collapsing. + window.requestAnimationFrame(this.setScrollOffset); + } + + /** + * 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 / "scroll offset" logic -- + + // 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 "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; + const rtl = this.option.rtl; + + // 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)); + + 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 + // 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; + // 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, 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. + 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); + + // 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 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 = finalScrollOffset; + + // 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 - finalScrollOffset; + // So we put the fractional difference into marginLeft to force it to sync. + this.option.currentWidth += fractionalOffset; + } + } + + public decouple() { + this.cancel(); + this.scrollContainer = null; + } + + private clear() { + this.startTimestamp = null; + window.cancelAnimationFrame(this.pendingAnimation); + this.pendingAnimation = null; + } + + cancel() { + this.clear(); + this.option.currentWidth = this.option.collapsedWidth; + } + + 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.setScrollOffset(); + }; + + 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.setScrollOffset(); + }; +} + 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..bb5db1cbb9e --- /dev/null +++ b/web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts @@ -0,0 +1,51 @@ +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 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 + * 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 0b16a5b1435..3d364a95495 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskKey.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskKey.ts @@ -11,6 +11,7 @@ import buttonClassNames from '../buttonClassNames.js'; import { KeyElement } from '../keyElement.js'; import VisualKeyboard from '../visualKeyboard.js'; +import { getTextMetrics } from './getTextMetrics.js'; /** * Replace default key names by special font codes for modifier keys @@ -166,53 +167,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 @@ -243,7 +197,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; @@ -351,7 +305,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/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index 36cb2dc8393..b1d16d292fc 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -627,6 +627,7 @@ export default abstract class OSKView if(!pending) { this.headerView?.refreshLayout(); this.bannerView.refreshLayout(); + this.bannerView.width = this.computedWidth; this.footerView?.refreshLayout(); } @@ -728,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); diff --git a/web/src/resources/osk/kmwosk.css b/web/src/resources/osk/kmwosk.css index d07d5726e9d..d0b9980d217 100644 --- a/web/src/resources/osk/kmwosk.css +++ b/web/src/resources/osk/kmwosk.css @@ -66,6 +66,25 @@ font-size: 0.75em; } +.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%; +} + .phone.windows .kmw-key-row{max-width:80%;} .phone .kmw-5rows {padding-top: 0;} @@ -81,7 +100,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? */ @@ -104,6 +122,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; @@ -118,7 +144,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 { @@ -136,6 +173,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 } @@ -174,6 +219,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; @@ -186,6 +239,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;} @@ -209,7 +270,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? */ @@ -249,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; @@ -258,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} @@ -309,22 +386,98 @@ .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: 32px; + height: 100%; + content: ''; + top: 0; + 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 */ + /* 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 { + background: linear-gradient(90deg, darkorange 0%, transparent 100%); + 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; +} + +/* Fallback suggestion-selection highlighting */ +.kmw-suggest-option.kmw-suggest-touched { + 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 { + 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: 10001} +.kmw-suggestion-text { + color:#fff; + line-height: normal; + position: relative; + vertical-align: middle; + /* 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% - 16px); /* To ensure the span stays centered. */ + 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;} @@ -505,7 +658,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); @@ -619,7 +772,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;} 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'],