Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(web): inactive banner management 🎏 #9988

Merged
merged 6 commits into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.setOptions is now gone?

Copy link
Contributor Author

@jahorton jahorton Nov 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. That paradigm started to feel really messy once I started adding in the HTML banner, especially since it obfuscates what properties are needed for which banner. Direct use of banner constructors is far, far clearer in that regard.

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.
*
* Gets the current control settings in use by `BannerManager`.
* 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.
*
* 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 = banner;
jahorton marked this conversation as resolved.
Show resolved Hide resolved
}
}

/**
* 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