change(web): webview context configuration + connections

This commit is contained in:
Joshua A. Horton 2023-03-16 13:15:36 +07:00
parent 7be31d0372
commit 8a13a4e64d
17 changed files with 247 additions and 104 deletions

View file

@ -85,7 +85,7 @@ export default class InputProcessor {
* @returns {Object} A RuleBehavior object describing the cumulative effects of
* all matched keyboard rules
*/
processNewContextEvent(outputTarget: OutputTarget): RuleBehavior {
processNewContextEvent(outputTarget: OutputTarget): RuleBehavior {
const ruleBehavior = this.keyboardProcessor.processNewContextEvent(this.contextDevice, outputTarget);
if(ruleBehavior) {
@ -365,13 +365,7 @@ export default class InputProcessor {
}
public resetContext(outputTarget?: OutputTarget) {
this.keyboardProcessor.resetContext();
this.keyboardProcessor.resetContext(outputTarget);
this.languageProcessor.invalidateContext(outputTarget, this.keyboardProcessor.layerId);
// Let the keyboard do its initial group processing
//console.log('processNewContextEvent called from resetContext');
if(outputTarget) {
this.processNewContextEvent(outputTarget);
}
}
}

View file

@ -50,7 +50,7 @@ export default class PredictionContext extends EventEmitter<PredictionContextEve
if(originalTarget != target) {
// Note: should be triggered after the corresponding new-context event rule has been processed,
// as that may affect the value of layerId here.
return this.langProcessor.invalidateContext(target, this.kbdProcessor.layerId);
return this.resetContext();
} else {
return Promise.resolve([]);
}
@ -337,4 +337,16 @@ export default class PredictionContext extends EventEmitter<PredictionContextEve
public sendUpdateEvent() {
this.emit('update', this.currentSuggestions);
}
public resetContext(): Promise<Suggestion[]> {
const target = this.currentTarget;
if(target) {
// Note: should be triggered after the corresponding new-context event rule has been processed,
// as that may affect the value of layerId here.
return this.langProcessor.invalidateContext(target, this.kbdProcessor.layerId);
} else {
return Promise.resolve([]);
}
}
}

View file

@ -554,9 +554,15 @@ export default class KeyboardProcessor extends EventEmitter<EventMap> {
return false;
}
resetContext() {
resetContext(target?: OutputTarget) {
this.layerId = 'default';
this.keyboardInterface.resetContextCache();
// May be null if it's a keyboard swap.
if(target) {
this.processNewContextEvent(this.contextDevice, target);
}
if(!this.contextDevice.touchable) {
this._UpdateVKShift(null);
}

View file

@ -1,10 +1,15 @@
import { OutputTarget } from '@keymanapp/keyboard-processor';
import { type Keyboard, Mock, OutputTarget } from '@keymanapp/keyboard-processor';
import { type KeyboardStub } from 'keyman/engine/keyboard-cache';
import {
ContextManager as ContextManagerBase,
type KeyboardInterface
} from 'keyman/engine/main';
import { BrowserConfiguration } from './configuration.js';
export default class ContextManager extends ContextManagerBase {
private _activeKeyboard: {keyboard: Keyboard, metadata: KeyboardStub};
private config: BrowserConfiguration;
initialize(): void {
// TBD: keyman.domManager.init (the page-integration parts)
// CTRL+F: `// Exit initialization here if we're using an embedded code path.`
@ -18,6 +23,17 @@ export default class ContextManager extends ContextManagerBase {
throw new Error('Method not implemented.');
}
get activeKeyboard() {
return this._activeKeyboard;
}
set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub}) {
throw new Error('Method not implemented.');
// depends on the target
// if not set with an "independent keyboard", changes the global.
// if set with an "independent keyboard", changes only the active target's keyboard.
}
insertText(kbdInterface: KeyboardInterface, Ptext: string, PdeadKey: number) {
// Find the correct output target to manipulate.
const outputTarget = this.activeTarget;

View file

@ -1,7 +1,10 @@
import { EngineConfiguration, InitOptionSpec, InitOptionDefaults } from "keyman/engine/main";
import { type OnInsertTextFunc } from "./contextManager.js";
export class WebviewConfiguration extends EngineConfiguration {
private _embeddingApp: string;
private _oninserttext: OnInsertTextFunc;
initialize(options: Required<WebviewInitOptionSpec>) {
this.initialize(options);
@ -13,6 +16,10 @@ export class WebviewConfiguration extends EngineConfiguration {
return this._embeddingApp;
}
get oninserttext() {
return this._oninserttext;
}
debugReport(): Record<string, any> {
const baseReport = super.debugReport();
baseReport.embeddingApp = this.embeddingApp;
@ -26,10 +33,16 @@ export interface WebviewInitOptionSpec extends InitOptionSpec {
/**
* May be used to denote the name of the embedding application
*/
embeddingApp?: string;
embeddingApp: string;
/**
* Accepts a callback used for updating the host application's context.
*/
oninserttext?: OnInsertTextFunc;
}
export const WebviewInitOptionDefaults: Required<WebviewInitOptionSpec> = {
embeddingApp: '',
oninserttext: null,
...InitOptionDefaults
}

View file

@ -1,24 +1,69 @@
import { Mock } from '@keymanapp/keyboard-processor';
import { ContextManager as ContextManagerBase } from 'keyman/engine/main';
import { type Keyboard, Mock } from '@keymanapp/keyboard-processor';
import { type KeyboardStub } from 'keyman/engine/keyboard-cache';
import { ContextManagerBase, ContextManagerConfiguration } from 'keyman/engine/main';
import { WebviewConfiguration } from './configuration.js';
export type OnInsertTextFunc = (deleteLeft: number, text: string, deleteRight: number) => void;
class ContextHost extends Mock {
readonly oninserttext?: OnInsertTextFunc;
constructor(oninserttext: OnInsertTextFunc) {
super();
this.oninserttext = oninserttext;
}
apply(transform: Transform): void {
super.apply(transform);
// Signal the necessary text changes to the embedding app, if it exists.
if(this.oninserttext) {
this.oninserttext(transform.deleteLeft, transform.insert, transform.deleteRight);
}
}
// ... selected text is actually looking kinda tricky here.
}
export default class ContextManager extends ContextManagerBase {
// Change of context? Just replace the Mock. Context will be ENTIRELY controlled
// by whatever is hosting the WebView. (Some aspects of this context replacement have
// yet to be modularized at this time, though.)
private _rawContext: Mock;
private _rawContext: ContextHost;
private config: WebviewConfiguration;
constructor() {
private _activeKeyboard: {keyboard: Keyboard, metadata: KeyboardStub};
constructor(engineConfig: WebviewConfiguration) {
super();
this._rawContext = new Mock();
this.config = engineConfig;
}
initialize(): void {
// There's little distinct to do on page-load for the WebView-hosted version of KMW.
// We don't do page integration here. That said...
// TBD: keyman.domManager.init (there probably are a few embedding-specific aspects worth note)
this._rawContext = new ContextHost(this.config.oninserttext);
this.resetContext();
}
get activeTarget(): Mock {
return this._rawContext;
}
get activeKeyboard() {
return this._activeKeyboard;
}
set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub}) {
const priorEntry = this._activeKeyboard;
this._activeKeyboard = kbd;
if(priorEntry.keyboard != kbd.keyboard || priorEntry.metadata != kbd.metadata) {
this.emit('keyboardchange', kbd);
// Differs from the standard 'resetContext' by not using new-context rules; this IS
// a keyboard change, not a true context change.
this.resetKeyState();
this.predictionContext.resetContext();
}
}
}

View file

@ -23,7 +23,7 @@ export class KeymanEngine extends KeymanEngineBase<ContextManager, PassthroughKe
}
}
super(worker, config, new ContextManager());
super(worker, config, new ContextManager(config));
this.hardKeyboard = new PassthroughKeyboard(config.hardDevice);
}
@ -34,14 +34,17 @@ export class KeymanEngine extends KeymanEngineBase<ContextManager, PassthroughKe
super.init({...WebviewInitOptionDefaults, ...options});
this.contextManager.initialize();
const oskConfig: ViewConfiguration = {
hostDevice: this.config.hostDevice,
pathConfig: this.config.paths,
// When hosted in a WebView, we never hide the Web OSK without hiding the hosting WebView.
activator: new StaticActivator(),
embeddedGestureConfig: buildEmbeddedGestureConfig(this.config.softDevice),
doCacheBusting: true
}
doCacheBusting: true,
predictionContextManager: this.contextManager.predictionContext
};
this.osk = new AnchoredOSKView(oskConfig);
setupEmbeddedListeners(this, this.osk);

View file

@ -1,25 +1,8 @@
import { OutputTarget as OutputTargetBase } from "@keymanapp/keyboard-processor";
import EventEmitter from 'eventemitter3';
export interface BaseEventMap {
/**
* Meant to facilitate the following code that existed pre-modularization:
```
// This class has non-integrated unit tests in which the `singleton` object doesn't exist.
// Thus, we need to test for this case.
let keyman = com.keyman['singleton'];
// Signal the necessary text changes to the embedding app, if it exists.
if(keyman && keyman['oninserttext'] && keyman.isEmbedded) {
keyman['oninserttext'](transform.deleteLeft, transform.insert, transform.deleteRight);
}
```
*/
'oninserttext': (deleteLeft: number, insert: string, deleteRight: number) => void;
}
export default abstract class OutputTarget<EventMap extends BaseEventMap = BaseEventMap> extends OutputTargetBase {
// JS/TS can't do multiple inheritance, so we maintain class events on a readonly field.
export default abstract class OutputTarget<EventMap extends EventEmitter.ValidEventTypes> extends OutputTargetBase {
// JS/TS can't do true multiple inheritance, so we maintain class events on a readonly field.
public readonly events: EventEmitter<EventMap, this> = new EventEmitter<EventMap, this>();
/**
@ -53,10 +36,5 @@ export default abstract class OutputTarget<EventMap extends BaseEventMap = BaseE
apply(transform: Transform) {
super.apply(transform);
// The TS compiler can't quite handle this typing scenario properly; the following cast
// allows us to work around its type inference limitations.
const baseEvents = (this.events as unknown as EventEmitter<BaseEventMap, this>);
baseEvents.emit('oninserttext', transform.deleteLeft, transform.insert, transform.deleteRight);
}
}

View file

@ -1,44 +0,0 @@
import EventEmitter from 'eventemitter3';
import { type Keyboard, type KeyboardInterface, type KeyboardProperties, type OutputTarget } from '@keymanapp/keyboard-processor';
interface EventMap {
'changedcontext': (target: OutputTarget, keyboard: Keyboard) => void;
}
export default abstract class ContextManager extends EventEmitter<EventMap> {
private _activeKeyboard: {keyboard: Keyboard, metadata: KeyboardProperties};
abstract initialize(): void;
abstract get activeTarget(): OutputTarget;
// activeKeyboard: Keyboard; // probably belongs here.
insertText(kbdInterface: KeyboardInterface, Ptext: string, PdeadKey: number) {
// Find the correct output target to manipulate.
const outputTarget = this.activeTarget;
if(outputTarget != null) {
if(Ptext != null) {
kbdInterface.output(0, outputTarget, Ptext);
}
if((typeof(PdeadKey)!=='undefined') && (PdeadKey !== null)) {
kbdInterface.deadkeyOutput(0, outputTarget, PdeadKey);
}
outputTarget.invalidateSelection();
return true;
}
return false;
}
get activeKeyboard(): {keyboard: Keyboard, metadata: KeyboardProperties} {
return this._activeKeyboard;
}
}
// Intended design:
// - SiteContextManager - for website, document-aware context management
// - app/embed
// - PassthroughContextManager - for WebView-hosted, app-embedded context management.
// - app/web

View file

@ -0,0 +1,88 @@
import EventEmitter from 'eventemitter3';
import { type Keyboard, type KeyboardInterface, type OutputTarget } from '@keymanapp/keyboard-processor';
import { type KeyboardStub } from 'keyman/engine/keyboard-cache';
import { PredictionContext } from '@keymanapp/input-processor';
interface EventMap {
// target, then keyboard.
'targetchange': (target: OutputTarget) => void;
'keyboardchange': (kbd: {keyboard: Keyboard, metadata: KeyboardStub}) => void;
}
export interface ContextManagerConfiguration {
/**
* A function that resets any state-dependent keyboard key-state information such as
* emulated modifier state and layer id. Also purges the context cache.
* If an `outputTarget` is specified, it will also trigger new-context rule processing.
*
* Does not reset option-stores, variable-stores, etc.
*/
readonly resetKeyState: (outputTarget?: OutputTarget) => void;
/**
* A predictive-state management object that interfaces the predictive-text banner
* with the active context.
*/
readonly predictionContext: PredictionContext;
}
export abstract class ContextManagerBase extends EventEmitter<EventMap> {
abstract initialize(): void;
abstract get activeTarget(): OutputTarget;
private _predictionContext: PredictionContext;
private _resetKeyState: (outputTarget?: OutputTarget) => void;
get predictionContext(): PredictionContext {
return this._predictionContext;
}
protected get resetKeyState(): (outputTarget?: OutputTarget) => void {
return this._resetKeyState;
}
constructor() {
super();
}
configure(config: ContextManagerConfiguration) {
// TODO: Set in followup configuration method. Part of initialization?
this._resetKeyState = config.resetKeyState;
this._predictionContext = config.predictionContext;
}
insertText(kbdInterface: KeyboardInterface, Ptext: string, PdeadKey: number) {
// Find the correct output target to manipulate.
const outputTarget = this.activeTarget;
if(outputTarget != null) {
if(Ptext != null) {
kbdInterface.output(0, outputTarget, Ptext);
}
if((typeof(PdeadKey)!=='undefined') && (PdeadKey !== null)) {
kbdInterface.deadkeyOutput(0, outputTarget, PdeadKey);
}
outputTarget.invalidateSelection();
return true;
}
return false;
}
resetContext() {
this._resetKeyState(this.activeTarget);
this.predictionContext.resetContext();
}
abstract get activeKeyboard(): {keyboard: Keyboard, metadata: KeyboardStub};
abstract set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub});
}
// Intended design:
// - SiteContextManager - for website, document-aware context management
// - app/embed
// - PassthroughContextManager - for WebView-hosted, app-embedded context management.
// - app/web

View file

@ -1,5 +1,5 @@
export { EngineConfiguration, InitOptionDefaults, InitOptionSpec } from './engineConfiguration.js';
export { default as ContextManager } from './contextManager.js';
export { ContextManagerBase, ContextManagerConfiguration } from './contextManagerBase.js';
export { default as HardKeyboard } from './hardKeyboard.js';
export { default as KeyboardInterface } from './keyboardInterface.js';
export { default as KeymanEngine } from './keymanEngine.js';

View file

@ -5,18 +5,18 @@ import {
} from "@keymanapp/keyboard-processor";
import { KeyboardStub, RawKeyboardStub, StubAndKeyboardCache } from 'keyman/engine/keyboard-cache';
import ContextManager from './contextManager.js';
import { ContextManagerBase } from './contextManagerBase.js';
import { VariableStoreCookieSerializer } from "./variableStoreCookieSerializer.js";
export default class KeyboardInterface extends KeyboardInterfaceBase {
private readonly contextManager: ContextManager;
private readonly contextManager: ContextManagerBase;
private stubAndKeyboardCache: StubAndKeyboardCache;
private stubNamespacer?: (stub: RawKeyboardStub) => void;
constructor(
_jsGlobal: any,
keymanGlobal: KeyboardKeymanGlobal,
contextManager: ContextManager,
contextManager: ContextManagerBase,
stubNamespacer?: (stub: RawKeyboardStub) => void
) {
super(_jsGlobal, keymanGlobal, new VariableStoreCookieSerializer());

View file

@ -6,7 +6,7 @@ import { KeyboardRequisitioner } from "keyman/engine/keyboard-cache";
import { EngineConfiguration, InitOptionDefaults, InitOptionSpec } from "./engineConfiguration.js";
import KeyboardInterface from "./keyboardInterface.js";
import ContextManagerBase from "./contextManager.js";
import { ContextManagerBase } from "./contextManagerBase.js";
import { KeyEventHandler } from './keyEventSource.interface.js';
import HardKeyboardBase from "./hardKeyboard.js";
import { LegacyAPIEventEngine } from "./legacyAPIEvents.js";
@ -66,6 +66,9 @@ export default class KeymanEngine<ContextManager extends ContextManagerBase, Har
defaultOutputRules: new DefaultRules()
};
};
//
/**
* @param worker A configured WebWorker to serve as the predictive-text engine's main thread.
* Available in the following variants:
@ -88,6 +91,16 @@ export default class KeymanEngine<ContextManager extends ContextManagerBase, Har
this.processor = new InputProcessor(config.hostDevice, worker, this.processorConfiguration());
this.contextManager.configure({
resetKeyState: (target) => {
this.processor.keyboardProcessor.resetContext(target);
},
predictionContext: new PredictionContext(this.processor.languageProcessor, this.processor.keyboardProcessor)
});
// TODO: configure that context-manager!
// #region Event handler wiring
cache.on('stubAdded', (stub) => {
let eventRaiser = () => {
// The corresponding event is needed in order to update UI modules as new keyboard stubs "come online".
@ -121,6 +134,13 @@ export default class KeymanEngine<ContextManager extends ContextManagerBase, Har
this.config.deferForInitialization.then(eventRaiser);
}
});
contextManager.on('keyboardchange', (kbd) => {
if(this.osk) {
this.osk.activeKeyboard = kbd;
}
});
// #endregion
}
init(optionSpec: Required<InitOptionSpec>): void {

View file

@ -423,8 +423,10 @@ export class SuggestionBanner extends Banner {
// connect the new one!
this._predictionContext = context;
context.on('update', this.onSuggestionUpdate);
this.onSuggestionUpdate(context.currentSuggestions);
if(context) {
context.on('update', this.onSuggestionUpdate);
this.onSuggestionUpdate(context.currentSuggestions);
}
}
public onSuggestionUpdate = (suggestions: Suggestion[]): void => {

View file

@ -5,7 +5,7 @@ import OSKViewComponent from '../components/oskViewComponent.interface.js';
import { ParsedLengthStyle } from '../lengthStyle.js';
import { DeviceSpec } from '@keymanapp/web-utils';
import type { StateChangeEnum } from '@keymanapp/input-processor';
import type { PredictionContext, StateChangeEnum } from '@keymanapp/input-processor';
import { createUnselectableElement } from 'keyman/engine/dom-utils';
/**
@ -158,6 +158,8 @@ export class BannerController {
private alwaysShow: boolean;
private imagePath?: string = "";
private predictionContext?: PredictionContext;
private readonly hostDevice: DeviceSpec;
public static readonly DEFAULT_OPTIONS: BannerOptions = {
@ -165,10 +167,11 @@ export class BannerController {
imagePath: ""
}
constructor(bannerView: BannerView, hostDevice: DeviceSpec) {
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.
@ -281,8 +284,8 @@ export class BannerController {
} else {
this.setBanner('blank');
}
} else if(state == 'configured') {
// TODO: refresh PredictionContext once available.
} else if(state == 'configured' && this.activeBanner instanceof SuggestionBanner) {
this.activeBanner.predictionContext = this.predictionContext || null;
}
}

View file

@ -1,3 +1,4 @@
import { type PredictionContext } from "@keymanapp/input-processor";
import type Activator from "../views/activator.js";
import CommonConfiguration from "./commonConfiguration.js";
@ -33,4 +34,10 @@ export default interface Configuration extends CommonConfiguration {
* Defaults to 'false'.
*/
doCacheBusting?: boolean;
/**
* A predictive-state management object that interfaces the predictive-text banner
* with the active context.
*/
predictionContextManager?: PredictionContext;
}

View file

@ -322,7 +322,7 @@ export default abstract class OSKView extends EventEmitter<EventMap> implements
this.bannerView = new BannerView();
this.bannerView.events.on('bannerchange', () => this.refreshLayout());
this._bannerController = new BannerController(this.bannerView, this.hostDevice);
this._bannerController = new BannerController(this.bannerView, this.hostDevice, this.config.predictionContextManager);
this.keyboardView = null;
@ -337,7 +337,7 @@ export default abstract class OSKView extends EventEmitter<EventMap> implements
}
public get bannerController(): BannerController {
return this.bannerController;
return this._bannerController;
}
public get hostDevice(): DeviceSpec {