Merge pull request #7934 from keymanapp/feat/web/fancy-suggestion-banner

feat(web): suggestion banner UI enhancements - suggestion expanding + scrollable banner
This commit is contained in:
Joshua Horton 2024-01-22 08:17:40 +07:00 committed by GitHub
commit e00dbe8e6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1051 additions and 179 deletions

View file

@ -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;

View file

@ -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() {

View file

@ -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<any>;
curCoord: InputSample<any>;
baseScrollLeft: number;
constructor(coord: InputSample<any>, baseScrollLeft: number) {
this.baseCoord = coord;
this.curCoord = coord;
this.baseScrollLeft = baseScrollLeft;
this.totalLength = 0;
}
updateTo(coord: InputSample<any>): 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;
}
}

View file

@ -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() {
}
}

View file

@ -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;
ds.width = widthpc + '%';
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.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();
let display = this.generateSuggestionText(this.rtl);
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();
}
private updateText() {
let display = this.generateSuggestionText(this.rtl);
this.div.replaceChild(display, this.display);
this.display = display;
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<BannerSuggestion>;
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<SuggestionInputEventMap>(); //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<SuggestionBanner.SUGGESTION_LIMIT; i++) {
let d = new BannerSuggestion(i, rtl);
this.options[i] = d;
@ -196,44 +443,58 @@ export class SuggestionBanner extends Banner {
* for visuals/UI while still being internally LTR.
*/
for (var i=0; i<SuggestionBanner.SUGGESTION_LIMIT; i++) {
let indexToInsert = rtl ? SuggestionBanner.SUGGESTION_LIMIT - i -1 : i;
this.getDiv().appendChild(this.options[indexToInsert].div);
let indexToInsert = rtl ? SuggestionBanner.SUGGESTION_LIMIT - i - 1 : i;
this.container.appendChild(this.options[indexToInsert].div);
if(i != SuggestionBanner.SUGGESTION_LIMIT) {
// 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');
separatorDiv.className = 'kmw-banner-separator';
let ds = separatorDiv.style;
ds.marginLeft = (SuggestionBanner.MARGIN / 2) + '%';
ds.marginRight = (SuggestionBanner.MARGIN / 2) + '%';
ds.marginLeft = `calc(${(SuggestionBanner.MARGIN / 2)}% - 0.5px)`;
ds.marginRight = `calc(${(SuggestionBanner.MARGIN / 2)}% - 0.5px)`;
this.getDiv().appendChild(separatorDiv);
this.container.appendChild(separatorDiv);
// Ensure the separators are maintained in the same order as the
// suggestion elements!
this.separators[indexToInsert - (rtl ? 1 : 0)] = separatorDiv;
}
}
}
private setupInputHandling(): GestureRecognizer<BannerSuggestion> {
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<BannerSuggestion> = {
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<BannerSuggestion>,
roamingHighlightHandler: (sample: InputSample<BannerSuggestion>) => void,
scrollingHandler: (sample: InputSample<BannerSuggestion>) => 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;
sourceTracker.scrollingHandler = (sample) => {
const newScrollLeft = this.scrollState.updateTo(sample);
this.highlightAnimation.setBaseScroll(newScrollLeft);
if(suggestion != sourceTracker.suggestion) {
sourceTracker.suggestion.highlight(false);
suggestion.highlight(true);
sourceTracker.suggestion = suggestion;
// 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();
this.options.forEach((option: BannerSuggestion, i: number) => {
if(i < suggestions.length) {
option.update(suggestions[i]);
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<SuggestionBanner.SUGGESTION_LIMIT; i++) {
const d = this.options[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;
}
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();
};
}

View file

@ -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;
}

View file

@ -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.

View file

@ -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);

View file

@ -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;}

View file

@ -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'],

View file

@ -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'],