diff --git a/web/README.md b/web/README.md index cf82d2132a..7558c2a2c2 100644 --- a/web/README.md +++ b/web/README.md @@ -9,18 +9,18 @@ configure your build environment. The following folders contain the distribution for Keyman Engine for Web: - src Source code - src/resources/osk OSK resources for inclusion in mobile app builds; - keymanweb-osk.ttf is maintained at https://github.com/silnrsi/font-keymanweb-osk + src Source code + build/app/resources OSK + UI resources for inclusion in all build types; + keymanweb-osk.ttf is maintained at https://github.com/silnrsi/font-keymanweb-osk - build/app/web/release Fully-compiled KeymanWeb modules for release - build/app/embed/release Fully-compiled KMEA/KMEI modules for inclusion in mobile app builds - build/app/web/debug Fully-compiled but non-minified KeymanWeb modules - build/app/embed/debug Fully-compiled but non-minified KMEA/KMEI modules + build/app/browser/release Fully-compiled KeymanWeb modules for release + build/app/webview/release Fully-compiled KMEA/KMEI modules for inclusion in mobile app builds + build/app/browser/debug Fully-compiled but non-minified KeymanWeb modules + build/app/webview/debug Fully-compiled but non-minified KMEA/KMEI modules - src/samples Sample pages demonstrating ways to link with KeymanWeb - src/test/manual Test-case web-pages for various aspects of KeymanWeb functionality - src/test/auto A Node-driven test suite for automated testing of KeymanWeb + src/samples Sample pages demonstrating ways to link with KeymanWeb + src/test/manual Test-case web-pages for various aspects of KeymanWeb functionality + src/test/auto A Node-driven test suite for automated testing of KeymanWeb ********************************************************************** @@ -64,3 +64,73 @@ the former command executes. tab. 4. In the Dev console, you can set a breakpoint in your test and refresh the page to debug + +### Approximate Overall Design + +```mermaid +graph TD; + OSK[web/src/engine/osk]; + KP["common/web/keyboard-processor"]; + IP["common/web/input-processor"]; + OSK-->KP; + IP-->KP; + Utils["common/web/utils"]; + KP---->Utils; + Wordbreakers["common/models/wordbreakers"]; + Models["common/models/templates"]; + Models-->Utils; + LMWorker["common/web/lm-worker"]; + LMWorker-->Models; + LMWorker-->Wordbreakers; + LMLayer["common/predictive-text"]; + LMLayer-->LMWorker; + IP-->LMLayer; + + subgraph PredText["WebWorker + its interface"] + LMLayer; + LMWorker; + Models; + Wordbreakers; + end + + subgraph Headless["Fully headless components"] + direction LR + KP; + IP; + Utils; + PredText; + end + + subgraph ClassicWeb["Previously unmodularized components"] + Device[web/src/engine/device-detect]; + Device----->Utils; + Elements[web/src/engine/element-wrappers]; + Elements-->KP; + KeyboardCache[web/src/engine/package-cache]; + KeyboardCache-->IP; + DomUtils[web/src/engine/dom-utils]; + DomUtils-->Utils; + OSK-->DomUtils; + OSK---->IP; + Configuration[web/src/engine/paths]; + Configuration-->OSK; + CommonEngine[web/src/engine/main]; + CommonEngine-->Configuration; + CommonEngine-->Device; + CommonEngine-->KeyboardCache; + CommonEngine-->OSK; + Attachment[web/src/engine/attachment]; + Attachment-->DomUtils; + Attachment-->Elements; + end + + subgraph WebEngine["Keyman Engine for Web (top-level libraries)"] + Browser[web/src/app/browser]; + WebView[web/src/app/webview]; + + WebView--->CommonEngine; + + Browser--->CommonEngine; + Browser-->Attachment; + end +``` \ No newline at end of file diff --git a/web/build.sh b/web/build.sh index 179102705b..b11223ca79 100755 --- a/web/build.sh +++ b/web/build.sh @@ -29,7 +29,9 @@ builder_describe "Builds engine modules for Keyman Engine for Web (KMW)." \ "configure" \ "build" \ "test" \ + ":app/browser The form of Keyman Engine for Web for use on websites" \ ":app/webview A puppetable version of KMW designed for use in a host app's WebView" \ + ":app/ui Builds KMW's desktop form-factor keyboard-selection UI modules" \ ":engine/attachment Subset used for detecting valid page contexts for use in text editing " \ ":engine/device-detect Subset used for device-detection " \ ":engine/dom-utils A common subset of function used for DOM calculations, layout, etc" \ @@ -95,23 +97,22 @@ builder_run_child_actions build:engine/package-cache # Uses engine/paths, engine/device-detect, engine/package-cache, & engine/osk builder_run_child_actions build:engine/main -# Uses all but engine/element-wrappers +# Uses all but engine/element-wrappers and engine/attachment builder_run_child_actions build:app/webview # Uses literally everything `engine/` above -# Is not yet compilable due to unmodularized components. -# builder_run_child_actions build:app/browser +builder_run_child_actions build:app/browser + +builder_run_child_actions test if builder_has_action build:app/browser; then - builder_die "Modularization work is not yet complete; builds dependent on this will fail." + builder_warn "Modularization work is not yet complete; consumers may find needed API or components to be missing" fi if builder_has_action build:app/ui; then builder_die "Modularization work is not yet complete; builds dependent on this will fail." fi -builder_run_child_actions test - if builder_start_action test; then ./test.sh :engine diff --git a/web/ci.sh b/web/ci.sh index 1b7429d737..d160cc4190 100755 --- a/web/ci.sh +++ b/web/ci.sh @@ -40,7 +40,6 @@ builder_parse "$@" TIER=`cat ../TIER.md` BUILD_NUMBER=`cat ../VERSION.md` -S_KEYMAN_COM=../../s.keyman.com if builder_start_action build; then # Build step: since CI builds start (and should start) from scratch, run the following @@ -113,9 +112,11 @@ if builder_start_action prepare:s.keyman.com; then # The main build products are expected to reside at the root of this folder. BASE_PUBLISH_FOLDER="$S_KEYMAN_COM/kmw/engine/$VERSION" + echo "FOLDER: $BASE_PUBLISH_FOLDER" mkdir -p "$BASE_PUBLISH_FOLDER" - cp -Rf build/app/web/release/* "$BASE_PUBLISH_FOLDER" + cp -Rf build/app/browser/release/* "$BASE_PUBLISH_FOLDER" + cp -Rf build/app/resources/* "$BASE_PUBLISH_FOLDER/resources" cp -Rf build/app/ui/release/* "$BASE_PUBLISH_FOLDER" # Third phase: tweak the sourcemaps @@ -160,12 +161,16 @@ if builder_start_action prepare:downloads.keyman.com; then fi fi - pushd build/app/web/release + pushd build/app/browser/release "${COMPRESS_CMD}" $COMPRESS_ADD ../../../../$ZIP * cd .. "${COMPRESS_CMD}" $COMPRESS_ADD ../../../$ZIP debug popd + pushd build/app/resources + "${COMPRESS_CMD}" $COMPRESS_ADD ../../../$ZIP * + popd + pushd build/app/ui/release "${COMPRESS_CMD}" $COMPRESS_ADD ../../../../$ZIP * cd .. diff --git a/web/src/app/browser/build-bundler.js b/web/src/app/browser/build-bundler.js index 8da047207a..786f30a0db 100644 --- a/web/src/app/browser/build-bundler.js +++ b/web/src/app/browser/build-bundler.js @@ -7,6 +7,28 @@ import esbuild from 'esbuild'; import { spawn } from 'child_process'; +import fs from 'fs'; + +/* + * Refer to https://github.com/microsoft/TypeScript/issues/13721#issuecomment-307259227 - + * the `@class` emit comment-annotation is designed to facilitate tree-shaking for ES5-targeted + * down-level emits. `esbuild` doesn't look for it by default... but we can override that with + * this plugin. + */ +let es5ClassAnnotationAsPurePlugin = { + name: '@class -> __PURE__', + setup(build) { + build.onLoad({filter: /\.js$/ }, async (args) => { + let source = await fs.promises.readFile(args.path, 'utf8'); + return { + // Marks any classes compiled by TS (as per the /** @class */ annotation) + // as __PURE__ in order to facilitate tree-shaking. + contents: source.replace('/** @class */', '/* @__PURE__ */ /** @class */'), + loader: 'js' + } + }); + } +} await esbuild.build({ bundle: true, @@ -14,9 +36,29 @@ await esbuild.build({ format: "iife", nodePaths: ['../../../../node_modules'], entryPoints: { - 'index': '../../../build/app/webview/obj/main.js', + 'index': '../../../build/app/browser/obj/debug-main.js', }, - outdir: '../../../build/app/webview/lib/', - tsconfig: './tsconfig.json', - target: "es5" + outfile: '../../../build/app/browser/debug/keymanweb.js', + plugins: [ es5ClassAnnotationAsPurePlugin ], + target: "es5", + treeShaking: true, + tsconfig: './tsconfig.json' +}); + +await esbuild.build({ + bundle: true, + sourcemap: true, + minifyWhitespace: true, + minifySyntax: true, + minifyIdentifiers: false, + format: "iife", + nodePaths: ['../../../../node_modules'], + entryPoints: { + 'index': '../../../build/app/browser/obj/release-main.js', + }, + outfile: '../../../build/app/browser/release/keymanweb.js', + plugins: [ es5ClassAnnotationAsPurePlugin ], + target: "es5", + treeShaking: true, + tsconfig: './tsconfig.json' }); diff --git a/web/src/app/browser/build.sh b/web/src/app/browser/build.sh index 672e64dcfe..e5340bdff8 100755 --- a/web/src/app/browser/build.sh +++ b/web/src/app/browser/build.sh @@ -22,13 +22,7 @@ SUBPROJECT_NAME=app/browser # ################################ Main script ################################ builder_describe "Builds the Keyman Engine for Web's website-integrating version for use in non-puppeted browsers." \ - "@/common/web/input-processor build" \ - "@/web/src/engine/device-detect build" \ - "@/web/src/engine/paths build" \ - "@/web/src/engine/package-cache build" \ - "@/web/src/engine/events build" \ - "@/web/src/engine/osk build" \ - "@/web/src/engine/element-wrappers build" \ + "@/web/src/engine/attachment build" \ "@/web/src/engine/main build" \ "clean" \ "configure" \ @@ -38,11 +32,16 @@ builder_describe "Builds the Keyman Engine for Web's website-integrating version # Possible TODO?s # "upload-symbols Uploads build product to Sentry for error report symbolification. Only defined for $DOC_BUILD_EMBED_WEB" \ +builder_parse "$@" + +config=release +if builder_is_debug_build; then + config=debug +fi + builder_describe_outputs \ configure /node_modules \ - build /web/build/$SUBPROJECT_NAME/lib/index.js - -builder_parse "$@" + build /web/build/$SUBPROJECT_NAME/$config/keymanweb.js #### Build action definitions #### @@ -60,6 +59,9 @@ fi if builder_start_action build; then compile $SUBPROJECT_NAME + mkdir -p "$KEYMAN_ROOT/web/build/app/resources/osk" + cp -R "$KEYMAN_ROOT/web/src/resources/osk/." "$KEYMAN_ROOT/web/build/app/resources/osk/" + builder_finish_action success build fi diff --git a/web/src/app/browser/src/configuration.ts b/web/src/app/browser/src/configuration.ts index 14cd9aac9d..4ae768bc92 100644 --- a/web/src/app/browser/src/configuration.ts +++ b/web/src/app/browser/src/configuration.ts @@ -3,6 +3,7 @@ import { EngineConfiguration, InitOptionSpec, InitOptionDefaults } from "keyman/ import { OutputTarget as DOMOutputTarget } from 'keyman/engine/element-wrappers'; import { isEmptyTransform, OutputTarget, RuleBehavior } from '@keymanapp/keyboard-processor'; import { AlertHost } from "./utils/alertHost.js"; +import { whenDocumentReady } from "./utils/documentReady.js"; export class BrowserConfiguration extends EngineConfiguration { private _ui: string; @@ -11,13 +12,18 @@ export class BrowserConfiguration extends EngineConfiguration { private _alertHost?: AlertHost; initialize(options: Required) { - this.initialize(options); + super.initialize(options); this._ui = options.ui; this._attachType = options.attachType; - if(options.useAlerts) { - this._alertHost = new AlertHost(); - } + whenDocumentReady().then(() => { + if(options.useAlerts && !this.alertHost) { + this._alertHost = new AlertHost(); + } else if(!options.useAlerts && this.alertHost) { + this._alertHost.shutdown(); + this._alertHost = null; + } + }); } get attachType() { diff --git a/web/src/app/browser/src/context/pageIntegrationHandlers.ts b/web/src/app/browser/src/context/pageIntegrationHandlers.ts index 579cc99a9f..98367eb422 100644 --- a/web/src/app/browser/src/context/pageIntegrationHandlers.ts +++ b/web/src/app/browser/src/context/pageIntegrationHandlers.ts @@ -1,6 +1,6 @@ import { DomEventTracker } from 'keyman/engine/events'; -import { KeymanEngine } from "../keymanEngine.js"; +import KeymanEngine from "../keymanEngine.js"; import { FocusAssistant } from './focusAssistant.js'; // Note: in the future, it'd probably be best to have an instance per iframe window as diff --git a/web/src/app/browser/src/contextManager.ts b/web/src/app/browser/src/contextManager.ts index 49561f57c3..da041f2490 100644 --- a/web/src/app/browser/src/contextManager.ts +++ b/web/src/app/browser/src/contextManager.ts @@ -26,7 +26,7 @@ interface KeyboardCookie { * @param {Object} Ptarg Target element */ function _SetTargDir(Ptarg: HTMLElement, activeKeyboard: Keyboard) { - var elDir=(activeKeyboard && activeKeyboard.isRTL) ? 'rtl' : 'ltr'; + const elDir = activeKeyboard?.isRTL ? 'rtl' : 'ltr'; if(Ptarg) { if(Ptarg instanceof Ptarg.ownerDocument.defaultView.HTMLInputElement @@ -269,7 +269,7 @@ export default class ContextManager extends ContextManagerBase) { - let attachment = target.getElement()._kmwAttachment; + let attachment = target?.getElement()._kmwAttachment; if(!attachment) { // if not set with an "independent keyboard", changes the global. diff --git a/web/src/app/browser/src/debug-main.ts b/web/src/app/browser/src/debug-main.ts new file mode 100644 index 0000000000..f8331e36de --- /dev/null +++ b/web/src/app/browser/src/debug-main.ts @@ -0,0 +1,15 @@ +import KeymanEngine from './keymanEngine.js' +import { SourcemappedWorker } from '@keymanapp/lexical-model-layer/web' + + /** + * Determine path and protocol of executing script, setting them as + * construction defaults. + * + * This can only be done during load when the active script will be the + * last script loaded. Otherwise the script must be identified by name. + */ + var scripts = document.getElementsByTagName('script'); + var ss = scripts[scripts.length-1].src; + var sPath = ss.substr(0,ss.lastIndexOf('/')+1); + +window['keyman'] = new KeymanEngine(SourcemappedWorker.constructInstance(), sPath); \ No newline at end of file diff --git a/web/src/app/browser/src/keyboardInterface.ts b/web/src/app/browser/src/keyboardInterface.ts index 8e16fc2e29..bffc75e3de 100644 --- a/web/src/app/browser/src/keyboardInterface.ts +++ b/web/src/app/browser/src/keyboardInterface.ts @@ -1,10 +1,9 @@ -import { KeyboardKeymanGlobal } from '@keymanapp/keyboard-processor'; -import { StubAndKeyboardCache } from 'keyman/engine/package-cache'; import { type OutputTarget } from 'keyman/engine/element-wrappers'; import { FloatingOSKView, OSKView } from 'keyman/engine/osk'; import { KeyboardInterface as KeyboardInterfaceBase } from 'keyman/engine/main'; import ContextManager from './contextManager.js'; +import KeymanEngine from './keymanEngine.js'; export default class KeyboardInterface extends KeyboardInterfaceBase { // TBD: allowing it to be set and/or the retrieval mechanism. @@ -15,10 +14,9 @@ export default class KeyboardInterface extends KeyboardInterfaceBase { - return this.contextManager.lastActiveTarget; + return this.engine.contextManager.lastActiveTarget; } focusLastActiveElement(): void { - this.contextManager.restoreLastActiveTarget(); + this.engine.contextManager.restoreLastActiveTarget(); } //The following entry points are defined but should not normally be used in a keyboard, as OSK display is no longer determined by the keyboard diff --git a/web/src/app/browser/src/keymanEngine.ts b/web/src/app/browser/src/keymanEngine.ts index 22bfc3dc14..274ac4718f 100644 --- a/web/src/app/browser/src/keymanEngine.ts +++ b/web/src/app/browser/src/keymanEngine.ts @@ -2,7 +2,8 @@ import { KeymanEngine as KeymanEngineBase } from 'keyman/engine/main'; import { Device as DeviceDetector } from 'keyman/engine/device-detect'; import { getAbsoluteY } from 'keyman/engine/dom-utils'; import { OutputTarget } from 'keyman/engine/element-wrappers'; -import { AnchoredOSKView, FloatingOSKView, FloatingOSKViewConfiguration, OSKView } from 'keyman/engine/osk'; +import { AnchoredOSKView, FloatingOSKView, FloatingOSKViewConfiguration, OSKView, TwoStateActivator } from 'keyman/engine/osk'; +import { ErrorStub, KeyboardStub } from 'keyman/engine/package-cache'; import { DeviceSpec, ProcessorInitOptions, extendString } from "@keymanapp/keyboard-processor"; import { BrowserConfiguration, BrowserInitOptionDefaults, BrowserInitOptionSpec } from './configuration.js'; @@ -13,11 +14,14 @@ import { FocusStateAPIObject } from './context/focusAssistant.js'; import { PageIntegrationHandlers } from './context/pageIntegrationHandlers.js'; import { LanguageMenu } from './languageMenu.js'; import { setupOskListeners } from './oskConfiguration.js'; +import { whenDocumentReady } from './utils/documentReady.js'; -export class KeymanEngine extends KeymanEngineBase { +export default class KeymanEngine extends KeymanEngineBase { touchLanguageMenu?: LanguageMenu; private pageIntegration: PageIntegrationHandlers; + private _initialized: number = 0; + keyEventRefocus = () => { this.contextManager.restoreLastActiveTarget(); } @@ -30,13 +34,14 @@ export class KeymanEngine extends KeymanEngineBase) => { + const e = target?.getElement(); + (this.osk.activationModel as TwoStateActivator).activationTrigger = e; + if(this.config.hostDevice.touchable) { - if(!target || !this.osk) { + if(!e || !target || !this.osk) { return; } - const e = target.getElement(); - // Get the absolute position of the caret const y = getAbsoluteY(e); const t = window.pageYOffset; @@ -55,6 +60,10 @@ export class KeymanEngine extends KeymanEngineBase} Promise of added keyboard/error stubs + * + */ + addKeyboards(...args: (any)[]) : Promise<(KeyboardStub|ErrorStub)[]> { + if (!args || !args[0] || args[0].length == 0) { + // Get the cloud keyboard catalog + return this.keyboardRequisitioner.fetchCloudCatalog().catch((errVal) => { + console.error(errVal[0].error); + return errVal; + }); + } else { + let x: (string|KeyboardStub)[] = []; + if (Array.isArray(args[0])) { + x.push(...args[0]); + } else if (Array.isArray(args)) { + x.push(...args); + } else { + x.push(args); + } + return this.keyboardRequisitioner.addKeyboardArray(x); + } + } + + /** + * Add default keyboards for given language(s) + * + * @param {string|string[]} arg Language name (multiple arguments allowed) + * @returns {Promise<(KeyboardStub|ErrorStub)[]>} Promise of added keyboard/error stubs + **/ + ['addKeyboardsForLanguage'](arg: string[]|string) : Promise<(KeyboardStub|ErrorStub)[]> { + if (typeof arg === 'string') { + return this.keyboardRequisitioner.addLanguageKeyboards(arg.split(',').map(item => item.trim())); + } else { + return this.keyboardRequisitioner.addLanguageKeyboards(arg); + } + } + + /** * Detaches all KMW event handlers attached by this instance of the engine and releases * other related resources as appropriate. diff --git a/web/src/app/browser/src/languageMenu.ts b/web/src/app/browser/src/languageMenu.ts index 4c543989fd..5493752280 100644 --- a/web/src/app/browser/src/languageMenu.ts +++ b/web/src/app/browser/src/languageMenu.ts @@ -2,7 +2,7 @@ import { getAbsoluteX, landscapeView } from "keyman/engine/dom-utils"; import { KeyboardStub } from "keyman/engine/package-cache"; -import { KeymanEngine } from "./keymanEngine.js"; +import KeymanEngine from "./keymanEngine.js"; import * as util from "./utils/index.js"; // Used by 'native'-mode KMW only - the Android and iOS embedding apps implement their own menus. diff --git a/web/src/app/browser/src/main.ts b/web/src/app/browser/src/main.ts deleted file mode 100644 index e2a56806ca..0000000000 --- a/web/src/app/browser/src/main.ts +++ /dev/null @@ -1 +0,0 @@ -import ContextManager from './contextManager.js'; \ No newline at end of file diff --git a/web/src/app/browser/src/oskConfiguration.ts b/web/src/app/browser/src/oskConfiguration.ts index c6804d0451..8f78d917a2 100644 --- a/web/src/app/browser/src/oskConfiguration.ts +++ b/web/src/app/browser/src/oskConfiguration.ts @@ -1,7 +1,7 @@ import { type KeyElement, OSKView, VisualKeyboard } from "keyman/engine/osk"; import { KEYMAN_VERSION } from "@keymanapp/keyman-version"; import ContextManager from "./contextManager.js"; -import { KeymanEngine } from "./keymanEngine.js"; +import KeymanEngine from "./keymanEngine.js"; import { LanguageMenu } from "./languageMenu.js"; export function setupOskListeners(engine: KeymanEngine, osk: OSKView, contextManager: ContextManager) { @@ -64,7 +64,8 @@ export function setupOskListeners(engine: KeymanEngine, osk: OSKView, contextMan // On event start focusAssistant.setMaintainingFocus(true); - // The original did nothing when the pointer left the OSK's bounds. Possible bug? - // await promise; // should we wish to change that. + await promise; + + focusAssistant.setMaintainingFocus(false); }); } \ No newline at end of file diff --git a/web/src/app/browser/src/release-main.ts b/web/src/app/browser/src/release-main.ts new file mode 100644 index 0000000000..8fba3bb293 --- /dev/null +++ b/web/src/app/browser/src/release-main.ts @@ -0,0 +1,15 @@ +import KeymanEngine from './keymanEngine.js' +import { Worker } from '@keymanapp/lexical-model-layer/web' + + /** + * Determine path and protocol of executing script, setting them as + * construction defaults. + * + * This can only be done during load when the active script will be the + * last script loaded. Otherwise the script must be identified by name. + */ + var scripts = document.getElementsByTagName('script'); + var ss = scripts[scripts.length-1].src; + var sPath = ss.substr(0,ss.lastIndexOf('/')+1); + +window['keyman'] = new KeymanEngine(Worker.constructInstance(), sPath); \ No newline at end of file diff --git a/web/src/app/browser/src/utilApiEndpoint.ts b/web/src/app/browser/src/utilApiEndpoint.ts deleted file mode 100644 index b1feb07c9f..0000000000 --- a/web/src/app/browser/src/utilApiEndpoint.ts +++ /dev/null @@ -1,346 +0,0 @@ - -import { - CookieSerializer, - createStyleSheet, - getAbsoluteX, - getAbsoluteY, - StylesheetManager - } from "keyman/engine/dom-utils"; -import { DomEventTracker } from "keyman/engine/events"; -import { BrowserConfiguration, BrowserInitOptionSpec } from "./configuration.js"; -import { getStyleValue } from "./utils/getStyleValue.js"; -import { AlertHost } from "./utils/alertHost.js"; - -/** - * Calls document.createElement for the specified node type and also applies - * 'user-select: none' styling to the new element. - * @param nodeName - * @returns - */ -export function createUnselectableElement(nodeName:E) { - const e = document.createElement(nodeName); - e.style.userSelect="none"; - return e; -} - -export class UtilApiEndpoint { - readonly config: BrowserConfiguration; - private readonly stylesheetManager: StylesheetManager; - private readonly domEventTracker: DomEventTracker; - private _alertHost: AlertHost; - - constructor(config: BrowserConfiguration) { - this.config = config; - this.stylesheetManager = new StylesheetManager(document.body, config.applyCacheBusting); - this.domEventTracker = new DomEventTracker(); - } - - readonly getAbsoluteX = getAbsoluteX; - readonly getAbsoluteY = getAbsoluteY; - - // These four were renamed, but we need to maintain their legacy names. - readonly _GetAbsoluteX = getAbsoluteX; - readonly _GetAbsoluteY = getAbsoluteY; - readonly _GetAbsolute = this.getAbsolute; - readonly toNzString = this.nzString; - - /** - * Expose the touchable state for UIs - will disable external UIs entirely - **/ - isTouchDevice(): boolean { - return this.config.hostDevice.touchable; - } - - getAbsolute(elem: HTMLElement): { x: number, y: number } { - return { - x: getAbsoluteX(elem), - y: getAbsoluteY(elem) - }; - } - - /** - * Calls document.createElement for the specified node type and also applies - * 'user-select: none' styling to the new element. - * @param nodeName - * @returns - */ - readonly createElement = createUnselectableElement; - - /** - * Function getOption - * Scope Public - * @param {string} optionName Name of option - * @param {*=} dflt Default value of option - * @return {*} - * Description Returns value of named option - */ - getOption(optionName: keyof BrowserInitOptionSpec, dflt?:any): any { - if(optionName in this.config.paths) { - return this.config.paths[optionName]; - } else if(optionName in this.config.options) { - return this.config.options[optionName]; - } else if(arguments.length > 1) { - return dflt; - } else { - return ''; - } - } - - setOption(optionName: keyof BrowserInitOptionSpec, value: any): void { - switch(optionName) { - case 'attachType': - // 16.0 & before: did nothing. - // Fixable for 17.0 with some extra work, but the changes would likely be enough to - // merit a focused PR. It's not 100% straightforward. - break; - case 'ui': - // 16.0 & before: relies on the Float UI to passively pick up on any changes. - // Only appears to be effective before the Float UI initializes. - break; - case 'useAlerts': - this.config.alertHost = (value ? new AlertHost() : null); - break; - case 'setActiveOnRegister': - this.config.activateFirstKeyboard = !!value; - break; - case 'spacebarText': - this.config.spacebarText = value; - break; - default: - throw new Error("Path-related options may not be changed after the engine has initialized."); - } - } - - /** - * Document cookie parsing for use by kernel, OSK, UI etc. - * - * @param {string=} cn cookie name (optional) - * @return {Object} array of names and strings, or array of variables and values - */ - loadCookie>(cn?: string) { - const cookie = new CookieSerializer(cn); - return cookie.load(decodeURIComponent); - } - - /** - * Standard cookie saving for use by kernel, OSK, UI etc. - * - * @param {string} cn name of cookie - * @param {Object} cv object with array of named arguments and values - */ - saveCookie>(cn: string, cv: CookieType) { - const cookie = new CookieSerializer(cn); - cookie.save(cv, encodeURIComponent); - } - - /** - * Add a stylesheet to a page programmatically, for use by the OSK, the UI or the page creator - * - * @param {string} s style string - * @return {Object} returns the object reference - **/ - addStyleSheet(s: string): HTMLStyleElement { - const styleSheet = createStyleSheet(s); - this.stylesheetManager.linkStylesheet(styleSheet); - - return styleSheet; - } - - /** - * Remove a stylesheet element - * - * @param {Object} s style sheet reference - * @return {boolean} false if element is not a style sheet - **/ - removeStyleSheet(s: HTMLStyleElement) { - return this.stylesheetManager.unlink(s); - } - - /** - * Add a reference to an external stylesheet file - * - * @param {string} s path to stylesheet file - */ - linkStyleSheet(s: string): void { - this.stylesheetManager.linkExternalSheet(s); - } - - // Possible alternative: https://www.npmjs.com/package/language-tags - // This would necessitate linking in a npm module into compiled KeymanWeb, though. - getLanguageCodes(lgCode: string): string[] { - if(lgCode.indexOf('-')==-1) { - return [lgCode]; - } else { - return lgCode.split('-'); - } - } - - /** - * Function attachDOMEvent: Note for most browsers, adds an event to a chain, doesn't stop existing events - * Scope Public - * @param {Object} Pelem Element (or IFrame-internal Document) to which event is being attached - * @param {string} Peventname Name of event without 'on' prefix - * @param {function(Object)} Phandler Event handler for event - * @param {boolean=} PuseCapture True only if event to be handled on way to target element - * Description Attaches event handler to element DOM event - */ - attachDOMEvent( - Pelem: Window, - Peventname: K, - Phandler: (ev: WindowEventMap[K]) => any, - PuseCapture?: boolean - ): void; - attachDOMEvent( - Pelem: Document, - Peventname: K, - Phandler: (ev: DocumentEventMap[K]) => any, - PuseCapture?: boolean - ): void; - attachDOMEvent( - Pelem: HTMLElement, - Peventname: K, - Phandler: (ev: HTMLElementEventMap[K]) => any, - PuseCapture?: boolean - ): void; - attachDOMEvent(Pelem: EventTarget, Peventname: string, Phandler: (Object) => boolean, PuseCapture?: boolean): void { - // TS can't quite track the type inference forwarding here. - this.domEventTracker.attachDOMEvent(Pelem as any, Peventname as any, Phandler, PuseCapture); - } - - /** - * Function detachDOMEvent - * Scope Public - * @param {Object} Pelem Element from which event is being detached - * @param {string} Peventname Name of event without 'on' prefix - * @param {function(Object)} Phandler Event handler for event - * @param {boolean=} PuseCapture True if event was being handled on way to target element - * Description Detaches event handler from element [to prevent memory leaks] - */ - detachDOMEvent( - Pelem: Window, - Peventname: K, - Phandler: (ev: WindowEventMap[K]) => any, - PuseCapture?: boolean - ): void; - detachDOMEvent( - Pelem: Document, - Peventname: K, - Phandler: (ev: DocumentEventMap[K]) => any, - PuseCapture?: boolean - ): void; - detachDOMEvent( - Pelem: HTMLElement, - Peventname: K, - Phandler: (ev: HTMLElementEventMap[K]) => any, - PuseCapture?: boolean - ): void; - detachDOMEvent(Pelem: EventTarget, Peventname: string, Phandler: (Object) => boolean, PuseCapture?: boolean): void { - // TS can't quite track the type inference forwarding here. - this.domEventTracker.detachDOMEvent(Pelem as any, Peventname as any, Phandler, PuseCapture); - } - - getStyleValue = getStyleValue; - - private get alertHost(): AlertHost { - if(this.config.alertHost) { - return this.config.alertHost; - } else if(!this._alertHost) { - // Lazy init: if KMW is set to not show alerts, we try not to initialize the alert host. - // If the .alert API is called, though, we have no choice. - this._alertHost = new AlertHost(); - } - - return this._alertHost; - } - - alert(s: string, fn: () => void) { - this.alertHost.alert(s, fn); - } - - /** - * Function toNzString - * Scope Public - * @param {*} item variable to test - * @param {?*=} dflt default value - * @return {*} - * Description Test if a variable is null, false, empty string, or undefined, and return as string - */ - nzString(item: any, dflt: string): string { - // // ... is this whole thing essentially just: - // return '' + (item || dflt || ''); - // // ? - - let dfltValue = ''; - if(arguments.length > 1) { - dfltValue = dflt; - } - - if(typeof(item) == 'undefined') { - return dfltValue; - } - - if(item == null) { - return dfltValue; - } - - if(item == 0 || item == '') { - return dfltValue; - } - - return ''+item; - } - - /** - * Function toNumber - * Scope Public - * @param {string} s numeric string - * @param {number} dflt default value - * @return {number} - * Description Return string converted to integer or default value - */ - toNumber(s: string, dflt: number): number { - const x = parseInt(s,10); - return isNaN(x) ? dflt : x; - } - - /** - * Function toNumber - * Scope Public - * @param {string} s numeric string - * @param {number} dflt default value - * @return {number} - * Description Return string converted to real value or default value - */ - toFloat(s: string, dflt: number): number { - const x = parseFloat(s); - return isNaN(x) ? dflt : x; - } - - /** - * Function rgba - * Scope Public - * @param {Object} s element style object - * @param {number} r red value, 0-255 - * @param {number} g green value, 0-255 - * @param {number} b blue value, 0-255 - * @param {number} a opacity value, 0-1.0 - * @return {string} background colour style string - * Description Browser-independent alpha-channel management - */ - rgba(s: HTMLStyleElement, r:number, g:number, b:number, a:number): string { - let bgColor='transparent'; - try { - bgColor='rgba('+r+','+g+','+b+','+a+')'; - } catch(ex) { - bgColor='rgb('+r+','+g+','+b+')'; - } - - return bgColor; - } - - shutdown() { - this.stylesheetManager?.unlinkAll(); - this.domEventTracker?.shutdown(); - this._alertHost?.shutdown(); - } -} \ No newline at end of file diff --git a/web/src/app/browser/src/utils/documentReady.ts b/web/src/app/browser/src/utils/documentReady.ts new file mode 100644 index 0000000000..f719388fba --- /dev/null +++ b/web/src/app/browser/src/utils/documentReady.ts @@ -0,0 +1,15 @@ +export function whenDocumentReady(): Promise { + if(document.readyState === 'complete') { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const loadHandler: (e: Event) => void = () => { + window.removeEventListener('load', loadHandler); + + resolve(); + }; + + window.addEventListener('load', loadHandler); + }); +} \ No newline at end of file diff --git a/web/src/app/browser/src/utils/index.ts b/web/src/app/browser/src/utils/index.ts index 42b203addc..d4decbd002 100644 --- a/web/src/app/browser/src/utils/index.ts +++ b/web/src/app/browser/src/utils/index.ts @@ -1,4 +1,5 @@ export { AlertHost } from './alertHost.js'; export { _CreateElement } from './createElement.js'; export { getStyleValue } from './getStyleValue.js'; -export { getViewportScale } from './getViewportScale.js'; \ No newline at end of file +export { getViewportScale } from './getViewportScale.js'; +export { whenDocumentReady } from './documentReady.js'; \ No newline at end of file diff --git a/web/src/app/webview/src/keymanEngine.ts b/web/src/app/webview/src/keymanEngine.ts index abf4344c72..a68f3f8999 100644 --- a/web/src/app/webview/src/keymanEngine.ts +++ b/web/src/app/webview/src/keymanEngine.ts @@ -41,8 +41,7 @@ export default class KeymanEngine extends KeymanEngineBase this.emit('beforekeyboardchange', activatingKeyboard.metadata); } - this.activateKeyboardForTarget({ - keyboard: keyboard, - metadata: activatingKeyboard.metadata - }, this.keyboardTarget); + let kbdStubPair: { keyboard: Keyboard, metadata: KeyboardStub } = null; + if(keyboard) { + kbdStubPair = { + keyboard: keyboard, + metadata: activatingKeyboard.metadata + }; + } + + this.activateKeyboardForTarget(kbdStubPair, this.keyboardTarget); // Only trigger `keyboardchange` events when they will affect the active context. if(this.keyboardTarget == originalKeyboardTarget) { @@ -266,7 +271,12 @@ export abstract class ContextManagerBase languageCode ||= ''; // Check that the saved keyboard is currently registered - let requestedStub = this.keyboardCache.getStub(keyboardId, languageCode); + let requestedStub = null; + if(keyboardId) { + requestedStub = this.keyboardCache.getStub(keyboardId, languageCode); + } else { + languageCode == ''; + } // Mobile device addition: force selection of the first keyboard if none set if(this.engineConfig.hostDevice.touchable && !requestedStub) { diff --git a/web/src/engine/main/src/keyboardInterface.ts b/web/src/engine/main/src/keyboardInterface.ts index 59a39ff4e8..b2035c5190 100644 --- a/web/src/engine/main/src/keyboardInterface.ts +++ b/web/src/engine/main/src/keyboardInterface.ts @@ -1,33 +1,27 @@ import { - Keyboard, KeyboardInterface as KeyboardInterfaceBase, - KeyboardKeymanGlobal, } from "@keymanapp/keyboard-processor"; -import { KeyboardStub, RawKeyboardStub, StubAndKeyboardCache, toUnprefixedKeyboardId as unprefixed } from 'keyman/engine/package-cache'; +import { KeyboardStub, RawKeyboardStub, toUnprefixedKeyboardId as unprefixed } from 'keyman/engine/package-cache'; import { ContextManagerBase } from './contextManagerBase.js'; import { VariableStoreCookieSerializer } from "./variableStoreCookieSerializer.js"; +import KeymanEngine from "./keymanEngine.js"; +import { EngineConfiguration } from "./engineConfiguration.js"; export default class KeyboardInterface> extends KeyboardInterfaceBase { - protected readonly contextManager: ContextManagerType; - private stubAndKeyboardCache: StubAndKeyboardCache; + protected readonly engine: KeymanEngine; private stubNamespacer?: (stub: RawKeyboardStub) => void; constructor( _jsGlobal: any, - keymanGlobal: KeyboardKeymanGlobal, - contextManager: ContextManagerType, + engine: KeymanEngine, stubNamespacer?: (stub: RawKeyboardStub) => void ) { - super(_jsGlobal, keymanGlobal, new VariableStoreCookieSerializer()); - this.contextManager = contextManager; + super(_jsGlobal, engine, new VariableStoreCookieSerializer()); + this.engine = engine; this.stubNamespacer = stubNamespacer; } - setKeyboardCache(cache: StubAndKeyboardCache) { - this.stubAndKeyboardCache = cache; - } - // Preserves a keyboard's ID, even if namespaced, via script tag tagging. preserveID(Pk: any /** a `Keyboard`'s `scriptObject` entry */) { var trueID; @@ -59,12 +53,14 @@ export default class KeyboardInterface { + if(!this.engine.keyboardRequisitioner.cache.isFetchingKeyboard(registeredKeyboard.id)) { + // Deliberate keyboard pre-loading via direct script-tag link on the page. + // Just load the keyboard and reset the harness's keyboard-receiver field. + this.engine.keyboardRequisitioner.cache.addKeyboard(registeredKeyboard); + this.loadedKeyboard = null; + } + }); } /** @@ -89,11 +85,16 @@ export default class KeyboardInterface this.engine.keyboardRequisitioner.cache.addStub(stub)); + } else { + this.engine.keyboardRequisitioner.cache.addStub(stub); + } + return null; } @@ -101,8 +102,11 @@ export default class KeyboardInterface implements KeyboardKeymanGlobal { readonly config: Configuration; - readonly contextManager: ContextManager; - readonly interface: KeyboardInterface; + contextManager: ContextManager; + interface: KeyboardInterface; readonly core: InputProcessor; - readonly keyboardRequisitioner: KeyboardRequisitioner; - readonly modelCache: ModelCache; + keyboardRequisitioner: KeyboardRequisitioner; + modelCache: ModelCache; protected legacyAPIEvents = new LegacyEventEmitter(); private _hardKeyboard: HardKeyboard; @@ -97,23 +97,97 @@ export default class KeymanEngine< this.config = config; this.contextManager = contextManager; - // Since we're not sandboxing keyboard loads yet, we just use `window` as the jsGlobal object. - this.interface = new KeyboardInterface(window, this, this.contextManager, config.stubNamespacer); - const keyboardLoader = new KeyboardLoader(this.interface, config.applyCacheBusting); - this.keyboardRequisitioner = new KeyboardRequisitioner(keyboardLoader, new DOMCloudRequester(), this.config.paths); - this.modelCache = new ModelCache(); - - const kbdCache = this.keyboardRequisitioner.cache; - this.interface.setKeyboardCache(this.keyboardRequisitioner.cache); - + this.interface = new KeyboardInterface(window, this, config.stubNamespacer); this.core = new InputProcessor(config.hostDevice, worker, this.processorConfiguration()); + this.core.languageProcessor.on('statechange', (state) => { // The banner controller cannot directly trigger a layout-refresh at this time, // so we handle that here. - this.osk.bannerController.selectBanner(state); - this.osk.refreshLayout(); + this.osk?.bannerController.selectBanner(state); + this.osk?.refreshLayout(); }); + this.core.keyboardProcessor.beepHandler = (target) => { + if(this.doBeep) { + this.doBeep(target); + } + } + + this.contextManager.on('beforekeyboardchange', (metadata) => { + this.legacyAPIEvents.callEvent('beforekeyboardchange', { + internalName: metadata?.id, + languageCode: metadata?.langId + }); + }); + + this.contextManager.on('keyboardchange', (kbd) => { + this.refreshModel(); + this.core.activeKeyboard = kbd?.keyboard; + + this.legacyAPIEvents.callEvent('keyboardchange', { + internalName: kbd?.metadata.id, + languageCode: kbd?.metadata.langId + }); + + // Hide OSK and do not update keyboard list if using internal keyboard (desktops). + // Condition will not be met for touch form-factors; they force selection of a + // default keyboard. + if(!kbd) { + this.osk.startHide(false); + } + + if(this.osk) { + this.osk.setNeedsLayout(); + this.osk.activeKeyboard = kbd; + this.osk.present(); + } + }); + + this.contextManager.on('keyboardasyncload', (metadata) => { + /* Original implementation pre-modularization: + * + * > Force OSK display for CJK keyboards (keyboards using a pick list) + * + * A matching subcondition in the block below will ensure that the OSK activates pre-load + * for CJK keyboards. Yes, even before a CJK picker could ever show. We should be fine + * without the CJK check so long as a picker keyboard's OSK is kept activated post-load, + * when the picker actually needs to be kept persistently-active. + * `metadata` would be relevant a the CJK-check, which was based on language codes. + * + * Of course, as mobile devices don't have guaranteed physical keyboards... we need to + * keep the OSK visible for them, hence the actual block below. + */ + if(this.config.hostDevice.touchable && this.osk?.activationModel) { + this.osk.activationModel.enabled = true; + // Also note: the OSKView.mayDisable method returns false when hostDevice.touchable = false. + // The .startHide() call below will check that method before actually starting an OSK hide. + } + + // Always (temporarily) hide the OSK when loading a new keyboard, to ensure + // that a failure to load doesn't leave the current OSK displayed + this.osk?.startHide(false); + }); + } + + async init(optionSpec: Required){ + // There may be some valid mutations possible even on repeated calls? + // The original seems to allow it. + + const config = this.config; + if(config.deferForInitialization.hasFinalized) { + // abort! Maybe throw an error, too. + return Promise.resolve(); + } + + config.initialize(optionSpec); + + // Since we're not sandboxing keyboard loads yet, we just use `window` as the jsGlobal object. + // All components initialized below require a properly-configured `config.paths` or similar. + const keyboardLoader = new KeyboardLoader(this.interface, config.applyCacheBusting); + this.keyboardRequisitioner = new KeyboardRequisitioner(keyboardLoader, new DOMCloudRequester(), this.config.paths); + this.modelCache = new ModelCache(); + const kbdCache = this.keyboardRequisitioner.cache; + this.contextManager.configure({ resetContext: (target) => { this.core.resetContext(target); @@ -122,12 +196,6 @@ export default class KeymanEngine< keyboardCache: this.keyboardRequisitioner.cache }); - this.core.keyboardProcessor.beepHandler = (target) => { - if(this.doBeep) { - this.doBeep(target); - } - } - // #region Event handler wiring this.config.on('spacebartext', () => { // On change of spacebar-text mode, we currently need a layout refresh to update the @@ -145,6 +213,12 @@ export default class KeymanEngine< languageCode: stub.KLC, package: stub.KP }); + + // If this is the first stub loaded, set it as active. + if(this.keyboardRequisitioner.cache.defaultStub == stub) { + // Note: leaving this out is super-useful for debugging issues that occur when no keyboard is active. + this.contextManager.activateKeyboard(stub.id, stub.langId, true); + } } if(this.config.deferForInitialization.hasFinalized) { @@ -169,61 +243,6 @@ export default class KeymanEngine< } }); - contextManager.on('beforekeyboardchange', (metadata) => { - this.legacyAPIEvents.callEvent('beforekeyboardchange', { - internalName: metadata.id, - languageCode: metadata.langId - }); - }); - - contextManager.on('keyboardchange', (kbd) => { - this.refreshModel(); - this.core.activeKeyboard = kbd.keyboard; - - this.legacyAPIEvents.callEvent('keyboardchange', { - internalName: kbd.metadata.id, - languageCode: kbd.metadata.langId - }); - - // Hide OSK and do not update keyboard list if using internal keyboard (desktops). - // Condition will not be met for touch form-factors; they force selection of a - // default keyboard. - if(kbd.keyboard == null && kbd.metadata == null) { - this.osk.startHide(false); - } - - if(this.osk) { - this.osk.setNeedsLayout(); - this.osk.activeKeyboard = kbd; - this.osk.present(); - } - }); - - contextManager.on('keyboardasyncload', (metadata) => { - /* Original implementation pre-modularization: - * - * > Force OSK display for CJK keyboards (keyboards using a pick list) - * - * A matching subcondition in the block below will ensure that the OSK activates pre-load - * for CJK keyboards. Yes, even before a CJK picker could ever show. We should be fine - * without the CJK check so long as a picker keyboard's OSK is kept activated post-load, - * when the picker actually needs to be kept persistently-active. - * `metadata` would be relevant a the CJK-check, which was based on language codes. - * - * Of course, as mobile devices don't have guaranteed physical keyboards... we need to - * keep the OSK visible for them, hence the actual block below. - */ - if(this.config.hostDevice.touchable && this.osk?.activationModel) { - this.osk.activationModel.enabled = true; - // Also note: the OSKView.mayDisable method returns false when hostDevice.touchable = false. - // The .startHide() call below will check that method before actually starting an OSK hide. - } - - // Always (temporarily) hide the OSK when loading a new keyboard, to ensure - // that a failure to load doesn't leave the current OSK displayed - this.osk?.startHide(false); - }); - this.keyboardRequisitioner.cache.on('keyboardAdded', (keyboard) => { this.legacyAPIEvents.callEvent('keyboardloaded', { keyboardName: keyboard.id }); }); @@ -231,18 +250,6 @@ export default class KeymanEngine< // #endregion } - async init(optionSpec: Required){ - // There may be some valid mutations possible even on repeated calls? - // The original seems to allow it. - - if(this.config.deferForInitialization.hasFinalized) { - // abort! Maybe throw an error, too. - return Promise.resolve(); - } - - this.config.initialize(optionSpec); - } - public get hardKeyboard(): HardKeyboard { return this._hardKeyboard; } @@ -285,7 +292,7 @@ export default class KeymanEngine< // is fully complete. private refreshModel(): Promise { const kbd = this.contextManager.activeKeyboard; - const model = this.modelCache.modelForLanguage(kbd.metadata.langId); + const model = this.modelCache.modelForLanguage(kbd?.metadata.langId); if(this.core.activeModel != model) { if(this.core.activeModel) { diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index 3c659938f0..53133f5003 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -728,10 +728,10 @@ export default abstract class OSKView extends EventEmitter implements this._Box.appendChild(this.banner.element); if(this.bannerView.banner) { - this.banner.banner.configureForKeyboard(this.keyboardData.keyboard, this.keyboardData.metadata); + this.banner.banner.configureForKeyboard(this.keyboardData?.keyboard, this.keyboardData?.metadata); } - let kbdView: KeyboardView = this.keyboardView = this._GenerateKeyboardView(this.keyboardData.keyboard, this.keyboardData.metadata); + let kbdView: KeyboardView = this.keyboardView = this._GenerateKeyboardView(this.keyboardData?.keyboard, this.keyboardData?.metadata); this._Box.appendChild(kbdView.element); kbdView.postInsert(); diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index c240ace9c2..0b0a6d298c 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -1528,6 +1528,10 @@ export default class VisualKeyboard extends EventEmitter implements Ke customStyle = customStyle + activeKeyboard.oskStyling; this.styleSheet = createStyleSheet(customStyle); //Build 360 + this.styleSheet.addEventListener('load', () => { + // Once any related fonts are loaded, we can re-adjust key-cap scaling. + this.refreshLayout(); + }) this.styleSheetManager.linkStylesheet(this.styleSheet); } diff --git a/web/src/engine/package-cache/src/keyboardRequisitioner.ts b/web/src/engine/package-cache/src/keyboardRequisitioner.ts index bafa996693..043b3a0362 100644 --- a/web/src/engine/package-cache/src/keyboardRequisitioner.ts +++ b/web/src/engine/package-cache/src/keyboardRequisitioner.ts @@ -7,7 +7,16 @@ import { import { PathConfiguration } from "keyman/engine/paths"; // TODO: is cleanup needed here, to use local paths instead? -import { CloudQueryEngine, type ErrorStub, KeyboardAPISpec, KeyboardStub, StubAndKeyboardCache, RawKeyboardStub, mergeAndResolveStubPromises } from "./index.js"; +import { + CloudQueryEngine, + type ErrorStub, + KeyboardAPISpec, + KeyboardStub, + StubAndKeyboardCache, + RawKeyboardStub, + mergeAndResolveStubPromises, + toUnprefixedKeyboardId as unprefixed + } from "./index.js"; import { default as CloudRequesterInterface } from "./cloud/requesterInterface.js"; class CloudRequestEntry { @@ -158,7 +167,8 @@ export default class KeyboardRequisitioner { } // Requests not of string form never specify a specific version. - const querySpec = toQuerySpecs(incomplete.id, incomplete.langId); + // If an 'incomplete stub', we may have prefixed the keyboard ID - undo that! + const querySpec = toQuerySpecs(unprefixed(incomplete.id), incomplete.langId); if(isUniqueRequest(this.cache, cloudList, querySpec)) { cloudList.push(querySpec); } @@ -262,8 +272,17 @@ export default class KeyboardRequisitioner { return Promise.reject(errorStubs); } - return this.cloudQueryEngine.keymanCloudRequest('&keyboardid='+cmd, false).then((result) => { - return mergeAndResolveStubPromises(result, errorStubs); + return this.cloudQueryEngine.keymanCloudRequest('&keyboardid='+cmd, false).then(async (result) => { + const results = await mergeAndResolveStubPromises(result, errorStubs); + + for(let result of results) { + // If not an error stub... + if(typeof result['error'] == 'undefined') { + this.cache.addStub(result as KeyboardStub); + } + } + + return results; }, (err) => { console.error(err); let stub: ErrorStub = {error: err}; @@ -272,6 +291,16 @@ export default class KeyboardRequisitioner { }); } + async fetchCloudCatalog() { + try { + const stubs = await this.cloudQueryEngine.keymanCloudRequest('', false); + stubs.forEach((stub) => this.cache.addStub(stub)); + return stubs; + } catch(error) { + return Promise.reject([{error: error}]); + } + } + /** * Display warning if language name unavailable to add keyboard * @param {string} languageName diff --git a/web/src/engine/package-cache/src/keyboardStub.ts b/web/src/engine/package-cache/src/keyboardStub.ts index 51b2b9b65e..f5b4724ff6 100644 --- a/web/src/engine/package-cache/src/keyboardStub.ts +++ b/web/src/engine/package-cache/src/keyboardStub.ts @@ -53,7 +53,7 @@ export default class KeyboardStub extends KeyboardProperties { let rx=RegExp('^(([\\.]/)|([\\.][\\.]/)|(/))|(:)'); arg1 = arg1 || ''; - if(!rx.test(this.KF)) { + if(this.KF && !rx.test(this.KF)) { this.KF = arg1 + this.KF; } } else { diff --git a/web/src/engine/package-cache/src/stubAndKeyboardCache.ts b/web/src/engine/package-cache/src/stubAndKeyboardCache.ts index 44baf5721e..a673557861 100644 --- a/web/src/engine/package-cache/src/stubAndKeyboardCache.ts +++ b/web/src/engine/package-cache/src/stubAndKeyboardCache.ts @@ -58,6 +58,9 @@ export default class StubAndKeyboardCache extends EventEmitter { } getKeyboard(keyboardID: string): Keyboard { + if(!keyboardID) { + return null; + } const entry = this.keyboardTable[prefixed(keyboardID)]; // Unit testing may 'trip up' in the DOM, as bundled versions of a class from one bundled @@ -198,7 +201,9 @@ export default class StubAndKeyboardCache extends EventEmitter { keyboardID = arg0; } - keyboardID = prefixed(keyboardID); + if(keyboardID) { + keyboardID = prefixed(keyboardID); + } const stubTable = this.stubSetTable[keyboardID] ?? {}; @@ -237,10 +242,10 @@ export default class StubAndKeyboardCache extends EventEmitter { let arr: KeyboardStub[] = []; const kbdIds = Object.keys(this.stubSetTable); - for(let kbdId in kbdIds) { + for(let kbdId of kbdIds) { let row = this.stubSetTable[kbdId]; const langIds = Object.keys(row); - for(let langId in langIds) { + for(let langId of langIds) { arr.push(row[langId]); } } diff --git a/web/src/test/auto/headless/packages/keyboardRequisitioner.js b/web/src/test/auto/headless/packages/keyboardRequisitioner.js index 771e7dc4d6..1b62932f5a 100644 --- a/web/src/test/auto/headless/packages/keyboardRequisitioner.js +++ b/web/src/test/auto/headless/packages/keyboardRequisitioner.js @@ -283,7 +283,12 @@ describe("KeyboardRequisitioner", () => { mockedRequester.request = swapFake; const promise = keyboardRequisitioner.addLanguageKeyboards(['Khmer', 'Dzongkha']); - await promise; + try { + await promise; + } catch (e) { + // We didn't mock the actual query based on the language codes, but just knowing + // that a query was made, with the right parameters, is enough for us here. + } assert.equal(swapFake.callCount, 2); @@ -333,4 +338,13 @@ describe("KeyboardRequisitioner", () => { assert.strictEqual(cache.getKeyboardForStub(stub), khmer_angkor); assert.isOk(khmer_angkor); }); + + // TODO: unit tests for these. + describe.skip('fetchCloudStubs', () => { + it('fetches stubs for all supported cloud keyboards', () => {}); + + it('caches all fetched cloud stubs upon completion', () => {}); + + it('returns an error stub if unable to access the Cloud API', () => {}); + }); }); \ No newline at end of file diff --git a/web/src/test/manual/web/unminified.html b/web/src/test/manual/web/unminified.html index 7f2ab0062d..fb7d2deab7 100644 --- a/web/src/test/manual/web/unminified.html +++ b/web/src/test/manual/web/unminified.html @@ -23,7 +23,7 @@ - +