diff --git a/web/docs/engine/reference/core/setKeyboardForControl.md b/web/docs/engine/reference/core/setKeyboardForControl.md index 007c1e6fb13..b088d137a72 100644 --- a/web/docs/engine/reference/core/setKeyboardForControl.md +++ b/web/docs/engine/reference/core/setKeyboardForControl.md @@ -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 @@ -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) diff --git a/web/src/app/browser/src/contextManager.ts b/web/src/app/browser/src/contextManager.ts index d1d428c1ae9..8e171a77fe4 100644 --- a/web/src/app/browser/src/contextManager.ts +++ b/web/src/app/browser/src/contextManager.ts @@ -366,21 +366,22 @@ export class ContextManager extends ContextManagerBase { protected currentKeyboardSrcTextStore(): AbstractElementTextStore | null { const textStore = this.currentTextStore || this.mostRecentTextStore; - if(this.isTextStoreKeyboardIndependent(textStore)) { + if(this.isElementInIndependentMode(textStore?.getElement())) { return textStore; } return null; } - private isTextStoreKeyboardIndependent(textStore: AbstractElementTextStore): 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): void { + protected activateKeyboardForTextStore(kbd: KeyboardInfoPair, textStore: AbstractElementTextStore): void { const attachment = textStore?.getElement()._kmwAttachment; if(!attachment) { @@ -421,7 +422,7 @@ export class ContextManager extends ContextManagerBase { * @param kbdId * @param langId */ - public setKeyboardForTextStore(textStore: AbstractElementTextStore, kbdId: string, langId: string): void { + public setKeyboardForTextStore(textStore: AbstractElementTextStore, kbdId: string | null, langId: string | null): void { if(textStore instanceof DesignIFrameElementTextStore) { console.warn("'keymanweb.setKeyboardForControl' cannot set keyboard on iframes."); return; @@ -436,20 +437,25 @@ export class ContextManager extends ContextManagerBase { 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 ); } @@ -457,7 +463,7 @@ export class ContextManager extends ContextManagerBase { } public getKeyboardStubForTextStore(textStore: AbstractElementTextStore) { - if(!this.isTextStoreKeyboardIndependent(textStore)) { + if(!this.isElementInIndependentMode(textStore?.getElement())) { return this.globalKeyboard.metadata; } else { const attachment = textStore.getElement()._kmwAttachment; @@ -578,7 +584,7 @@ export class ContextManager extends ContextManagerBase { langCode = lgCode; } - if(lastElem && lastElem._kmwAttachment.keyboard != null) { + if (lastElem && this.isElementInIndependentMode(lastElem)) { lastElem._kmwAttachment.keyboard = keyboardID; lastElem._kmwAttachment.languageCode = langCode; } else { @@ -598,8 +604,10 @@ export class ContextManager extends ContextManagerBase { 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); diff --git a/web/src/app/browser/src/keymanEngine.ts b/web/src/app/browser/src/keymanEngine.ts index 9b56247cdd1..531e0970a77 100644 --- a/web/src/app/browser/src/keymanEngine.ts +++ b/web/src/app/browser/src/keymanEngine.ts @@ -292,7 +292,10 @@ export class KeymanEngine extends KeymanEngineBase, public keyboard: string) {} + constructor( + public readonly textStore: AbstractElementTextStore, + public keyboard: string | null) { } } diff --git a/web/src/engine/src/main/contextManagerBase.ts b/web/src/engine/src/main/contextManagerBase.ts index 931c18c039b..887fb0564ff 100644 --- a/web/src/engine/src/main/contextManagerBase.ts +++ b/web/src/engine/src/main/contextManagerBase.ts @@ -161,13 +161,16 @@ export abstract class ContextManagerBase 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++) { diff --git a/web/src/test/auto/dom/cases/browser/contextManager.tests.ts b/web/src/test/auto/dom/cases/browser/contextManager.tests.ts index ce96c2a3561..a86e0a20f71 100644 --- a/web/src/test/auto/dom/cases/browser/contextManager.tests.ts +++ b/web/src/test/auto/dom/cases/browser/contextManager.tests.ts @@ -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(); @@ -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); @@ -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); + }); }); }); diff --git a/web/src/test/auto/dom/cases/browser/keymanEngine.tests.ts b/web/src/test/auto/dom/cases/browser/keymanEngine.tests.ts new file mode 100644 index 00000000000..845858ce70b --- /dev/null +++ b/web/src/test/auto/dom/cases/browser/keymanEngine.tests.ts @@ -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'); + }); +}); diff --git a/web/src/test/auto/e2e/e2eUtils.ts b/web/src/test/auto/e2e/e2eUtils.ts index 0ef45ffec00..ee9ffa191da 100644 --- a/web/src/test/auto/e2e/e2eUtils.ts +++ b/web/src/test/auto/e2e/e2eUtils.ts @@ -4,34 +4,64 @@ import { type Locator, type Page } from "@playwright/test"; +declare const keyman: any; + +/** + * Wait until the keyboard menu is updated and shows the active keyboard + */ +export async function waitForKeyboardSelection(page: Page): Promise { + await page.waitForFunction(() => { + const activeKbd = keyman.getActiveKeyboard(); + const activeLang = keyman.getActiveLanguage(); + const selectedElem = document.querySelector('#kmwico .selected'); + if (!selectedElem) { + return false; + } + const selectedText = (selectedElem.textContent || '').trim(); + + if (!activeKbd) { + return selectedText === '(System keyboard)'; + } + + const keyboards = keyman.getKeyboards(); + for (const kbd of keyboards) { + if (kbd.InternalName === activeKbd && kbd.LanguageCode === activeLang) { + return selectedText === `${kbd.LanguageName} - ${kbd.Name}`; + } + } + return false; + }, { timeout: 5000 }); +} + /** * Expands the keyboard selection menu and returns the text content of the * currently selected keyboard. */ export async function getSelectedKeyboardMenuText(page: Page): Promise { - const watchDog = page.waitForFunction(() => !!document.getElementById('KeymanWeb_KbdList')); - await page.getByRole('img', { name: 'Use Web Keyboard' }).click(); - await watchDog; + await page.waitForFunction(() => document.getElementById('kmwico')); + await page.locator('#kmwico').hover(); + await page.waitForFunction(() => document.querySelector('#KeymanWeb_KbdList.sfhover')); + await waitForKeyboardSelection(page); return page.evaluate(() => { const selectedKbd = document.querySelector('#kmwico .selected'); - return selectedKbd?.textContent; + return selectedKbd?.textContent?.trim(); }); -}; +} /** * Expands the keyboard selection menu and returns the menu items as an array */ export async function getAllKeyboardMenuText(page: Page): Promise<(string|undefined)[]> { - const watchDog = page.waitForFunction(() => !!document.getElementById('KeymanWeb_KbdList')); - await page.getByRole('img', { name: 'Use Web Keyboard' }).hover(); - await watchDog; + await page.waitForFunction(() => document.getElementById('kmwico')); + await page.locator('#kmwico').hover(); + await page.waitForFunction(() => document.querySelector('#KeymanWeb_KbdList.sfhover')); return page.evaluate(() => { - const menuItems = []; + const menuItems: (string | undefined)[] = []; const menuDiv = document.querySelector('#kmwico'); const kbdList = menuDiv?.lastElementChild; for (let i = 0; i < (kbdList ? kbdList.children.length : 0); i++) { const item = kbdList?.children[i]; - menuItems.push(item?.textContent); + menuItems.push(item?.textContent?.trim()); } return menuItems; }); @@ -51,14 +81,8 @@ export async function loadPage(page: Page, url: string): Promise { * locator for the OSK title bar. */ export async function clickFieldAndWaitForOSK(page: Page, fieldLocator: Locator): Promise { - const keyboardchangePromise = page.evaluate(async () => { - return new Promise((resolve) => { - keyman.addEventListener('keyboardchange', function (kbd) { - resolve(kbd); - }); - }); - }); await fieldLocator.click(); - await keyboardchangePromise; + await waitForKeyboardSelection(page); + await page.waitForFunction(() => keyman.osk.isVisible()); return page.locator('#keymanweb_title_bar'); } diff --git a/web/src/test/auto/e2e/guide-examples.tests.ts b/web/src/test/auto/e2e/guide-examples.tests.ts index 574ddf6276e..ee449daca54 100644 --- a/web/src/test/auto/e2e/guide-examples.tests.ts +++ b/web/src/test/auto/e2e/guide-examples.tests.ts @@ -4,14 +4,35 @@ import { test, expect, type Page } from '@playwright/test'; import { clickFieldAndWaitForOSK, getAllKeyboardMenuText, getSelectedKeyboardMenuText, loadPage } from './e2eUtils'; -async function setTimeoutAndLoadPage(page: Page, url: string): Promise { +declare const keyman: any; + +async function setTimeoutAndLoadPage(page: Page, url: string, numKeyboards: number): Promise { test.setTimeout(5000); + await loadPage(page, url); + + await page.waitForFunction( + (num: number) => typeof keyman !== 'undefined' && keyman.getKeyboards().length >= num, + numKeyboards + ); + + // Now that we know that the expected number of keyboards were loaded, we can + // force trigger updateKeyboardList() instead of waiting for the timeout which + // might come in the middle of the tests + await page.evaluate(() => { + if (keyman.ui && keyman.ui.updateTimer) { + clearTimeout(keyman.ui.updateTimer); + } + keyman.ui?.updateKeyboardList(); + }); } -test.describe.skip('First example from the guide', function () { +test.describe('First example from the guide', function () { const beforeEach = async (page: Page) => { - await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__first-example.html'); + // output messages from the browser console to the test output, for debugging + page.on('console', msg => console.log(msg.text())); + + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__first-example.html', 2); } test('Input field shows US keyboard', async ({ page } : { page: Page }) => { @@ -22,8 +43,8 @@ test.describe.skip('First example from the guide', function () { // Verify OSK shows US keyboard await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).toBeVisible(); - await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); - await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect.poll(() => page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect.poll(() => page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); await expect(oskTitleBar).toContainText('US'); await expect(await getSelectedKeyboardMenuText(page)).toBe('English - US'); @@ -41,9 +62,12 @@ test.describe.skip('First example from the guide', function () { }); }); -test.describe.skip('Auto-control example from the guide', function () { +test.describe('Auto-control example from the guide', function () { const beforeEach = async (page: Page) => { - await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__auto-control.html'); + // output messages from the browser console to the test output, for debugging + page.on('console', msg => console.log(msg.text())); + + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__auto-control.html', 1); } test('Input field shows Lao keyboard', async ({ page } : { page: Page }) => { @@ -52,8 +76,8 @@ test.describe.skip('Auto-control example from the guide', function () { await page.getByTestId('multilingual' ).click(); // Verify OSK is shown - await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); - await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect.poll(() => page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect.poll(() => page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao (Phonetic)'); }); @@ -69,9 +93,12 @@ test.describe.skip('Auto-control example from the guide', function () { }); }); -test.describe.skip('Control-by-control example from the guide', function () { +test.describe('Control-by-control example from the guide', function () { const beforeEach = async (page: Page) => { - await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__control-by-control.html'); + // output messages from the browser console to the test output, for debugging + page.on('console', msg => console.log(msg.text())); + + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__control-by-control.html', 6); } test('address field does not have KeymanWeb enabled', async ({ page } : { page: Page }) => { @@ -86,17 +113,17 @@ test.describe.skip('Control-by-control example from the guide', function () { await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).not.toBeVisible(); }); - // TODO: #16080 - test.skip('subject field does not show keyboard and defaults to system keyboard', async ({ page } : { page: Page }) => { + test('subject field does not show keyboard and defaults to system keyboard', async ({ page } : { page: Page }) => { // Setup await beforeEach(page); await page.getByPlaceholder('id = subject').click(); - // Verify OSK is shown + // Verify the control is in system-keyboard mode: the OSK stays hidden, + // while the toggle UI remains available for switching keyboards. await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); - await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeFalsy(); await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); - await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).not.toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' }).isHidden()).toBeTruthy(); await expect(await getSelectedKeyboardMenuText(page)).toBe('(System keyboard)'); }); @@ -119,9 +146,12 @@ test.describe.skip('Control-by-control example from the guide', function () { }); }); -test.describe.skip('Full manual control example from the guide', function () { +test.describe('Full manual control example from the guide', function () { const beforeEach = async (page: Page) => { - await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__full-manual-control.html'); + // output messages from the browser console to the test output, for debugging + page.on('console', msg => console.log(msg.text())); + + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__full-manual-control.html', 6); } test('Shows English and no OSK after loading page', async ({ page } : { page: Page }) => { @@ -203,9 +233,12 @@ test.describe.skip('Full manual control example from the guide', function () { }); }); -test.describe.skip('Manual control example from the guide', function () { +test.describe('Manual control example from the guide', function () { const beforeEach = async (page: Page) => { - await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__manual-control.html'); + // output messages from the browser console to the test output, for debugging + page.on('console', msg => console.log(msg.text())); + + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__manual-control.html', 1); } test('Does not show OSK after loading', async ({ page } : { page: Page }) => {