Skip to content

Commit

Permalink
Merge pull request #9988 from keymanapp/refactor/web/inactive-banner-…
Browse files Browse the repository at this point in the history
…management

refactor(web): inactive banner management 🎏
  • Loading branch information
jahorton authored Nov 16, 2023
2 parents e6e89d7 + db67f04 commit 8ebb725
Show file tree
Hide file tree
Showing 9 changed files with 115 additions and 129 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ if(_debug) {
var oskHeight = 0;
var oskWidth = 0;
var bannerHeight = 0;
var bannerImgPath = '';

var sentryManager = new KeymanSentryManager({
hostPlatform: "ios"
Expand Down Expand Up @@ -73,14 +74,13 @@ function verifyLoaded() {

function showBanner(flag) {
console.log("Setting banner display for dictionaryless keyboards to " + flag);
keyman.osk.bannerController.setOptions({'alwaysShow': flag});

var bc = keyman.osk.bannerController;
bc.inactiveBanner = flag ? new bc.ImageBanner(bannerImgPath) : null;
}

function setBannerImage(path) {
var kmw=window['keyman'];
if(kmw.osk) {
kmw.osk.bannerController.setOptions({"imagePath": path});
}
bannerImgPath = path;
}

function setBannerHeight(h) {
Expand Down
2 changes: 1 addition & 1 deletion web/src/engine/main/src/keymanEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ export default class KeymanEngine<
id: this.core?.activeModel?.id || ''
},
osk: {
banner: this.osk?.bannerController?.activeType ?? '',
banner: this.osk?.banner?.banner.type ?? '',
layer: this.osk?.vkbd?.layerId || ''
}
};
Expand Down
2 changes: 2 additions & 0 deletions web/src/engine/osk/src/banner/banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,6 @@ export abstract class Banner {
* @param keyboardProperties
*/
public configureForKeyboard(keyboard: Keyboard, keyboardProperties: KeyboardProperties) { }

abstract get type();
}
148 changes: 43 additions & 105 deletions web/src/engine/osk/src/banner/bannerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,134 +2,84 @@ import { DeviceSpec } from '@keymanapp/web-utils';
import type { PredictionContext, StateChangeEnum } from '@keymanapp/input-processor';
import { ImageBanner } from './imageBanner.js';
import { SuggestionBanner } from './suggestionBanner.js';
import { BannerView, BannerOptions, BannerType } from './bannerView.js';
import { BannerView } from './bannerView.js';
import { Banner } from './banner.js';
import { BlankBanner } from './blankBanner.js';
import { HTMLBanner } from './htmlBanner.js';

export class BannerController {
private _activeType: BannerType;
private _options: BannerOptions = {};
private container: BannerView;
private alwaysShow: boolean;
private imagePath?: string = "";

private predictionContext?: PredictionContext;

private readonly hostDevice: DeviceSpec;

public static readonly DEFAULT_OPTIONS: BannerOptions = {
alwaysShow: false,
imagePath: ""
}
private _inactiveBanner: Banner;

/**
* Builds a banner for use when predictions are not active, supporting a single image.
*/
public readonly ImageBanner = ImageBanner;

/**
* Builds a banner for use when predictions are not active, supporting a more generalized
* content pattern than ImageBanner via `innerHTML` specifications.
*/
public readonly HTMLBanner = HTMLBanner;

constructor(bannerView: BannerView, hostDevice: DeviceSpec, predictionContext?: PredictionContext) {
// Step 1 - establish the container element. Must come before this.setOptions.
this.hostDevice = hostDevice;
this.container = bannerView;
this.predictionContext = predictionContext;

// Initialize with the default options - any 'manually set' options come post-construction.
// This will also automatically set the default banner in place.
this.setOptions(BannerController.DEFAULT_OPTIONS);
this.inactiveBanner = new BlankBanner();
}

/**
* This function corresponds to `keyman.osk.banner.getOptions`.
* Specifies the `Banner` instance to use when predictive-text is _not_ available to the user.
*
* Defaults to a hidden, "blank" `Banner` if not otherwise specified. Changes to its value
* when predictive-text is not active will result in banner hot-swapping.
*
* Gets the current control settings in use by `BannerManager`.
* The assigned instance will persist until directly changed through a new assignment,
* regardless of any keyboard swaps and/or activations of the suggestion banner that may
* occur in the meantime.
*/
public getOptions(): BannerOptions {
let retObj = {};

for(let key in this._options) {
retObj[key] = this._options[key];
}

return retObj;
public get inactiveBanner() {
return this._inactiveBanner;
}

/**
* This function corresponds to `keyman.osk.banner.setOptions`.
*
* Sets options used to tweak the automatic `Banner`
* control logic used by `BannerManager`.
* @param optionSpec An object specifying one or more of the following options:
* * `persistentBanner` (boolean) When `true`, ensures that a `Banner`
* is always displayed, even when no predictive model exists
* for the active language.
*
* Default: `false`
* * `imagePath` (URL string) Specifies the file path to use for an
* `ImageBanner` when `persistentBanner` is `true` and no predictive model exists.
*
* Default: `''`.
* * `enablePredictions` (boolean) Turns KMW predictions
* on (when `true`) and off (when `false`).
*
* Default: `true`.
*/
public setOptions(optionSpec: BannerOptions) {
for(let key in optionSpec) {
switch(key) {
// Each defined option may require specialized handling.
case 'alwaysShow':
// Determines the banner type to activate.
this.alwaysShow = optionSpec[key];
break;
case 'imagePath':
// Determines the image file to use for ImageBanners.
this.imagePath = optionSpec[key];
break;
default:
// Invalid option specified!
}
this._options[key] = optionSpec[key];

// If no banner instance exists yet, go with a safe, blank initialization.
if(!this.container.banner) {
this.selectBanner('inactive');
}
public set inactiveBanner(banner: Banner) {
this._inactiveBanner = banner ?? new BlankBanner();

if(!(this.container.banner instanceof SuggestionBanner)) {
this.container.banner = this._inactiveBanner;
}
}

/**
* Sets the active `Banner` to the specified type, regardless of
* existing management logic settings.
* Sets the active `Banner` to match the specified state for predictive text.
*
* @param type `'blank' | 'image' | 'suggestion'` - A plain-text string
* representing the type of `Banner` to set active.
* @param height - Optional banner height in pixels.
* @param on Whether prediction is active (`true`) or disabled (`false`).
*/
public setBanner(type: BannerType) {
var banner: Banner;
public activateBanner(on: boolean) {
let banner: Banner;

let oldBanner = this.container.banner;
const oldBanner = this.container.banner;
if(oldBanner instanceof SuggestionBanner) {
this.predictionContext.off('update', oldBanner.onSuggestionUpdate);
}

switch(type) {
case 'blank':
banner = new BlankBanner();
break;
case 'image':
banner = new ImageBanner(this.imagePath, this.container.activeBannerHeight);
break;
case 'suggestion':
let suggestBanner = banner = new SuggestionBanner(this.hostDevice, this.container.activeBannerHeight);
suggestBanner.predictionContext = this.predictionContext;
suggestBanner.events.on('apply', (selection) => this.predictionContext.accept(selection.suggestion));

this.predictionContext.on('update', suggestBanner.onSuggestionUpdate);
break;
default:
throw new Error("Invalid type specified for the banner!");
}

this._activeType = type;
if(!on) {
this.container.banner = this.inactiveBanner;
} else {
let suggestBanner = banner = new SuggestionBanner(this.hostDevice, this.container.activeBannerHeight);
suggestBanner.predictionContext = this.predictionContext;
suggestBanner.events.on('apply', (selection) => this.predictionContext.accept(selection.suggestion));

if(banner) {
this.container.banner = banner;
this.predictionContext.on('update', suggestBanner.onSuggestionUpdate);
this.container.banner = suggestBanner;
}
}

Expand All @@ -140,18 +90,6 @@ export class BannerController {
*/
selectBanner(state: StateChangeEnum) {
// Only display a SuggestionBanner when LanguageProcessor states it is active.
if(state == 'active' || state == 'configured') {
this.setBanner('suggestion');
} else if(state == 'inactive') {
if(this.alwaysShow) {
this.setBanner('image');
} else {
this.setBanner('blank');
}
}
}

public get activeType(): BannerType {
return this._activeType;
this.activateBanner(state == 'active' || state == 'configured');
}
}
39 changes: 21 additions & 18 deletions web/src/engine/osk/src/banner/bannerView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface BannerOptions {
imagePath?: string;
}

export type BannerType = "blank" | "image" | "suggestion";
export type BannerType = "blank" | "image" | "suggestion" | "html";

interface BannerViewEventMap {
'bannerchange': () => void;
Expand Down Expand Up @@ -59,7 +59,11 @@ interface BannerViewEventMap {
*/
export class BannerView implements OSKViewComponent {
private bannerContainer: HTMLDivElement;
private activeBanner: Banner;

/**
* The currently active banner.
*/
private currentBanner: Banner;
private _activeBannerHeight: number = Banner.DEFAULT_HEIGHT;

public readonly events = new EventEmitter<BannerViewEventMap>();
Expand Down Expand Up @@ -90,32 +94,31 @@ export class BannerView implements OSKViewComponent {
* Applies any stylesheets needed by specific `Banner` instances.
*/
public appendStyles() {
if(this.activeBanner) {
this.activeBanner.appendStyleSheet();
if(this.currentBanner) {
this.currentBanner.appendStyleSheet();
}
}

public get banner(): Banner {
return this.activeBanner;
return this.currentBanner;
}

/**
* Sets the active `Banner` to the specified type, regardless of
* existing management logic settings.
*
* @param banner The `Banner` instance to set as active.
* The `Banner` actively being displayed to the user in the OSK's current state,
* whether a `SuggestionBanner` (with predictive-text active) or a different
* type for use when the predictive-text engine is inactive.
*/
public set banner(banner: Banner) {
if(this.activeBanner) {
if(banner == this.activeBanner) {
if(this.currentBanner) {
if(banner == this.currentBanner) {
return;
} else {
let prevBanner = this.activeBanner;
this.activeBanner = banner;
let prevBanner = this.currentBanner;
this.currentBanner = banner;
this.bannerContainer.replaceChild(banner.getDiv(), prevBanner.getDiv());
}
} else {
this.activeBanner = banner;
this.currentBanner = banner;
if(banner) {
this.bannerContainer.appendChild(banner.getDiv());
}
Expand All @@ -132,8 +135,8 @@ export class BannerView implements OSKViewComponent {
* Gets the height (in pixels) of the active `Banner` instance.
*/
public get height(): number {
if(this.activeBanner) {
return this.activeBanner.height;
if(this.currentBanner) {
return this.currentBanner.height;
} else {
return 0;
}
Expand All @@ -149,8 +152,8 @@ export class BannerView implements OSKViewComponent {
public set activeBannerHeight(h: number) {
this._activeBannerHeight = h;

if (this.activeBanner && !(this.activeBanner instanceof BlankBanner)) {
this.activeBanner.height = h;
if (this.currentBanner && !(this.currentBanner instanceof BlankBanner)) {
this.currentBanner.height = h;
}
}

Expand Down
1 change: 1 addition & 0 deletions web/src/engine/osk/src/banner/blankBanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Banner } from "./banner.js";
* Description A banner of height 0 that should not be shown
*/
export class BlankBanner extends Banner {
readonly type = 'blank';

constructor() {
super(0);
Expand Down
32 changes: 32 additions & 0 deletions web/src/engine/osk/src/banner/htmlBanner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Banner } from "./banner.js";

export class HTMLBanner extends Banner {
readonly container: ShadowRoot | HTMLElement;
readonly type = 'html';

constructor(contents?: string) {
super();

const bannerHost = this.getDiv();

// Ensure any HTML styling applied for the banner contents only apply to the contents,
// and not the banner's `position: 'relative'` hosting element.
const div = document.createElement('div');
div.style.userSelect = 'none';
div.style.height = '100%';
div.style.width = '100%';
bannerHost.appendChild(div);

// If possible, quarantine styling and JS for the banner contents within Shadow DOM.
this.container = (div.attachShadow) ? div.attachShadow({mode: 'closed'}) : div;
this.container.innerHTML = contents;
}

get innerHTML() {
return this.container.innerHTML;
}

set innerHTML(raw: string) {
this.container.innerHTML = raw;
}
}
Loading

0 comments on commit 8ebb725

Please sign in to comment.