Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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 {
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;
}
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);
}

/**
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.
*/
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;
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;
}

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', () => {
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