Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 9 additions & 6 deletions web/docs/engine/reference/core/setKeyboardForControl.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@ Associate control with independent keyboard settings initialized to a specific k
## Syntax

```js
keyman.setDefaultKeyboardForControl(Pelem, keyboard, languageCode);
keyman.setKeyboardForControl(elem, keyboard, languageCode);
```

### Parameters

`Pelem`
`elem`
: Type: `Element`
: The control element to be managed manually.

`keyboard`
: Type: `string` *optional*
: Type: `string | null` *optional*
: The ID (internal name) of a keyboard.

`languageCode`
: Type: `string` *optional*
: Type: `string | null` *optional*
: The three-letter language code for the keyboard.

### Return Value
Expand All @@ -34,14 +34,17 @@ keyman.setDefaultKeyboardForControl(Pelem, keyboard, languageCode);

This function establishes the control with separately-managed keyboard
settings from other, non-specialized controls on the page. This may be
undone by setting both `keyboard` and `languageCode` to null, reverting
the control back to default keyboard-management behavior.
undone by setting both `keyboard` and `languageCode` to null,
reverting the control back to default keyboard-management behavior.

Note that either **both** parameters should be set or **neither**. In
particular, if `languageCode` is specified but not `keyboard`, this
method will not automatically select an appropriate keyboard, even if
one has previously been registered with that language code.

Set `keyboard` and `languageCode` to the empty string (`''`) to select the system
keyboard (on non-touch devices), or the first installed keyboard (on touch devices).

Due to system limitations, this function will fail if called on an IFRAME element.

See also [Control-by-Control Example (guide)](../../guide/examples/control-by-control)
38 changes: 23 additions & 15 deletions web/src/app/browser/src/contextManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,21 +366,22 @@ export class ContextManager extends ContextManagerBase<BrowserConfiguration> {
protected currentKeyboardSrcTextStore(): AbstractElementTextStore<any> | null {
const textStore = this.currentTextStore || this.mostRecentTextStore;

if(this.isTextStoreKeyboardIndependent(textStore)) {
if(this.isElementInIndependentMode(textStore?.getElement())) {
return textStore;
}
return null;
}

private isTextStoreKeyboardIndependent(textStore: AbstractElementTextStore<any>): boolean {
const attachment = textStore?.getElement()._kmwAttachment;
public isElementInIndependentMode(element: HTMLElement | null): boolean {
const attachment = element?._kmwAttachment;

// If null or undefined, we're in 'global' mode.
return !!(attachment?.keyboard || attachment?.keyboard === '');
return !!(attachment &&
(attachment.keyboard !== undefined && attachment.keyboard !== null));
}

// Note: is part of the keyboard activation process. Not to be called directly by published API.
public activateKeyboardForTextStore(kbd: KeyboardInfoPair, textStore: AbstractElementTextStore<any>): void {
protected activateKeyboardForTextStore(kbd: KeyboardInfoPair, textStore: AbstractElementTextStore<any>): void {
const attachment = textStore?.getElement()._kmwAttachment;

if(!attachment) {
Expand Down Expand Up @@ -421,7 +422,7 @@ export class ContextManager extends ContextManagerBase<BrowserConfiguration> {
* @param kbdId
* @param langId
*/
public setKeyboardForTextStore(textStore: AbstractElementTextStore<any>, kbdId: string, langId: string): void {
public setKeyboardForTextStore(textStore: AbstractElementTextStore<any>, kbdId: string | null, langId: string | null): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not use kbdId?: string, langId?: string instead? That way, there's no need for the nullish fallbacksin line 317 of keymanEngine.ts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Since null is one of the possible values I'd like to keep it in the type to be explicit. But I can make the arguments optional so that the signature is similar to KeymanEngine.setKeyboardForControl.

Done.

if(textStore instanceof DesignIFrameElementTextStore) {
console.warn("'keymanweb.setKeyboardForControl' cannot set keyboard on iframes.");
return;
Expand All @@ -436,28 +437,33 @@ export class ContextManager extends ContextManagerBase<BrowserConfiguration> {
if(!attachment) {
return;
} else {
if(wasPriorTextStore && kbdId === null) {
this.findAndPopActivation(textStore);
}

// Either establishes or cancels independent-keyboard mode by setting the
// associated metadata. This will have direct effects on the results
// of .currentKeyboardSrcTextStore().
attachment.keyboard = kbdId || null;
attachment.languageCode = langId || null;
attachment.keyboard = kbdId ?? null;
attachment.languageCode = langId ?? null;

// If it has just entered independent-keyboard mode, we need the second check.
if(wasPriorTextStore || this.currentKeyboardSrcTextStore() == textStore) {
const globalKbd = this.globalKeyboard.metadata;

// The `||` bits below - in case we're cancelling independent-keyboard mode.
// `??` preserves empty-string values for an explicit "system keyboard" state,
// while falling back to the global keyboard only when the control is truly unset.
this.activateKeyboard(
attachment.keyboard || globalKbd.id,
attachment.languageCode || globalKbd.langId,
attachment.keyboard ?? globalKbd.id,
attachment.languageCode ?? globalKbd.langId,
true
);
}
}
}

public getKeyboardStubForTextStore(textStore: AbstractElementTextStore<any>) {
if(!this.isTextStoreKeyboardIndependent(textStore)) {
if(!this.isElementInIndependentMode(textStore?.getElement())) {
return this.globalKeyboard.metadata;
} else {
const attachment = textStore.getElement()._kmwAttachment;
Expand Down Expand Up @@ -578,7 +584,7 @@ export class ContextManager extends ContextManagerBase<BrowserConfiguration> {
langCode = lgCode;
}

if(lastElem && lastElem._kmwAttachment.keyboard != null) {
if (lastElem && this.isElementInIndependentMode(lastElem)) {
lastElem._kmwAttachment.keyboard = keyboardID;
lastElem._kmwAttachment.languageCode = langCode;
} else {
Expand All @@ -598,8 +604,10 @@ export class ContextManager extends ContextManagerBase<BrowserConfiguration> {
const attachment = lastElem._kmwAttachment;
const global = this.globalKeyboard;

if(attachment.keyboard != null) {
this.activateKeyboard(attachment.keyboard, attachment.languageCode, true);
if (this.isElementInIndependentMode(lastElem)) {
const keyboardId = attachment.keyboard ?? global?.metadata.id ?? '';
const languageCode = attachment.languageCode ?? global?.metadata.langId ?? '';
this.activateKeyboard(keyboardId, languageCode, true);
} else if(!blockGlobalChange && (global?.metadata != this._activeKeyboard?.metadata)) {
// TODO: can we drop `!blockGlobalChange` in favor of the latter check?
this.activateKeyboard(global?.metadata.id, global?.metadata.langId, true);
Expand Down
36 changes: 24 additions & 12 deletions web/src/app/browser/src/keymanEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,10 @@ export class KeymanEngine extends KeymanEngineBase<BrowserConfiguration, Context
* @param {string|null=} languageCode A BCP47 language code which was used when
* registering the keyboard stub.
*/
public setKeyboardForControl(elem: HTMLElement, keyboard?: string, languageCode?: string): void {
public setKeyboardForControl(elem: HTMLElement, keyboard?: string | null, languageCode?: string | null): void {
if (!elem.ownerDocument.defaultView) {
return;
}
Comment on lines +300 to +302

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What role does this new conditional play?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Just to play safe. defaultView could theoretically be null.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It seems unlikely? But if we want to really play safe, we should:

Suggested change
if (!elem.ownerDocument.defaultView) {
return;
}
if (!elem?.ownerDocument?.defaultView) {
return;
}

That will cover cases where elem is not set.

In some ways, this may be misleading -- perhaps it would be better for an error to be raised, so the consumer knows that this failed. Either by returning false or by throw:

Suggested change
if (!elem.ownerDocument.defaultView) {
return;
}
if (!elem?.ownerDocument?.defaultView) {
return false;
}
Suggested change
if (!elem.ownerDocument.defaultView) {
return;
}
if (!elem?.ownerDocument?.defaultView) {
throw new Error('setKeyboardForControl: elem null or has no default View');
}

However, my primary thought here is that there are so many things we could consider for all the API endpoints -- this is a piecemeal validation step which will probably become inconsistent over time with other API endpoints, so really we should be thinking about parameter validation across the whole API -- types, object shape, return values / exceptions / console.warn / console.error, and implement that consistently. That would be a better outcome than micro patches. We see a similar issue with lines 299-302 below, where we have a console warning raised which is not really visible to the API consumer at runtime -- so becomes an invisible log message in 99% of cases.

if(elem instanceof elem.ownerDocument.defaultView.HTMLIFrameElement) {
console.warn("'keymanweb.setKeyboardForControl' cannot set keyboard on iframes.");
return;
Expand All @@ -311,7 +314,7 @@ export class KeymanEngine extends KeymanEngineBase<BrowserConfiguration, Context
}
}

this.contextManager.setKeyboardForTextStore(elem._kmwAttachment.textStore, keyboard, languageCode);
this.contextManager.setKeyboardForTextStore(elem._kmwAttachment.textStore, keyboard ?? null, languageCode ?? null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would prefer changes that leave these two parameters completely pass-through. Why change this line when an equally-simple change avoids the need for it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

}

/**
Expand All @@ -320,13 +323,20 @@ export class KeymanEngine extends KeymanEngineBase<BrowserConfiguration, Context
*
* See https://help.keyman.com/developer/engine/web/current-version/reference/core/getKeyboardForControl
*
* @param {Element} Pelem Control element
* @param {Element} elem Control element
* @return {string|null} The independently-managed keyboard for the control,
* or null if it is following the global keyboard setting.
Comment on lines 331 to 332

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This may be '' to mean 'independently-managed but set to system keyboard', I think according to the intention of the spec. We should make sure that is clear

*/
public getKeyboardForControl(Pelem: HTMLElement): string | null{
const textStore = textStoreForElement(Pelem);
return this.contextManager.getKeyboardStubForTextStore(textStore).id;
public getKeyboardForControl(elem: HTMLElement): string | null{
if(!elem || !this.contextManager.isElementInIndependentMode(elem)) {
return null;
}
const keyboard = elem._kmwAttachment.keyboard;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What if _kmwAttachment is not defined?

Suggested change
const keyboard = elem._kmwAttachment.keyboard;
const keyboard = elem._kmwAttachment?.keyboard ?? '';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also suggest that we use keyboardId for consistency. Assuming that is what it is?

if(keyboard === '') {
return '';
}
const stub = this.keyboardRequisitioner.cache.getStub(keyboard, elem._kmwAttachment.languageCode);
return stub?.KI ?? keyboard;
}

// Is not currently published API... but it exists.
Expand All @@ -335,13 +345,15 @@ export class KeymanEngine extends KeymanEngineBase<BrowserConfiguration, Context
* for this control. If it is currently following the global keyboard setting,
* returns null instead.
*
* @param {Element} Pelem Control element
* @return {string|null} The independently-managed keyboard for the control,
* or null if it is following the global keyboard setting.
* @param {Element} elem Control element
* @return {string|null} The independently-managed keyboard for the control,
* or null if it is following the global keyboard setting.
*/
public getLanguageForControl(Pelem: HTMLElement): string | null {
const textStore = textStoreForElement(Pelem);
return this.contextManager.getKeyboardStubForTextStore(textStore).langId;
public getLanguageForControl(elem: HTMLElement): string | null {
if(!elem || !this.contextManager.isElementInIndependentMode(elem)) {
return null;
}
return elem._kmwAttachment.languageCode;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
return elem._kmwAttachment.languageCode;
return elem._kmwAttachment?.languageCode ?? '';

Query: should this be returning '' for system-specified language (matching the shape of getKeyboardForControl) or null (which is simpler to reason on)?

}

public isAttached(x: HTMLElement): boolean {
Expand Down
5 changes: 2 additions & 3 deletions web/src/app/browser/src/languageMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ export class LanguageMenu {
}

/**
* Add a keyboard entry to the language menu *
* Add a keyboard entry to the language menu
*
* @param {Object} kbd keyboard object
* @param {Object} kb element being added and styled
Expand Down Expand Up @@ -558,7 +558,6 @@ export class LanguageMenu {

languageMenu.lgList.style.display='none'; //still allows blank menu momentarily on selection
languageMenu.keyman.contextManager.activateKeyboard(entry.kn, entry.kc,true);
languageMenu.keyman.contextManager.restoreLastActiveTextStore();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is already done in activateKeyboard, so there's no need to do it twice (especially since activateKeyboard is async, so this call could possibly work with outdated data...)

languageMenu.hide();
}

Expand Down Expand Up @@ -598,4 +597,4 @@ export class LanguageMenu {

this.keyman.touchLanguageMenu = null;
}
}
}
9 changes: 6 additions & 3 deletions web/src/engine/src/attachment/attachmentInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export class AttachmentInfo {
/**
* Tracks the language code corresponding to the `keyboard` field.
*/
public languageCode: string;
public languageCode: string | null = null;

/**
* Tracks the inputmode originally set by the webpage.
Expand All @@ -15,7 +15,10 @@ export class AttachmentInfo {
* Constructor for AttachmentInfo.
*
* @param textStore - Provides the core interface between the DOM and the actual keyboard.
* @param keyboard - Provides the keyboard identifier.
* @param keyboard - Provides the keyboard identifier, empty string for system keyboard,
* or null to use the global keyboard.
*/
constructor(public readonly textStore: AbstractElementTextStore<any>, public keyboard: string) {}
constructor(
public readonly textStore: AbstractElementTextStore<any>,
public keyboard: string | null) { }
}
15 changes: 9 additions & 6 deletions web/src/engine/src/main/contextManagerBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,16 @@ export abstract class ContextManagerBase<MainConfig extends EngineConfiguration>
protected abstract activateKeyboardForTextStore(kbd: KeyboardInfoPair, textStore: TextStore): void;

/**
* Checks the pending keyboard-activation array for an entry corresponding to the specified
* TextStore. If found, also removes the entry for bookkeeping purposes.
* @param textStore The specific TextStore affected by the pending Keyboard activation.
* May be `null`, which corresponds to the global default Keyboard.
* @returns `true` if pending activation is still valid, `false` otherwise.
* Checks the pending keyboard-activation array for an entry
* corresponding to the specified TextStore. If found, also removes
* the entry for bookkeeping purposes.
* @param textStore The specific TextStore affected by the pending
* Keyboard activation. May be `null`, which
* corresponds to the global default Keyboard.
* @returns the pending activation for the specified TextStore, or
* `null` if no such activation exists.
*/
private findAndPopActivation(textStore: TextStore): PendingActivation {
protected findAndPopActivation(textStore: TextStore): PendingActivation {
// Array.findIndex requires Chrome 45+. :(
let activationIndex;
for(activationIndex = 0; activationIndex < this.pendingActivations.length; activationIndex++) {
Expand Down
30 changes: 28 additions & 2 deletions web/src/test/auto/dom/cases/browser/contextManager.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,7 @@ describe('app/browser: ContextManager', function () {

// Actual test: transitioning focus from an independent-mode target
// to a global-mode target.
contextManager.setKeyboardForTextStore(textStore, '', '');
contextManager.setKeyboardForTextStore(textStore, null, null);

const beforekeyboardchange = sinon.fake();
const keyboardchange = sinon.fake();
Expand Down Expand Up @@ -1017,7 +1017,7 @@ describe('app/browser: ContextManager', function () {

// Actual test: transitioning focus from an independent-mode target
// to a global-mode target.
contextManager.setKeyboardForTextStore(textStore, '', '');
contextManager.setKeyboardForTextStore(textStore, null, null);

// Allow the indirect keyboard-change operation to resolve.
await timedPromise(10);
Expand Down Expand Up @@ -1250,6 +1250,32 @@ describe('app/browser: ContextManager', function () {
assert.isTrue(keyboardasyncload.calledTwice);
assert.strictEqual(contextManager.activeKeyboard.metadata, KEYBOARDS.test_chirality.metadata);
});

it('cancels pending activation when leaving independent mode', async () => {
const FETCH_DELAY = 50;

keyboardCache.addKeyboard(KEYBOARDS.khmer_angkor.keyboard);

await contextManager.activateKeyboard('khmer_angkor', 'km');

const textarea = document.getElementById('textarea');
const textStore = textStoreForElement(textarea);

withDelayedFetching(keyboardLoader, FETCH_DELAY, () => {
contextManager.setKeyboardForTextStore(textStore, 'lao_2008_basic', 'lo');
});

await Promise.resolve();

contextManager.setKeyboardForTextStore(textStore, null, null);

await timedPromise(FETCH_DELAY + 10);

const attachment = textStore.getElement()._kmwAttachment;
assert.isNull(attachment.keyboard);
assert.equal((contextManager as any).currentKeyboardSrcTextStore(), null);
assert.strictEqual(contextManager.activeKeyboard.metadata, KEYBOARDS.khmer_angkor.metadata);
});
});
});

Expand Down
58 changes: 58 additions & 0 deletions web/src/test/auto/dom/cases/browser/keymanEngine.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*/
import { KeymanEngine } from 'keyman/app/browser';
import { StubAndKeyboardCache } from 'keyman/engine/keyboard-storage';
import { assert } from 'chai';

const mockWorkerFactory = {
constructInstance: (): null => null
};

describe('KeymanEngine.getKeyboardForControl', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No explicit .setKeyboardForControl tests here?

While I guess they're kinda tied, you should also be able to verify three things:

  1. A keystroke (either physical or via OSK) results in the correct output character
  2. Swapping to a second, still-global control swaps the current "active keyboard" reported by the engine to the global keyboard setting.
  3. Swapping back to the original control restores its setting and what the "current keyboard" reported by the engine is.

I thought we had some old automated tests that might have already been testing points 2 and 3, but I don't see them upon a search.

They did exist back in stable-16.0, but apparently they got erased at some point by accident during work toward stable-17.0. Here's a permalink to the relevant automated tests from before:

it("Keyboard Management (active control)", function() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added e2e tests

let engine: KeymanEngine;
let keyboardCache: StubAndKeyboardCache;

beforeEach(() => {
engine = new KeymanEngine(mockWorkerFactory, '');
keyboardCache = new StubAndKeyboardCache();
(engine as any).keyboardRequisitioner = {
cache: keyboardCache
};
});

it('returns null for controls in global mode', () => {
const input = document.createElement('input');
document.body.appendChild(input);
engine.attachToControl(input);

assert.isNull(engine.getKeyboardForControl(input));
});

it('returns empty string for explicit system-keyboard mode', () => {
const input = document.createElement('input');
document.body.appendChild(input);
engine.attachToControl(input);

engine.setKeyboardForControl(input, '', '');
assert.equal(engine.getKeyboardForControl(input), '');
});

it('returns canonical prefixed ID after setting an unprefixed ID', () => {
const stub = {
KI: 'Keyboard_lao_2008_basic',
KN: 'Lao 2008 Basic',
KL: 'Lao',
KLC: 'lo',
KF: 'resources/keyboards/lao_2008_basic.js',
} as any;
keyboardCache.addStub(stub);

const input = document.createElement('input');
document.body.appendChild(input);
engine.attachToControl(input);

engine.setKeyboardForControl(input, 'lao_2008_basic', 'lo');
assert.equal(engine.getKeyboardForControl(input), 'Keyboard_lao_2008_basic');
});
});
Loading