mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-24 08:37:42 +00:00
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:
commit
e00dbe8e6b
11 changed files with 1051 additions and 179 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
46
web/src/engine/osk/src/banner/bannerScrollState.ts
Normal file
46
web/src/engine/osk/src/banner/bannerScrollState.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
51
web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts
Normal file
51
web/src/engine/osk/src/keyboard-layout/getTextMetrics.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue