From 7f4debdfbf9b39864571c4e030f268c53c28df8d Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 31 Mar 2023 12:53:00 +0700 Subject: [PATCH] chore(web): restores feature-esmodule-web-engine to prior state Revert "Merge pull request #8553 from keymanapp/revert-8549-chore/master-to-esmodule-merge-a17s9" This reverts commit 518750f94eb030fa5a0f86cbd542db40ce137331, reversing changes made to 6df63eddc50a825042a44ff59ca5a1318686d75e. --- .../KMEA/app/src/main/assets/android-host.js | 14 +- common/models/templates/build-bundler.js | 33 + common/models/templates/build.sh | 64 + common/models/templates/package.json | 25 +- common/models/templates/src/common.ts | 263 +- common/models/templates/src/index.ts | 27 +- common/models/templates/src/priority-queue.ts | 367 +- common/models/templates/src/quote-behavior.ts | 72 +- common/models/templates/src/tokenization.ts | 222 +- common/models/templates/src/trie-model.ts | 868 +- common/models/templates/test/helpers.js | 18 +- common/models/templates/test/test-common.js | 4 +- .../templates/test/test-priority-queue.js | 4 +- .../templates/test/test-quote-behavior.js | 4 +- .../templates/test/test-tokenization.js | 8 +- .../models/templates/test/test-trie-model.js | 4 +- .../templates/test/test-trie-traversal.js | 26 +- common/models/templates/tsconfig.bundled.json | 21 - common/models/templates/tsconfig.json | 16 +- common/models/wordbreakers/build-bundler.js | 33 + common/models/wordbreakers/build.sh | 62 + common/models/wordbreakers/package.json | 19 +- common/models/wordbreakers/src/ascii.ts | 61 +- .../models/wordbreakers/src/default/data.ts | 5 +- .../models/wordbreakers/src/default/index.ts | 1105 +-- common/models/wordbreakers/src/index.ts | 11 +- common/models/wordbreakers/src/placeholder.ts | 43 +- .../test/test-ascii-word-breaker.js | 4 +- .../test/test-default-word-breaker.js | 4 +- .../test/test-placeholder-word-breaker.js | 4 +- common/models/wordbreakers/tsconfig.json | 10 +- common/predictive-text/.gitignore | 1 + common/predictive-text/browser.tsconfig.json | 31 - common/predictive-text/build-bundler.js | 57 + common/predictive-text/build.sh | 44 +- common/predictive-text/index.d.ts | 73 - common/predictive-text/index.ts | 254 - common/predictive-text/node-defaultWorker.ts | 12 - common/predictive-text/package.json | 24 +- common/predictive-text/promise-store.ts | 90 - common/predictive-text/src/index.ts | 3 + common/predictive-text/src/lmlayer.ts | 226 + common/predictive-text/src/node/index.ts | 3 + .../src/node/sourcemappedWorker.ts | 19 + common/predictive-text/src/node/tsconfig.json | 26 + .../src/node/virtualizedWorker.ts | 84 + common/predictive-text/src/node/worker.ts | 15 + common/predictive-text/src/promise-store.ts | 90 + common/predictive-text/src/tsconfig.json | 33 + common/predictive-text/src/unwrap.ts | 11 + common/predictive-text/src/web/index.ts | 3 + .../src/web/sourcemappedWorker.ts | 34 + common/predictive-text/src/web/tsconfig.json | 25 + common/predictive-text/src/web/worker.ts | 34 + .../{ => src}/worker-interface.d.ts | 0 common/predictive-text/tsconfig.all.json | 34 + common/predictive-text/tsconfig.json | 32 - .../unit_tests/headless/promise-store.js | 7 +- .../unit_tests/headless/top-level-lmlayer.js | 56 +- .../headless/worker-dummy-integration.js | 24 +- .../headless/worker-trie-integration.js | 17 +- common/predictive-text/unit_tests/helpers.js | 139 - .../unit_tests/in_browser/CI.conf.cjs | 4 + .../unit_tests/in_browser/CI.conf.js | 4 - .../{base.conf.js => base.conf.cjs} | 37 +- .../in_browser/cases/top-level-lmlayer.js | 11 +- .../cases/worker-dummy-integration.js | 10 +- .../cases/worker-trie-integration.js | 8 +- .../unit_tests/in_browser/cases/worker.js | 15 +- .../unit_tests/in_browser/helpers.js | 8 - .../unit_tests/in_browser/helpers.mjs | 3 + .../{manual.conf.js => manual.conf.cjs} | 2 +- common/predictive-text/unit_tests/test.sh | 16 +- common/predictive-text/virtualizedWorker.ts | 88 - common/predictive-text/web-defaultWorker.ts | 27 - common/test/resources/index.mjs | 3 + .../i_got_distracted_by_hazel.json | 0 .../json/models}/tries/accented.json | 0 .../json/models}/tries/english-1000.json | 0 ...onfig.js => karma-browserstack-config.cjs} | 0 .../test/resources/keyboards/khmer_angkor.js | 6 +- .../test/resources/keyboards/test_deadkeys.js | 6 +- common/test/resources/model-helpers.mjs | 160 + .../resources/models/naive-trie.js | 0 .../resources/models/simple-dummy.js | 0 .../resources/models/simple-trie.js | 0 common/test/resources/package.json | 12 + common/test/resources/timeout-adapter.js | 2 +- common/tools/sourcemap-path-remapper/build.sh | 22 +- .../sourcemap-path-remapper/package.json | 2 +- .../sourcemap-path-remapper/src/index.ts | 22 +- common/web/input-processor/build-bundler.js | 45 + common/web/input-processor/build.sh | 38 +- common/web/input-processor/package.json | 15 + .../input-processor/src/includes/events.ts | 20 - common/web/input-processor/src/index.ts | 7 + .../input-processor/src/text/contextWindow.ts | 78 +- .../src/text/inputProcessor.ts | 637 +- .../src/text/prediction/languageProcessor.ts | 718 +- .../src/text/prediction/modelSpec.ts | 27 + .../src/text/prediction/predictionContext.ts | 351 + .../input-processor/src/tsconfig.bundled.json | 22 - common/web/input-processor/src/tsconfig.json | 27 - .../tests/cases/inputProcessor.js | 70 +- .../tests/cases/languageProcessor.js | 74 +- .../tests/cases/predictionContext.js | 275 + common/web/input-processor/tsconfig.json | 28 + .../web/keyboard-processor/build-bundler.js | 58 + common/web/keyboard-processor/build.sh | 23 +- common/web/keyboard-processor/package.json | 37 +- common/web/keyboard-processor/src/index.ts | 45 + .../src/keyboards/activeLayout.ts | 1305 ++- .../src/keyboards/defaultLayouts.ts | 1524 ++- .../src/keyboards/keyboard.ts | 832 +- .../src/keyboards/keyboardHarness.ts | 114 + .../src/keyboards/keyboardLoaderBase.ts | 23 + .../src/keyboards/keyboardProperties.ts | 251 + .../keyboards/loaders/dom-keyboard-loader.ts | 1 + .../keyboards/loaders/domKeyboardLoader.ts | 73 + .../keyboards/loaders/node-keyboard-loader.ts | 1 + .../keyboards/loaders/nodeKeyboardLoader.ts | 41 + .../src/keyboards/loaders/tsconfig.dom.json | 25 + .../src/keyboards/loaders/tsconfig.node.json | 25 + .../src/keyboards/spacebarText.ts | 9 + .../src/keyboards/stateKeyMap.ts | 9 + .../keyboard-processor/src/keyboards/test.ts | 5 + .../web/keyboard-processor/src/text/codes.ts | 266 +- .../keyboard-processor/src/text/deadkeys.ts | 257 +- .../src/text/defaultOutput.ts | 191 - .../src/text/defaultRules.ts | 225 + .../src/text/kbdInterface.ts | 2002 ++-- .../keyboard-processor/src/text/keyEvent.ts | 235 +- .../keyboard-processor/src/text/keyMapping.ts | 340 +- .../src/text/keyboardProcessor.ts | 1141 +-- .../src/text/outputTarget.ts | 800 +- .../src/text/ruleBehavior.ts | 233 +- .../src/text/systemStores.ts | 229 +- .../src/tsconfig.bundled.json | 15 - .../web/keyboard-processor/src/tsconfig.json | 24 - .../tests/cases/basic-engine.js | 56 - .../tests/cases/deadkeys.js | 56 - .../cases/engine/unmatched_final_group.js | 46 - .../keyboard-processor/tests/dom/CI.conf.cjs | 26 + .../tests/dom/base.conf.cjs | 94 + .../tests/dom/cases/domKeyboardLoader.js | 101 + .../tests/dom/manual.conf.cjs | 4 +- .../keyboard-processor/tests/dom/readme.md | 5 + .../tests/node/basic-engine.js | 52 + .../tests/{cases => node}/basic-init.js | 22 +- .../tests/node/bundled-module.js | 50 + .../tests/{cases => node}/chirality.js | 47 +- .../keyboard-processor/tests/node/deadkeys.js | 52 + .../tests/{cases => node}/engine/context.js | 49 +- .../{cases => node}/engine/notany_context.js | 39 +- .../tests/{cases => node}/engine/stores.js | 12 +- .../node/engine/unmatched_final_group.js | 41 + .../tests/node/keyboard-loading.js | 105 + .../tests/node/keyboard-properties.js | 98 + .../keyboard-processor/tests/node/mocks.js | 92 + .../tests/{cases => node}/transcriptions.js | 55 +- .../web/keyboard-processor/tsconfig-base.json | 16 + .../keyboard-processor/tsconfig.common.json | 28 + common/web/keyboard-processor/tsconfig.json | 27 + common/web/keyman-version/build-bundler.js | 22 + common/web/keyman-version/build.sh | 70 +- common/web/keyman-version/index.ts | 9 - common/web/keyman-version/package.json | 13 +- common/web/keyman-version/tsconfig.esm.json | 18 - common/web/keyman-version/tsconfig.json | 6 +- common/web/lm-worker/build-bundler.js | 63 + .../lm-worker/build-polyfill-concatenator.js | 147 + common/web/lm-worker/build-wrap-and-minify.js | 76 + common/web/lm-worker/build.sh | 102 +- common/web/lm-worker/package.json | 20 +- .../src/correction/classical-calculation.ts | 572 -- .../src/correction/context-tracker.ts | 539 - .../src/correction/distance-modeler.ts | 805 -- .../main/correction/classical-calculation.ts | 572 ++ .../src/main/correction/context-tracker.ts | 542 + .../src/main/correction/distance-modeler.ts | 805 ++ .../lm-worker/src/main/correction/index.ts | 3 + common/web/lm-worker/src/{ => main}/index.ts | 52 +- .../src/{ => main}/model-compositor.ts | 8 +- .../lm-worker/src/main/models/dummy-model.ts | 86 + common/web/lm-worker/src/main/models/index.ts | 2 + .../src/{ => main}/transformUtils.ts | 2 +- .../src/{ => main}/worker-interfaces.ts | 42 +- .../lm-worker/src/main/worker-main.ts} | 22 +- .../web/lm-worker/src/models/dummy-model.ts | 85 - .../edit-distance/classical-calculation.js | 7 +- .../cases}/edit-distance/context-tracker.js | 14 +- .../cases}/edit-distance/distance-modeler.js | 13 +- .../src/test/cases}/transform-utils.js | 12 +- .../test/cases}/worker-custom-punctuation.js | 8 +- .../src/test/cases}/worker-initialization.js | 21 +- .../test/cases}/worker-model-compositor.js | 25 +- .../src/test/cases}/worker-predict-dummy.js | 7 +- .../src/test/cases}/worker-predict.js | 13 +- common/web/lm-worker/tsconfig.json | 23 +- common/web/recorder/build.sh | 33 +- common/web/recorder/package.json | 4 +- common/web/recorder/src/index.ts | 1220 +-- common/web/recorder/src/nodeProctor.ts | 161 +- .../recorder/src/nodeProctor.tsconfig.json | 28 - common/web/recorder/src/proctor.ts | 79 +- common/web/recorder/src/tsconfig.json | 27 - common/web/recorder/tsconfig.json | 31 + common/web/types/test/tsconfig.json | 2 +- common/web/types/tsconfig.json | 2 +- common/web/utils/build-bundler.js | 38 + common/web/utils/build.sh | 23 +- common/web/utils/package.json | 26 +- common/web/utils/src/deepCopy.ts | 42 +- common/web/utils/src/deviceSpec.ts | 109 +- common/web/utils/src/globalObject.ts | 58 +- common/web/utils/src/index.ts | 31 +- common/web/utils/src/kmwstring.ts | 841 +- common/web/utils/src/managedPromise.ts | 64 + common/web/utils/src/test/managedPromise.js | 49 + .../cases => utils/src/test}/versions.js | 22 +- common/web/utils/src/version.ts | 179 +- common/web/utils/tsconfig.json | 11 +- developer/src/kmc-keyboard/test/tsconfig.json | 2 +- developer/src/kmc-keyboard/tsconfig.json | 2 +- developer/src/kmc-model/src/main.ts | 6 +- developer/src/kmc-model/tsconfig.json | 2 +- developer/src/kmc-package/test/tsconfig.json | 2 +- developer/src/kmc-package/tsconfig.json | 2 +- developer/src/kmc/tsconfig.json | 2 +- docs/build/macos.md | 2 +- docs/build/windows.md | 2 +- .../Keyboard/KeymanWebViewController.swift | 2 +- .../Contents/Resources/ios-host.js | 14 +- package-lock.json | 481 +- package.json | 2 + resources/builder.inc.sh | 43 +- tsconfig-base.json | 3 +- tsconfig.cjs.json | 19 +- tsconfig.esm.json | 2 +- web/README.md | 29 +- web/build.sh | 504 +- web/ci.sh | 4 + web/common.inc.sh | 33 + web/namespaced-build.sh | 579 ++ web/package.json | 70 +- web/src/app/browser/README.md | 1 + web/src/app/browser/build-bundler.js | 22 + web/src/app/browser/build.sh | 68 + web/src/app/browser/src/configuration.ts | 37 + web/src/app/browser/src/contextManager.ts | 61 + .../app/browser/src/defaultBrowserRules.ts | 54 + web/src/app/browser/src/keyEventKeyboard.ts | 14 + web/src/app/browser/src/keyboardInterface.ts | 101 + web/src/app/browser/src/keymanEngine.ts | 19 + web/src/app/browser/src/main.ts | 1 + web/src/app/browser/src/oskConfiguration.ts | 34 + web/src/app/browser/tsconfig.json | 24 + web/src/app/embed/README.md | 3 + web/src/app/embed/kmwembedded.ts | 442 +- web/src/app/embed/osk/embedded/globeHint.ts | 188 - web/src/app/embed/osk/embedded/keytip.ts | 49 - .../embed/osk/embedded/pendingLongpress.ts | 47 - .../embed/osk/embedded/singleInputCapture.ts | 44 - .../app/embed/osk/embedded/subkeyDelegator.ts | 108 - web/src/app/web/README.md | 3 + web/src/app/web/kmwnative.ts | 10 - web/src/app/webview/README.md | 1 + web/src/app/webview/build-bundler.js | 22 + web/src/app/webview/build.sh | 67 + web/src/app/webview/src/configuration.ts | 48 + web/src/app/webview/src/contextManager.ts | 63 + web/src/app/webview/src/keymanEngine.ts | 208 + web/src/app/webview/src/main.ts | 1 + web/src/app/webview/src/osk/globeHint.ts | 194 + web/src/app/webview/src/osk/keytip.ts | 53 + .../app/webview/src/osk/pendingLongpress.ts | 52 + .../app/webview/src/osk/singleInputCapture.ts | 42 + .../app/webview/src/osk/subkeyDelegator.ts | 116 + web/src/app/webview/src/oskConfiguration.ts | 79 + .../app/webview/src/passthroughKeyboard.ts | 54 + web/src/app/webview/tsconfig.json | 24 + web/src/engine/README.md | 3 + web/src/engine/build.sh | 197 - web/src/engine/device-detect/build-bundler.js | 23 + web/src/engine/device-detect/build.sh | 63 + web/src/engine/device-detect/kmwdevice.ts | 203 - web/src/engine/device-detect/src/index.ts | 210 + .../device-detect/src/utils/styleConstants.ts | 31 + .../device-detect/tsconfig.bundled.json | 12 - web/src/engine/device-detect/tsconfig.json | 19 +- .../device-detect/utils/styleConstants.ts | 34 - web/src/engine/dom-utils/build.sh | 65 + web/src/engine/dom-utils/readme.md | 12 + .../engine/dom-utils/src/cookieSerializer.ts | 91 + .../src/createUnselectableElement.ts | 9 + web/src/engine/dom-utils/src/getAbsolute.ts | 81 + web/src/engine/dom-utils/src/index.ts | 5 + web/src/engine/dom-utils/src/landscapeView.ts | 22 + web/src/engine/dom-utils/src/stylesheets.ts | 220 + web/src/engine/dom-utils/tsconfig.json | 19 + .../engine/element-wrappers/build-bundler.js | 24 + web/src/engine/element-wrappers/build.sh | 64 + web/src/engine/element-wrappers/readme.md | 6 +- .../element-wrappers/src/contentEditable.ts | 273 + .../element-wrappers/src/designIFrame.ts | 358 + web/src/engine/element-wrappers/src/index.ts | 7 + web/src/engine/element-wrappers/src/input.ts | 238 + .../element-wrappers/src/outputTarget.ts | 36 + .../src}/readme.md | 0 .../engine/element-wrappers/src/textarea.ts | 221 + web/src/engine/element-wrappers/src/utils.ts | 43 + .../element-wrappers/src/wrapElement.ts | 31 + .../element-wrappers/tsconfig.bundled.json | 13 - web/src/engine/element-wrappers/tsconfig.json | 30 +- web/src/engine/main/build-bundler.js | 24 + web/src/engine/main/build.sh | 66 + web/src/engine/main/dom/domDefaultOutput.ts | 45 - .../main/dom/targets/contentEditable.ts | 267 - .../engine/main/dom/targets/designIFrame.ts | 352 - web/src/engine/main/dom/targets/input.ts | 177 - .../engine/main/dom/targets/outputTarget.ts | 45 - web/src/engine/main/dom/targets/textarea.ts | 170 - .../engine/main/dom/targets/wrapElement.ts | 36 - web/src/engine/main/dom/utils.ts | 181 - .../main/dom/variableStoreCookieSerializer.ts | 24 - web/src/engine/main/osk/anchoredOskView.ts | 307 - web/src/engine/main/osk/banner.ts | 736 -- web/src/engine/main/osk/bannerManager.ts | 292 - web/src/engine/main/osk/browser/keytip.ts | 146 - web/src/engine/main/osk/browser/oskSubKey.ts | 68 - .../main/osk/browser/pendingLongpress.ts | 73 - .../main/osk/browser/pendingMultiTap.ts | 128 - .../engine/main/osk/browser/subkeyPopup.ts | 295 - web/src/engine/main/osk/emptyView.ts | 23 - web/src/engine/main/osk/floatingOskView.ts | 573 -- .../engine/main/osk/globehint.interface.ts | 10 - web/src/engine/main/osk/helpPageView.ts | 39 - web/src/engine/main/osk/inlinedOskView.ts | 186 - .../engine/main/osk/inputEventCoordinate.ts | 74 - web/src/engine/main/osk/inputEventEngine.ts | 59 - .../engine/main/osk/keyboardView.interface.ts | 22 - web/src/engine/main/osk/keytip.interface.ts | 9 - .../main/osk/layouts/mouseDragOperation.ts | 171 - web/src/engine/main/osk/layouts/resizeBar.ts | 89 - .../main/osk/layouts/targetedFloatLayout.ts | 212 - web/src/engine/main/osk/layouts/titleBar.ts | 205 - web/src/engine/main/osk/lengthStyle.ts | 87 - web/src/engine/main/osk/mouseEventEngine.ts | 127 - web/src/engine/main/osk/oskBaseKey.ts | 164 - web/src/engine/main/osk/oskKey.ts | 519 - web/src/engine/main/osk/oskLayer.ts | 122 - web/src/engine/main/osk/oskLayerGroup.ts | 53 - web/src/engine/main/osk/oskRow.ts | 86 - web/src/engine/main/osk/oskView.ts | 1091 -- web/src/engine/main/osk/oskViewComponent.ts | 7 - .../main/osk/pendingGesture.interface.ts | 45 - web/src/engine/main/osk/preProcessor.ts | 71 - .../main/osk/realizedGesture.interface.ts | 34 - web/src/engine/main/osk/touchEventEngine.ts | 90 - web/src/engine/main/osk/uiTouchHandlerBase.ts | 428 - web/src/engine/main/osk/utils.ts | 18 - web/src/engine/main/osk/visualKeyboard.ts | 1825 ---- web/src/engine/main/readme.md | 3 + web/src/engine/main/src/contextManagerBase.ts | 82 + .../engine/main/src/engineConfiguration.ts | 105 + web/src/engine/main/src/hardKeyboard.ts | 18 + web/src/engine/main/src/index.ts | 7 + .../main/src/keyEventSource.interface.ts | 14 + web/src/engine/main/src/keyboardInterface.ts | 85 + web/src/engine/main/src/keymanEngine.ts | 251 + web/src/engine/main/src/legacyAPIEvents.ts | 18 + .../main/src/variableStoreCookieSerializer.ts | 35 + web/src/engine/main/text/domKbdInterface.ts | 143 - .../main/text/prediction/modelManager.ts | 111 - web/src/engine/main/tsconfig.json | 40 +- .../dom/domEventHandlers.ts | 2 - .../dom/domManager.ts | 105 +- .../dom/domOverrides.ts | 12 +- .../dom/preProcessor.ts | 0 web/src/engine/namespaced-main/dom/utils.ts | 34 + .../keyboards/kmwkeyboards.ts | 891 +- .../{main => namespaced-main}/keymanweb.ts | 0 .../{main => namespaced-main}/kmwapi.ts | 0 .../{main => namespaced-main}/kmwbase.ts | 51 +- .../{main => namespaced-main}/kmwdebug.js | 0 .../{main => namespaced-main}/kmwdebugstub.js | 0 .../{main => namespaced-main}/kmwexthtml.ts | 0 .../{main => namespaced-main}/kmwhotkeys.ts | 0 .../{main => namespaced-main}/kmwinit.ts | 0 .../kmwreleasestub.js | 0 .../{main => namespaced-main}/kmwtypedefs.ts | 0 .../{main => namespaced-main}/kmwuimanager.ts | 0 .../{main => namespaced-main}/kmwutils.ts | 296 - .../osk/languageMenu.ts | 0 .../{main => namespaced-main}/singleton.ts | 0 web/src/engine/namespaced-main/tsconfig.json | 32 + web/src/engine/osk/build-bundler.js | 16 + web/src/engine/osk/build.sh | 64 + web/src/engine/osk/src/banner/banner.ts | 562 ++ web/src/engine/osk/src/banner/bannerView.ts | 295 + web/src/engine/osk/src/buttonClassNames.ts | 19 + .../engine/osk/src/components/emptyView.ts | 22 + .../engine/osk/src/components/helpPageView.ts | 40 + .../src/components/keyboardView.interface.ts | 22 + .../components/oskViewComponent.interface.ts | 7 + .../engine/osk/src/components/resizeBar.ts | 87 + web/src/engine/osk/src/components/titleBar.ts | 202 + .../osk/src/config/commonConfiguration.ts | 33 + .../osk/src/config/embeddedGestureConfig.ts | 11 + .../oskResourcePathConfiguration.interface.ts | 5 + .../osk/src/config/viewConfiguration.ts | 43 + web/src/engine/osk/src/fontSizeUtils.ts | 28 + web/src/engine/osk/src/globehint.interface.ts | 10 + web/src/engine/osk/src/index.ts | 30 + .../event-interpreter/inputEventEngine.ts | 57 + .../event-interpreter/mouseEventEngine.ts | 95 + .../event-interpreter/touchEventEngine.ts | 60 + .../event-interpreter/uiTouchHandlerBase.ts | 429 + .../osk/src/input/gestures/browser/keytip.ts | 152 + .../src/input/gestures/browser/oskSubKey.ts | 68 + .../gestures/browser/pendingLongpress.ts | 73 + .../input/gestures/browser/pendingMultiTap.ts | 127 + .../src/input/gestures/browser/subkeyPopup.ts | 298 + .../gestures/pendingGesture.interface.ts | 46 + .../gestures/realizedGesture.interface.ts | 36 + .../osk/src/input/inputEventCoordinate.ts | 72 + .../osk/src/input/mouseDragOperation.ts | 167 + web/src/engine/osk/src/keyElement.ts | 40 + .../osk/src/keyboard-layout/oskBaseKey.ts | 166 + .../engine/osk/src/keyboard-layout/oskKey.ts | 434 + .../osk/src/keyboard-layout/oskLayer.ts | 124 + .../osk/src/keyboard-layout/oskLayerGroup.ts | 57 + .../engine/osk/src/keyboard-layout/oskRow.ts | 89 + web/src/engine/osk/src/keytip.interface.ts | 10 + web/src/engine/osk/src/lengthStyle.ts | 85 + web/src/engine/osk/src/screenUtils.ts | 43 + web/src/engine/osk/src/specialCharacters.ts | 48 + web/src/engine/osk/src/views/activator.ts | 44 + .../engine/osk/src/views/anchoredOskView.ts | 244 + .../engine/osk/src/views/floatingOskCookie.ts | 75 + .../engine/osk/src/views/floatingOskView.ts | 740 ++ .../engine/osk/src/views/inlinedOskView.ts | 126 + web/src/engine/osk/src/views/oskView.ts | 1381 +++ .../engine/osk/src/views/simpleActivator.ts | 29 + .../osk/src/views/touchEventPromiseMap.ts | 31 + .../engine/osk/src/views/twoStateActivator.ts | 50 + web/src/engine/osk/src/visualKeyboard.ts | 1976 ++++ web/src/engine/osk/tsconfig.json | 24 + web/src/engine/package-cache/README.md | 5 + web/src/engine/package-cache/build-bundler.js | 44 + web/src/engine/package-cache/build.sh | 66 + .../engine/package-cache/src/cloud/index.ts | 2 + .../package-cache/src/cloud/queryEngine.ts | 335 + .../src/cloud/requesterInterface.ts | 8 + .../package-cache/src/domCloudRequester.ts | 74 + web/src/engine/package-cache/src/index.ts | 16 + .../src/keyboardRequisitioner.ts | 288 + .../engine/package-cache/src/keyboardStub.ts | 202 + .../engine/package-cache/src/modelCache.ts | 66 + .../package-cache/src/nodeCloudRequester.ts | 101 + .../package-cache/src/stubAndKeyboardCache.ts | 181 + web/src/engine/package-cache/tsconfig.json | 24 + web/src/engine/paths/build-bundler.js | 24 + web/src/engine/paths/build.sh | 64 + web/src/engine/paths/src/index.ts | 2 + .../engine/paths/src/optionSpec.interface.ts | 31 + web/src/engine/paths/src/pathConfiguration.ts | 99 + web/src/engine/paths/tsconfig.json | 24 + web/src/resources/osk/kmwosk.css | 1 + web/src/test/auto/CI.conf.js | 4 - web/src/test/auto/cases/attachmentAPI.js | 257 - web/src/test/auto/cases/basics.js | 154 - web/src/test/auto/cases/engine.js | 292 - web/src/test/auto/cases/engine_chirality.js | 47 - web/src/test/auto/cases/events.js | 113 - web/src/test/auto/cases/text_selection.js | 545 - web/src/test/auto/dom/CI.conf.cjs | 26 + web/src/test/auto/dom/base.conf.cjs | 127 + .../test/auto/dom/cases/dom-utils/cookies.js | 102 + .../cases/element-wrappers}/target_mocks.js | 25 +- web/src/test/auto/dom/cases/osk/activation.js | 126 + web/src/test/auto/dom/cases/osk/events.js | 84 + .../auto/dom/cases/packages/cloudQueries.js | 165 + .../dom/cases/packages/domCloudRequester.js | 99 + .../cases/packages/keyboardRequisitioner.js | 104 + .../test/auto/{ => dom}/cases/test_config.js | 0 web/src/test/auto/dom/kbdLoader.mjs | 39 + web/src/test/auto/dom/manual.conf.cjs | 26 + web/src/test/auto/dom/readme.md | 5 + .../test/auto/{ => dom}/test_init_check.js | 4 +- web/src/test/auto/dom/test_utils.js | 331 + web/src/test/auto/dom/timedPromise.mjs | 18 + web/src/test/auto/headless/osk/activation.js | 233 + .../test/auto/headless/osk/lengthStyles.js | 81 + .../auto/headless/packages/cloudQueries.js | 228 + .../packages/keyboardRequisitioner.js | 336 + .../auto/headless/packages/keyboardStub.js | 59 + .../headless/packages/nodeCloudRequester.js | 104 + .../headless/packages/stubAndKeyboardCache.js | 153 + .../auto/headless/paths/pathConfiguration.js | 71 + web/src/test/auto/integrated/CI.conf.cjs | 4 + .../base.conf.cjs} | 36 +- .../auto/integrated/cases/attachmentAPI.js | 257 + web/src/test/auto/integrated/cases/basics.js | 154 + .../cases/element_interfaces.js | 121 +- web/src/test/auto/integrated/cases/engine.js | 292 + .../auto/integrated/cases/engine_chirality.js | 47 + web/src/test/auto/integrated/cases/events.js | 113 + .../test/auto/integrated/cases/test_config.js | 29 + .../auto/integrated/cases/text_selection.js | 545 + web/src/test/auto/integrated/manual.conf.cjs | 26 + .../{ => integrated}/modernizr.config.json | 0 .../test/auto/integrated/test_init_check.js | 30 + web/src/test/auto/integrated/test_utils.js | 331 + .../query-mock-results/@dz.js.fixture | 1609 +++ .../khmer_angkor.hand-edited.js.fixture | 49 + .../query-mock-results/languages.js.fixture | 8760 +++++++++++++++++ .../resources/query-mock-results/readme.md | 4 + .../sil_cameroon_azerty.js.fixture | 2248 +++++ .../sil_euro_latin@no_sv.js.fixture | 52 + web/src/test/auto/test_utils.js | 332 - web/src/test/manual/web/osk/kbdLoader.mjs | 103 + .../manual/web/osk/scratchspace/commands.js | 265 + .../manual/web/osk/scratchspace/index.css | 113 + .../manual/web/osk/scratchspace/index.html | 179 + .../manual/web/osk/scratchspace/setup.mjs | 48 + web/src/tools/testing/recorder/tsconfig.json | 6 +- web/src/tsconfig.all.json | 30 + web/test.sh | 25 +- 529 files changed, 49506 insertions(+), 27199 deletions(-) create mode 100644 common/models/templates/build-bundler.js create mode 100755 common/models/templates/build.sh delete mode 100644 common/models/templates/tsconfig.bundled.json create mode 100644 common/models/wordbreakers/build-bundler.js create mode 100755 common/models/wordbreakers/build.sh delete mode 100644 common/predictive-text/browser.tsconfig.json create mode 100644 common/predictive-text/build-bundler.js delete mode 100644 common/predictive-text/index.d.ts delete mode 100644 common/predictive-text/index.ts delete mode 100644 common/predictive-text/node-defaultWorker.ts delete mode 100644 common/predictive-text/promise-store.ts create mode 100644 common/predictive-text/src/index.ts create mode 100644 common/predictive-text/src/lmlayer.ts create mode 100644 common/predictive-text/src/node/index.ts create mode 100644 common/predictive-text/src/node/sourcemappedWorker.ts create mode 100644 common/predictive-text/src/node/tsconfig.json create mode 100644 common/predictive-text/src/node/virtualizedWorker.ts create mode 100644 common/predictive-text/src/node/worker.ts create mode 100644 common/predictive-text/src/promise-store.ts create mode 100644 common/predictive-text/src/tsconfig.json create mode 100644 common/predictive-text/src/unwrap.ts create mode 100644 common/predictive-text/src/web/index.ts create mode 100644 common/predictive-text/src/web/sourcemappedWorker.ts create mode 100644 common/predictive-text/src/web/tsconfig.json create mode 100644 common/predictive-text/src/web/worker.ts rename common/predictive-text/{ => src}/worker-interface.d.ts (100%) create mode 100644 common/predictive-text/tsconfig.all.json delete mode 100644 common/predictive-text/tsconfig.json delete mode 100644 common/predictive-text/unit_tests/helpers.js create mode 100644 common/predictive-text/unit_tests/in_browser/CI.conf.cjs delete mode 100644 common/predictive-text/unit_tests/in_browser/CI.conf.js rename common/predictive-text/unit_tests/in_browser/{base.conf.js => base.conf.cjs} (55%) delete mode 100644 common/predictive-text/unit_tests/in_browser/helpers.js create mode 100644 common/predictive-text/unit_tests/in_browser/helpers.mjs rename common/predictive-text/unit_tests/in_browser/{manual.conf.js => manual.conf.cjs} (95%) delete mode 100644 common/predictive-text/virtualizedWorker.ts delete mode 100644 common/predictive-text/web-defaultWorker.ts create mode 100644 common/test/resources/index.mjs rename common/{predictive-text/unit_tests/in_browser/json => test/resources/json/models}/future_suggestions/i_got_distracted_by_hazel.json (100%) rename common/{predictive-text/unit_tests/in_browser/json => test/resources/json/models}/tries/accented.json (100%) rename common/{predictive-text/unit_tests/in_browser/json => test/resources/json/models}/tries/english-1000.json (100%) rename common/test/resources/{karma-browserstack-config.js => karma-browserstack-config.cjs} (100%) create mode 100644 common/test/resources/model-helpers.mjs rename common/{predictive-text/unit_tests/in_browser => test}/resources/models/naive-trie.js (100%) rename common/{predictive-text/unit_tests/in_browser => test}/resources/models/simple-dummy.js (100%) rename common/{predictive-text/unit_tests/in_browser => test}/resources/models/simple-trie.js (100%) create mode 100644 common/test/resources/package.json create mode 100644 common/web/input-processor/build-bundler.js delete mode 100644 common/web/input-processor/src/includes/events.ts create mode 100644 common/web/input-processor/src/index.ts create mode 100644 common/web/input-processor/src/text/prediction/modelSpec.ts create mode 100644 common/web/input-processor/src/text/prediction/predictionContext.ts delete mode 100644 common/web/input-processor/src/tsconfig.bundled.json delete mode 100644 common/web/input-processor/src/tsconfig.json create mode 100644 common/web/input-processor/tests/cases/predictionContext.js create mode 100644 common/web/input-processor/tsconfig.json create mode 100644 common/web/keyboard-processor/build-bundler.js create mode 100644 common/web/keyboard-processor/src/index.ts create mode 100644 common/web/keyboard-processor/src/keyboards/keyboardHarness.ts create mode 100644 common/web/keyboard-processor/src/keyboards/keyboardLoaderBase.ts create mode 100644 common/web/keyboard-processor/src/keyboards/keyboardProperties.ts create mode 100644 common/web/keyboard-processor/src/keyboards/loaders/dom-keyboard-loader.ts create mode 100644 common/web/keyboard-processor/src/keyboards/loaders/domKeyboardLoader.ts create mode 100644 common/web/keyboard-processor/src/keyboards/loaders/node-keyboard-loader.ts create mode 100644 common/web/keyboard-processor/src/keyboards/loaders/nodeKeyboardLoader.ts create mode 100644 common/web/keyboard-processor/src/keyboards/loaders/tsconfig.dom.json create mode 100644 common/web/keyboard-processor/src/keyboards/loaders/tsconfig.node.json create mode 100644 common/web/keyboard-processor/src/keyboards/spacebarText.ts create mode 100644 common/web/keyboard-processor/src/keyboards/stateKeyMap.ts create mode 100644 common/web/keyboard-processor/src/keyboards/test.ts delete mode 100644 common/web/keyboard-processor/src/text/defaultOutput.ts create mode 100644 common/web/keyboard-processor/src/text/defaultRules.ts delete mode 100644 common/web/keyboard-processor/src/tsconfig.bundled.json delete mode 100644 common/web/keyboard-processor/src/tsconfig.json delete mode 100644 common/web/keyboard-processor/tests/cases/basic-engine.js delete mode 100644 common/web/keyboard-processor/tests/cases/deadkeys.js delete mode 100644 common/web/keyboard-processor/tests/cases/engine/unmatched_final_group.js create mode 100644 common/web/keyboard-processor/tests/dom/CI.conf.cjs create mode 100644 common/web/keyboard-processor/tests/dom/base.conf.cjs create mode 100644 common/web/keyboard-processor/tests/dom/cases/domKeyboardLoader.js rename web/src/test/auto/manual.conf.js => common/web/keyboard-processor/tests/dom/manual.conf.cjs (86%) create mode 100644 common/web/keyboard-processor/tests/dom/readme.md create mode 100644 common/web/keyboard-processor/tests/node/basic-engine.js rename common/web/keyboard-processor/tests/{cases => node}/basic-init.js (63%) create mode 100644 common/web/keyboard-processor/tests/node/bundled-module.js rename common/web/keyboard-processor/tests/{cases => node}/chirality.js (87%) create mode 100644 common/web/keyboard-processor/tests/node/deadkeys.js rename common/web/keyboard-processor/tests/{cases => node}/engine/context.js (97%) rename common/web/keyboard-processor/tests/{cases => node}/engine/notany_context.js (70%) rename common/web/keyboard-processor/tests/{cases => node}/engine/stores.js (84%) create mode 100644 common/web/keyboard-processor/tests/node/engine/unmatched_final_group.js create mode 100644 common/web/keyboard-processor/tests/node/keyboard-loading.js create mode 100644 common/web/keyboard-processor/tests/node/keyboard-properties.js create mode 100644 common/web/keyboard-processor/tests/node/mocks.js rename common/web/keyboard-processor/tests/{cases => node}/transcriptions.js (93%) create mode 100644 common/web/keyboard-processor/tsconfig-base.json create mode 100644 common/web/keyboard-processor/tsconfig.common.json create mode 100644 common/web/keyboard-processor/tsconfig.json create mode 100644 common/web/keyman-version/build-bundler.js delete mode 100644 common/web/keyman-version/index.ts delete mode 100644 common/web/keyman-version/tsconfig.esm.json create mode 100644 common/web/lm-worker/build-bundler.js create mode 100644 common/web/lm-worker/build-polyfill-concatenator.js create mode 100644 common/web/lm-worker/build-wrap-and-minify.js delete mode 100644 common/web/lm-worker/src/correction/classical-calculation.ts delete mode 100644 common/web/lm-worker/src/correction/context-tracker.ts delete mode 100644 common/web/lm-worker/src/correction/distance-modeler.ts create mode 100644 common/web/lm-worker/src/main/correction/classical-calculation.ts create mode 100644 common/web/lm-worker/src/main/correction/context-tracker.ts create mode 100644 common/web/lm-worker/src/main/correction/distance-modeler.ts create mode 100644 common/web/lm-worker/src/main/correction/index.ts rename common/web/lm-worker/src/{ => main}/index.ts (90%) rename common/web/lm-worker/src/{ => main}/model-compositor.ts (99%) create mode 100644 common/web/lm-worker/src/main/models/dummy-model.ts create mode 100644 common/web/lm-worker/src/main/models/index.ts rename common/web/lm-worker/src/{ => main}/transformUtils.ts (94%) rename common/web/lm-worker/src/{ => main}/worker-interfaces.ts (82%) rename common/{predictive-text/embedded_worker.d.ts => web/lm-worker/src/main/worker-main.ts} (67%) delete mode 100644 common/web/lm-worker/src/models/dummy-model.ts rename common/{predictive-text/unit_tests/headless => web/lm-worker/src/test/cases}/edit-distance/classical-calculation.js (98%) rename common/{predictive-text/unit_tests/headless => web/lm-worker/src/test/cases}/edit-distance/context-tracker.js (95%) rename common/{predictive-text/unit_tests/headless => web/lm-worker/src/test/cases}/edit-distance/distance-modeler.js (97%) rename common/{predictive-text/unit_tests/headless => web/lm-worker/src/test/cases}/transform-utils.js (86%) rename common/{predictive-text/unit_tests/headless => web/lm-worker/src/test/cases}/worker-custom-punctuation.js (92%) rename common/{predictive-text/unit_tests/headless => web/lm-worker/src/test/cases}/worker-initialization.js (89%) rename common/{predictive-text/unit_tests/headless => web/lm-worker/src/test/cases}/worker-model-compositor.js (97%) rename common/{predictive-text/unit_tests/headless => web/lm-worker/src/test/cases}/worker-predict-dummy.js (93%) rename common/{predictive-text/unit_tests/headless => web/lm-worker/src/test/cases}/worker-predict.js (79%) delete mode 100644 common/web/recorder/src/nodeProctor.tsconfig.json delete mode 100644 common/web/recorder/src/tsconfig.json create mode 100644 common/web/recorder/tsconfig.json create mode 100644 common/web/utils/build-bundler.js create mode 100644 common/web/utils/src/managedPromise.ts create mode 100644 common/web/utils/src/test/managedPromise.js rename common/web/{keyboard-processor/tests/cases => utils/src/test}/versions.js (59%) create mode 100644 web/common.inc.sh create mode 100755 web/namespaced-build.sh create mode 100644 web/src/app/browser/README.md create mode 100644 web/src/app/browser/build-bundler.js create mode 100755 web/src/app/browser/build.sh create mode 100644 web/src/app/browser/src/configuration.ts create mode 100644 web/src/app/browser/src/contextManager.ts create mode 100644 web/src/app/browser/src/defaultBrowserRules.ts create mode 100644 web/src/app/browser/src/keyEventKeyboard.ts create mode 100644 web/src/app/browser/src/keyboardInterface.ts create mode 100644 web/src/app/browser/src/keymanEngine.ts create mode 100644 web/src/app/browser/src/main.ts create mode 100644 web/src/app/browser/src/oskConfiguration.ts create mode 100644 web/src/app/browser/tsconfig.json create mode 100644 web/src/app/embed/README.md delete mode 100644 web/src/app/embed/osk/embedded/globeHint.ts delete mode 100644 web/src/app/embed/osk/embedded/keytip.ts delete mode 100644 web/src/app/embed/osk/embedded/pendingLongpress.ts delete mode 100644 web/src/app/embed/osk/embedded/singleInputCapture.ts delete mode 100644 web/src/app/embed/osk/embedded/subkeyDelegator.ts create mode 100644 web/src/app/web/README.md create mode 100644 web/src/app/webview/README.md create mode 100644 web/src/app/webview/build-bundler.js create mode 100755 web/src/app/webview/build.sh create mode 100644 web/src/app/webview/src/configuration.ts create mode 100644 web/src/app/webview/src/contextManager.ts create mode 100644 web/src/app/webview/src/keymanEngine.ts create mode 100644 web/src/app/webview/src/main.ts create mode 100644 web/src/app/webview/src/osk/globeHint.ts create mode 100644 web/src/app/webview/src/osk/keytip.ts create mode 100644 web/src/app/webview/src/osk/pendingLongpress.ts create mode 100644 web/src/app/webview/src/osk/singleInputCapture.ts create mode 100644 web/src/app/webview/src/osk/subkeyDelegator.ts create mode 100644 web/src/app/webview/src/oskConfiguration.ts create mode 100644 web/src/app/webview/src/passthroughKeyboard.ts create mode 100644 web/src/app/webview/tsconfig.json create mode 100644 web/src/engine/README.md delete mode 100755 web/src/engine/build.sh create mode 100644 web/src/engine/device-detect/build-bundler.js create mode 100755 web/src/engine/device-detect/build.sh delete mode 100644 web/src/engine/device-detect/kmwdevice.ts create mode 100644 web/src/engine/device-detect/src/index.ts create mode 100644 web/src/engine/device-detect/src/utils/styleConstants.ts delete mode 100644 web/src/engine/device-detect/tsconfig.bundled.json delete mode 100644 web/src/engine/device-detect/utils/styleConstants.ts create mode 100755 web/src/engine/dom-utils/build.sh create mode 100644 web/src/engine/dom-utils/readme.md create mode 100644 web/src/engine/dom-utils/src/cookieSerializer.ts create mode 100644 web/src/engine/dom-utils/src/createUnselectableElement.ts create mode 100644 web/src/engine/dom-utils/src/getAbsolute.ts create mode 100644 web/src/engine/dom-utils/src/index.ts create mode 100644 web/src/engine/dom-utils/src/landscapeView.ts create mode 100644 web/src/engine/dom-utils/src/stylesheets.ts create mode 100644 web/src/engine/dom-utils/tsconfig.json create mode 100644 web/src/engine/element-wrappers/build-bundler.js create mode 100755 web/src/engine/element-wrappers/build.sh create mode 100644 web/src/engine/element-wrappers/src/contentEditable.ts create mode 100644 web/src/engine/element-wrappers/src/designIFrame.ts create mode 100644 web/src/engine/element-wrappers/src/index.ts create mode 100644 web/src/engine/element-wrappers/src/input.ts create mode 100644 web/src/engine/element-wrappers/src/outputTarget.ts rename web/src/engine/{main/dom/targets => element-wrappers/src}/readme.md (100%) create mode 100644 web/src/engine/element-wrappers/src/textarea.ts create mode 100644 web/src/engine/element-wrappers/src/utils.ts create mode 100644 web/src/engine/element-wrappers/src/wrapElement.ts delete mode 100644 web/src/engine/element-wrappers/tsconfig.bundled.json create mode 100644 web/src/engine/main/build-bundler.js create mode 100755 web/src/engine/main/build.sh delete mode 100644 web/src/engine/main/dom/domDefaultOutput.ts delete mode 100644 web/src/engine/main/dom/targets/contentEditable.ts delete mode 100644 web/src/engine/main/dom/targets/designIFrame.ts delete mode 100644 web/src/engine/main/dom/targets/input.ts delete mode 100644 web/src/engine/main/dom/targets/outputTarget.ts delete mode 100644 web/src/engine/main/dom/targets/textarea.ts delete mode 100644 web/src/engine/main/dom/targets/wrapElement.ts delete mode 100644 web/src/engine/main/dom/utils.ts delete mode 100644 web/src/engine/main/dom/variableStoreCookieSerializer.ts delete mode 100644 web/src/engine/main/osk/anchoredOskView.ts delete mode 100644 web/src/engine/main/osk/banner.ts delete mode 100644 web/src/engine/main/osk/bannerManager.ts delete mode 100644 web/src/engine/main/osk/browser/keytip.ts delete mode 100644 web/src/engine/main/osk/browser/oskSubKey.ts delete mode 100644 web/src/engine/main/osk/browser/pendingLongpress.ts delete mode 100644 web/src/engine/main/osk/browser/pendingMultiTap.ts delete mode 100644 web/src/engine/main/osk/browser/subkeyPopup.ts delete mode 100644 web/src/engine/main/osk/emptyView.ts delete mode 100644 web/src/engine/main/osk/floatingOskView.ts delete mode 100644 web/src/engine/main/osk/globehint.interface.ts delete mode 100644 web/src/engine/main/osk/helpPageView.ts delete mode 100644 web/src/engine/main/osk/inlinedOskView.ts delete mode 100644 web/src/engine/main/osk/inputEventCoordinate.ts delete mode 100644 web/src/engine/main/osk/inputEventEngine.ts delete mode 100644 web/src/engine/main/osk/keyboardView.interface.ts delete mode 100644 web/src/engine/main/osk/keytip.interface.ts delete mode 100644 web/src/engine/main/osk/layouts/mouseDragOperation.ts delete mode 100644 web/src/engine/main/osk/layouts/resizeBar.ts delete mode 100644 web/src/engine/main/osk/layouts/targetedFloatLayout.ts delete mode 100644 web/src/engine/main/osk/layouts/titleBar.ts delete mode 100644 web/src/engine/main/osk/lengthStyle.ts delete mode 100644 web/src/engine/main/osk/mouseEventEngine.ts delete mode 100644 web/src/engine/main/osk/oskBaseKey.ts delete mode 100644 web/src/engine/main/osk/oskKey.ts delete mode 100644 web/src/engine/main/osk/oskLayer.ts delete mode 100644 web/src/engine/main/osk/oskLayerGroup.ts delete mode 100644 web/src/engine/main/osk/oskRow.ts delete mode 100644 web/src/engine/main/osk/oskView.ts delete mode 100644 web/src/engine/main/osk/oskViewComponent.ts delete mode 100644 web/src/engine/main/osk/pendingGesture.interface.ts delete mode 100644 web/src/engine/main/osk/preProcessor.ts delete mode 100644 web/src/engine/main/osk/realizedGesture.interface.ts delete mode 100644 web/src/engine/main/osk/touchEventEngine.ts delete mode 100644 web/src/engine/main/osk/uiTouchHandlerBase.ts delete mode 100644 web/src/engine/main/osk/utils.ts delete mode 100644 web/src/engine/main/osk/visualKeyboard.ts create mode 100644 web/src/engine/main/readme.md create mode 100644 web/src/engine/main/src/contextManagerBase.ts create mode 100644 web/src/engine/main/src/engineConfiguration.ts create mode 100644 web/src/engine/main/src/hardKeyboard.ts create mode 100644 web/src/engine/main/src/index.ts create mode 100644 web/src/engine/main/src/keyEventSource.interface.ts create mode 100644 web/src/engine/main/src/keyboardInterface.ts create mode 100644 web/src/engine/main/src/keymanEngine.ts create mode 100644 web/src/engine/main/src/legacyAPIEvents.ts create mode 100644 web/src/engine/main/src/variableStoreCookieSerializer.ts delete mode 100644 web/src/engine/main/text/domKbdInterface.ts delete mode 100644 web/src/engine/main/text/prediction/modelManager.ts rename web/src/engine/{main => namespaced-main}/dom/domEventHandlers.ts (99%) rename web/src/engine/{main => namespaced-main}/dom/domManager.ts (97%) rename web/src/engine/{main => namespaced-main}/dom/domOverrides.ts (73%) rename web/src/engine/{main => namespaced-main}/dom/preProcessor.ts (100%) create mode 100644 web/src/engine/namespaced-main/dom/utils.ts rename web/src/engine/{main => namespaced-main}/keyboards/kmwkeyboards.ts (52%) rename web/src/engine/{main => namespaced-main}/keymanweb.ts (100%) rename web/src/engine/{main => namespaced-main}/kmwapi.ts (100%) rename web/src/engine/{main => namespaced-main}/kmwbase.ts (95%) rename web/src/engine/{main => namespaced-main}/kmwdebug.js (100%) rename web/src/engine/{main => namespaced-main}/kmwdebugstub.js (100%) rename web/src/engine/{main => namespaced-main}/kmwexthtml.ts (100%) rename web/src/engine/{main => namespaced-main}/kmwhotkeys.ts (100%) rename web/src/engine/{main => namespaced-main}/kmwinit.ts (100%) rename web/src/engine/{main => namespaced-main}/kmwreleasestub.js (100%) rename web/src/engine/{main => namespaced-main}/kmwtypedefs.ts (100%) rename web/src/engine/{main => namespaced-main}/kmwuimanager.ts (100%) rename web/src/engine/{main => namespaced-main}/kmwutils.ts (73%) rename web/src/engine/{main => namespaced-main}/osk/languageMenu.ts (100%) rename web/src/engine/{main => namespaced-main}/singleton.ts (100%) create mode 100644 web/src/engine/namespaced-main/tsconfig.json create mode 100644 web/src/engine/osk/build-bundler.js create mode 100755 web/src/engine/osk/build.sh create mode 100644 web/src/engine/osk/src/banner/banner.ts create mode 100644 web/src/engine/osk/src/banner/bannerView.ts create mode 100644 web/src/engine/osk/src/buttonClassNames.ts create mode 100644 web/src/engine/osk/src/components/emptyView.ts create mode 100644 web/src/engine/osk/src/components/helpPageView.ts create mode 100644 web/src/engine/osk/src/components/keyboardView.interface.ts create mode 100644 web/src/engine/osk/src/components/oskViewComponent.interface.ts create mode 100644 web/src/engine/osk/src/components/resizeBar.ts create mode 100644 web/src/engine/osk/src/components/titleBar.ts create mode 100644 web/src/engine/osk/src/config/commonConfiguration.ts create mode 100644 web/src/engine/osk/src/config/embeddedGestureConfig.ts create mode 100644 web/src/engine/osk/src/config/oskResourcePathConfiguration.interface.ts create mode 100644 web/src/engine/osk/src/config/viewConfiguration.ts create mode 100644 web/src/engine/osk/src/fontSizeUtils.ts create mode 100644 web/src/engine/osk/src/globehint.interface.ts create mode 100644 web/src/engine/osk/src/index.ts create mode 100644 web/src/engine/osk/src/input/event-interpreter/inputEventEngine.ts create mode 100644 web/src/engine/osk/src/input/event-interpreter/mouseEventEngine.ts create mode 100644 web/src/engine/osk/src/input/event-interpreter/touchEventEngine.ts create mode 100644 web/src/engine/osk/src/input/event-interpreter/uiTouchHandlerBase.ts create mode 100644 web/src/engine/osk/src/input/gestures/browser/keytip.ts create mode 100644 web/src/engine/osk/src/input/gestures/browser/oskSubKey.ts create mode 100644 web/src/engine/osk/src/input/gestures/browser/pendingLongpress.ts create mode 100644 web/src/engine/osk/src/input/gestures/browser/pendingMultiTap.ts create mode 100644 web/src/engine/osk/src/input/gestures/browser/subkeyPopup.ts create mode 100644 web/src/engine/osk/src/input/gestures/pendingGesture.interface.ts create mode 100644 web/src/engine/osk/src/input/gestures/realizedGesture.interface.ts create mode 100644 web/src/engine/osk/src/input/inputEventCoordinate.ts create mode 100644 web/src/engine/osk/src/input/mouseDragOperation.ts create mode 100644 web/src/engine/osk/src/keyElement.ts create mode 100644 web/src/engine/osk/src/keyboard-layout/oskBaseKey.ts create mode 100644 web/src/engine/osk/src/keyboard-layout/oskKey.ts create mode 100644 web/src/engine/osk/src/keyboard-layout/oskLayer.ts create mode 100644 web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts create mode 100644 web/src/engine/osk/src/keyboard-layout/oskRow.ts create mode 100644 web/src/engine/osk/src/keytip.interface.ts create mode 100644 web/src/engine/osk/src/lengthStyle.ts create mode 100644 web/src/engine/osk/src/screenUtils.ts create mode 100644 web/src/engine/osk/src/specialCharacters.ts create mode 100644 web/src/engine/osk/src/views/activator.ts create mode 100644 web/src/engine/osk/src/views/anchoredOskView.ts create mode 100644 web/src/engine/osk/src/views/floatingOskCookie.ts create mode 100644 web/src/engine/osk/src/views/floatingOskView.ts create mode 100644 web/src/engine/osk/src/views/inlinedOskView.ts create mode 100644 web/src/engine/osk/src/views/oskView.ts create mode 100644 web/src/engine/osk/src/views/simpleActivator.ts create mode 100644 web/src/engine/osk/src/views/touchEventPromiseMap.ts create mode 100644 web/src/engine/osk/src/views/twoStateActivator.ts create mode 100644 web/src/engine/osk/src/visualKeyboard.ts create mode 100644 web/src/engine/osk/tsconfig.json create mode 100644 web/src/engine/package-cache/README.md create mode 100644 web/src/engine/package-cache/build-bundler.js create mode 100755 web/src/engine/package-cache/build.sh create mode 100644 web/src/engine/package-cache/src/cloud/index.ts create mode 100644 web/src/engine/package-cache/src/cloud/queryEngine.ts create mode 100644 web/src/engine/package-cache/src/cloud/requesterInterface.ts create mode 100644 web/src/engine/package-cache/src/domCloudRequester.ts create mode 100644 web/src/engine/package-cache/src/index.ts create mode 100644 web/src/engine/package-cache/src/keyboardRequisitioner.ts create mode 100644 web/src/engine/package-cache/src/keyboardStub.ts create mode 100644 web/src/engine/package-cache/src/modelCache.ts create mode 100644 web/src/engine/package-cache/src/nodeCloudRequester.ts create mode 100644 web/src/engine/package-cache/src/stubAndKeyboardCache.ts create mode 100644 web/src/engine/package-cache/tsconfig.json create mode 100644 web/src/engine/paths/build-bundler.js create mode 100755 web/src/engine/paths/build.sh create mode 100644 web/src/engine/paths/src/index.ts create mode 100644 web/src/engine/paths/src/optionSpec.interface.ts create mode 100644 web/src/engine/paths/src/pathConfiguration.ts create mode 100644 web/src/engine/paths/tsconfig.json delete mode 100644 web/src/test/auto/CI.conf.js delete mode 100644 web/src/test/auto/cases/attachmentAPI.js delete mode 100644 web/src/test/auto/cases/basics.js delete mode 100644 web/src/test/auto/cases/engine.js delete mode 100644 web/src/test/auto/cases/engine_chirality.js delete mode 100644 web/src/test/auto/cases/events.js delete mode 100644 web/src/test/auto/cases/text_selection.js create mode 100644 web/src/test/auto/dom/CI.conf.cjs create mode 100644 web/src/test/auto/dom/base.conf.cjs create mode 100644 web/src/test/auto/dom/cases/dom-utils/cookies.js rename web/src/test/auto/{cases => dom/cases/element-wrappers}/target_mocks.js (86%) create mode 100644 web/src/test/auto/dom/cases/osk/activation.js create mode 100644 web/src/test/auto/dom/cases/osk/events.js create mode 100644 web/src/test/auto/dom/cases/packages/cloudQueries.js create mode 100644 web/src/test/auto/dom/cases/packages/domCloudRequester.js create mode 100644 web/src/test/auto/dom/cases/packages/keyboardRequisitioner.js rename web/src/test/auto/{ => dom}/cases/test_config.js (100%) create mode 100644 web/src/test/auto/dom/kbdLoader.mjs create mode 100644 web/src/test/auto/dom/manual.conf.cjs create mode 100644 web/src/test/auto/dom/readme.md rename web/src/test/auto/{ => dom}/test_init_check.js (89%) create mode 100644 web/src/test/auto/dom/test_utils.js create mode 100644 web/src/test/auto/dom/timedPromise.mjs create mode 100644 web/src/test/auto/headless/osk/activation.js create mode 100644 web/src/test/auto/headless/osk/lengthStyles.js create mode 100644 web/src/test/auto/headless/packages/cloudQueries.js create mode 100644 web/src/test/auto/headless/packages/keyboardRequisitioner.js create mode 100644 web/src/test/auto/headless/packages/keyboardStub.js create mode 100644 web/src/test/auto/headless/packages/nodeCloudRequester.js create mode 100644 web/src/test/auto/headless/packages/stubAndKeyboardCache.js create mode 100644 web/src/test/auto/headless/paths/pathConfiguration.js create mode 100644 web/src/test/auto/integrated/CI.conf.cjs rename web/src/test/auto/{base.conf.js => integrated/base.conf.cjs} (69%) create mode 100644 web/src/test/auto/integrated/cases/attachmentAPI.js create mode 100644 web/src/test/auto/integrated/cases/basics.js rename web/src/test/auto/{ => integrated}/cases/element_interfaces.js (93%) create mode 100644 web/src/test/auto/integrated/cases/engine.js create mode 100644 web/src/test/auto/integrated/cases/engine_chirality.js create mode 100644 web/src/test/auto/integrated/cases/events.js create mode 100644 web/src/test/auto/integrated/cases/test_config.js create mode 100644 web/src/test/auto/integrated/cases/text_selection.js create mode 100644 web/src/test/auto/integrated/manual.conf.cjs rename web/src/test/auto/{ => integrated}/modernizr.config.json (100%) create mode 100644 web/src/test/auto/integrated/test_init_check.js create mode 100644 web/src/test/auto/integrated/test_utils.js create mode 100644 web/src/test/auto/resources/query-mock-results/@dz.js.fixture create mode 100644 web/src/test/auto/resources/query-mock-results/khmer_angkor.hand-edited.js.fixture create mode 100644 web/src/test/auto/resources/query-mock-results/languages.js.fixture create mode 100644 web/src/test/auto/resources/query-mock-results/readme.md create mode 100644 web/src/test/auto/resources/query-mock-results/sil_cameroon_azerty.js.fixture create mode 100644 web/src/test/auto/resources/query-mock-results/sil_euro_latin@no_sv.js.fixture delete mode 100644 web/src/test/auto/test_utils.js create mode 100644 web/src/test/manual/web/osk/kbdLoader.mjs create mode 100644 web/src/test/manual/web/osk/scratchspace/commands.js create mode 100644 web/src/test/manual/web/osk/scratchspace/index.css create mode 100644 web/src/test/manual/web/osk/scratchspace/index.html create mode 100644 web/src/test/manual/web/osk/scratchspace/setup.mjs create mode 100644 web/src/tsconfig.all.json diff --git a/android/KMEA/app/src/main/assets/android-host.js b/android/KMEA/app/src/main/assets/android-host.js index 1bfcb0b1d6..4fc06a829c 100644 --- a/android/KMEA/app/src/main/assets/android-host.js +++ b/android/KMEA/app/src/main/assets/android-host.js @@ -28,9 +28,9 @@ function init() { keyman.init({'app':device,'fonts':'packages/',root:'./'}); keyman.util.setOption('attachType','manual'); keyman.oninserttext = insertText; - keyman.showKeyboardList = showMenu; - keyman.menuKeyUp = menuKeyUp; - keyman.hideKeyboard = hideKeyboard; + window.showKeyboardList = showMenu; + window.hideKeyboard = hideKeyboard; + window.menuKeyUp = menuKeyUp; keyman.getOskHeight = getOskHeight; keyman.getOskWidth = getOskWidth; keyman.beepKeyboard = beepKeyboard; @@ -170,10 +170,8 @@ function deregisterModel(modelID) { function enableSuggestions(model, mayPredict, mayCorrect) { // Set the options first so that KMW's ModelManager can properly handle model enablement states // the moment we actually register the new model. - keyman.osk.banner.setOptions({ - 'mayPredict': mayPredict, - 'mayCorrect': mayCorrect - }); + keyman.core.languageProcessor.mayPredict = mayPredict; + keyman.core.languageProcessor.mayCorrect = mayCorrect; registerModel(model); } @@ -186,7 +184,7 @@ function setBannerOptions(mayPredict) { function registerModel(model) { //window.console.log('registerModel: ' + model); - keyman.registerModel(model); + keyman.addModel(model); } function resetContext() { diff --git a/common/models/templates/build-bundler.js b/common/models/templates/build-bundler.js new file mode 100644 index 0000000000..49693c0d2a --- /dev/null +++ b/common/models/templates/build-bundler.js @@ -0,0 +1,33 @@ +/* + * Note: while this file is not meant to exist long-term, it provides a nice + * low-level proof-of-concept for esbuild bundling of the various Web submodules. + * + * Add some extra code at the end of src/index.ts and run it to verify successful bundling! + */ + +import esbuild from 'esbuild'; +import { spawn } from 'child_process'; + +await esbuild.build({ + bundle: true, + sourcemap: true, + format: "esm", + nodePaths: ['..'], + entryPoints: ['build/obj/index.js'], + outfile: "build/lib/index.mjs", + tsconfig: 'tsconfig.json', + target: "es5" +}); + + +// Bundled CommonJS (classic Node) module version +esbuild.buildSync({ + bundle: true, + sourcemap: true, + format: "cjs", + nodePaths: ['..'], + entryPoints: ['build/obj/index.js'], + outfile: "build/lib/index.cjs", + tsconfig: 'tsconfig.json', + target: "es5" +}); \ No newline at end of file diff --git a/common/models/templates/build.sh b/common/models/templates/build.sh new file mode 100755 index 0000000000..3ff6c5be89 --- /dev/null +++ b/common/models/templates/build.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# Compile our sourcemap-path remapping module for use by Web builds, releases, etc. +# +set -eu + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(greadlink -f "${BASH_SOURCE[0]}" 2>/dev/null || readlink -f "${BASH_SOURCE[0]}")" +. "$(dirname "$THIS_SCRIPT")/../../../resources/build/build-utils.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" + +# This script runs from its own folder +cd "$(dirname "$THIS_SCRIPT")" + +################################ Main script ################################ + +builder_describe "Builds the predictive-text model template implementation module" \ + "@../../web/keyman-version" \ + "@../wordbreakers" \ + "clean" \ + "configure" \ + "build" \ + "test" + +builder_describe_outputs \ + configure /node_modules \ + build build/lib/index.mjs + +builder_parse "$@" + +### CONFIGURE ACTIONS + +if builder_start_action configure; then + verify_npm_setup + builder_finish_action success configure +fi + +### CLEAN ACTIONS + +if builder_start_action clean; then + rm -rf build/ + builder_finish_action success clean +fi + +### BUILD ACTIONS + +if builder_start_action build; then + npm run tsc -- -b + node build-bundler.js + + # Declaration bundling. + npm run tsc -- --emitDeclarationOnly --outFile ./build/lib/index.d.ts + + builder_finish_action success build +fi + +if builder_start_action test; then + npm run mocha -- --require test/helpers.js --recursive test + + builder_finish_action success test +fi \ No newline at end of file diff --git a/common/models/templates/package.json b/common/models/templates/package.json index 67699e342a..f28557cdd1 100644 --- a/common/models/templates/package.json +++ b/common/models/templates/package.json @@ -16,8 +16,19 @@ ], "homepage": "https://github.com/keymanapp/keyman#readme", "license": "MIT", - "main": "index.js", - "types": "index.d.ts", + "main": "./build/obj/index.js", + "types": "./build/obj/index.d.ts", + "exports": { + ".": "./build/obj/index.js", + "./lib": { + "types": "./build/lib/index.d.ts", + "import": "./build/lib/index.mjs", + "require": "./build/lib/index.cjs" + }, + "./obj/*.js": "./build/obj/*.js" + }, + + "type": "module", "directories": { "test": "test" }, @@ -32,8 +43,9 @@ "url": "git+https://github.com/keymanapp/keyman.git" }, "scripts": { - "build": "tsc", - "pretest": "tsc -b tsconfig.bundled.json", + "build": "gosh ./build.sh", + "tsc": "tsc", + "mocha": "mocha", "test": "mocha -r test/helpers.js" }, "bugs": { @@ -50,6 +62,9 @@ "typescript": "^4.9.5" }, "dependencies": { - "@keymanapp/models-wordbreakers": "*" + "@keymanapp/keyman-version": "*", + "@keymanapp/models-types": "*", + "@keymanapp/models-wordbreakers": "*", + "@keymanapp/resources-gosh": "*" } } diff --git a/common/models/templates/src/common.ts b/common/models/templates/src/common.ts index 9117ec7d6f..9f3eae1dc1 100644 --- a/common/models/templates/src/common.ts +++ b/common/models/templates/src/common.ts @@ -1,148 +1,151 @@ -namespace models { - export const SENTINEL_CODE_UNIT = '\uFDD0'; - - export function applyTransform(transform: Transform, context: Context): Context { - // First, get the current context - let fullLeftContext = context.left || ''; - let lLen = fullLeftContext.kmwLength(); - let lDel = lLen < transform.deleteLeft ? lLen : transform.deleteLeft; - - let leftContext = fullLeftContext.kmwSubstr(0, lLen - lDel) + (transform.insert || ''); - - let fullRightContext = context.right || ''; - let rLen = fullRightContext.kmwLength(); - let rDel = rLen < transform.deleteRight ? rLen : transform.deleteRight; - - let rightContext = fullRightContext.kmwSubstr(rDel); - - return { - left: leftContext, - right: rightContext, - startOfBuffer: context.startOfBuffer, - endOfBuffer: context.endOfBuffer, - casingForm: context.casingForm - }; - } +// Allows the kmwstring bindings to resolve. +import { extendString } from "@keymanapp/web-utils"; - /** - * Merges two Transforms as if they were applied to a `Context` successively. - * @param first - * @param second - */ - export function buildMergedTransform(first: Transform, second: Transform): Transform { - // These exist to avoid parameter mutation. - let mergedFirstInsert: string = first.insert; - let mergedSecondDelete: number = second.deleteLeft; - - // The 'fun' case: the second Transform wants to delete something from the first. - if(second.deleteLeft) { - let firstLength = first.insert.kmwLength(); - if(firstLength <= second.deleteLeft) { - mergedFirstInsert = ''; - mergedSecondDelete = second.deleteLeft - firstLength; - } else { - mergedFirstInsert = first.insert.kmwSubstr(0, firstLength - second.deleteLeft); - mergedSecondDelete = 0; - } - } +extendString(); - return { - insert: mergedFirstInsert + second.insert, - deleteLeft: first.deleteLeft + mergedSecondDelete, - // As `first` would affect the context before `second` could take effect, - // this is the correct way to merge `deleteRight`. - deleteRight: (first.deleteRight || 0) + (second.deleteRight || 0) - } - } +export const SENTINEL_CODE_UNIT = '\uFDD0'; - /** - * Checks whether or not the specified UCS-2 character corresponds to a UTF-16 high surrogate. - * - * @param char A single JavaScript (UCS-2) char corresponding to a single code unit. - */ - export function isHighSurrogate(char: string): boolean; - /** - * Checks whether or not the specified UCS-2 character corresponds to a UTF-16 high surrogate. - * - * @param codeUnit A code unit corresponding to a single UCS-2 char. - */ - export function isHighSurrogate(codeUnit: number): boolean; - export function isHighSurrogate(codeUnit: string|number): boolean { - if(typeof codeUnit == 'string') { - codeUnit = codeUnit.charCodeAt(0); - } +export function applyTransform(transform: Transform, context: Context): Context { + // First, get the current context + let fullLeftContext = context.left || ''; + let lLen = fullLeftContext.kmwLength(); + let lDel = lLen < transform.deleteLeft ? lLen : transform.deleteLeft; - return codeUnit >= 0xD800 && codeUnit <= 0xDBFF; - } + let leftContext = fullLeftContext.kmwSubstr(0, lLen - lDel) + (transform.insert || ''); - /** - * Checks whether or not the specified UCS-2 character corresponds to a UTF-16 low surrogate. - * - * @param char A single JavaScript (UCS-2) char corresponding to a single code unit. - */ - export function isLowSurrogate(char: string): boolean; - /** - * Checks whether or not the specified UCS-2 character corresponds to a UTF-16 low surrogate. - * - * @param codeUnit A code unit corresponding to a single UCS-2 char. - */ - export function isLowSurrogate(codeUnit: number): boolean; - export function isLowSurrogate(codeUnit: string|number): boolean { - if(typeof codeUnit == 'string') { - codeUnit = codeUnit.charCodeAt(0); + let fullRightContext = context.right || ''; + let rLen = fullRightContext.kmwLength(); + let rDel = (rLen < (transform.deleteRight ?? 0)) ? rLen : (transform.deleteRight ?? 0); + + let rightContext = fullRightContext.kmwSubstr(rDel); + + return { + left: leftContext, + right: rightContext, + startOfBuffer: context.startOfBuffer, + endOfBuffer: context.endOfBuffer, + casingForm: context.casingForm + }; +} + +/** + * Merges two Transforms as if they were applied to a `Context` successively. + * @param first + * @param second + */ +export function buildMergedTransform(first: Transform, second: Transform): Transform { + // These exist to avoid parameter mutation. + let mergedFirstInsert: string = first.insert; + let mergedSecondDelete: number = second.deleteLeft; + + // The 'fun' case: the second Transform wants to delete something from the first. + if(second.deleteLeft) { + let firstLength = first.insert.kmwLength(); + if(firstLength <= second.deleteLeft) { + mergedFirstInsert = ''; + mergedSecondDelete = second.deleteLeft - firstLength; + } else { + mergedFirstInsert = first.insert.kmwSubstr(0, firstLength - second.deleteLeft); + mergedSecondDelete = 0; } + } - return codeUnit >= 0xDC00 && codeUnit <= 0xDFFF; + return { + insert: mergedFirstInsert + second.insert, + deleteLeft: first.deleteLeft + mergedSecondDelete, + // As `first` would affect the context before `second` could take effect, + // this is the correct way to merge `deleteRight`. + deleteRight: (first.deleteRight || 0) + (second.deleteRight || 0) } +} - export function isSentinel(char: string): boolean { - return char == models.SENTINEL_CODE_UNIT; +/** + * Checks whether or not the specified UCS-2 character corresponds to a UTF-16 high surrogate. + * + * @param char A single JavaScript (UCS-2) char corresponding to a single code unit. + */ +export function isHighSurrogate(char: string): boolean; +/** + * Checks whether or not the specified UCS-2 character corresponds to a UTF-16 high surrogate. + * + * @param codeUnit A code unit corresponding to a single UCS-2 char. + */ +export function isHighSurrogate(codeUnit: number): boolean; +export function isHighSurrogate(codeUnit: string|number): boolean { + if(typeof codeUnit == 'string') { + codeUnit = codeUnit.charCodeAt(0); } + return codeUnit >= 0xD800 && codeUnit <= 0xDBFF; +} + /** - * Builds a Suggestion based on a Transform corresponding to a predictive-text op. - * - * Assumes that the Transform's `insert` property represents a completed word, - * as models generally delete the whole prefix, replacing it with the full lexical entry. - * @param transform - */ - export function transformToSuggestion(transform: Transform): Suggestion; - export function transformToSuggestion(transform: Transform, p: number): WithOutcome; - export function transformToSuggestion(transform: Transform, p?: number): Outcome { - let suggestion: Outcome = { - transform: transform, - transformId: transform.id, - displayAs: transform.insert - }; - - if(p === 0 || p) { - suggestion.p = p; - } - return suggestion; + * Checks whether or not the specified UCS-2 character corresponds to a UTF-16 low surrogate. + * + * @param char A single JavaScript (UCS-2) char corresponding to a single code unit. + */ +export function isLowSurrogate(char: string): boolean; +/** + * Checks whether or not the specified UCS-2 character corresponds to a UTF-16 low surrogate. + * + * @param codeUnit A code unit corresponding to a single UCS-2 char. + */ +export function isLowSurrogate(codeUnit: number): boolean; +export function isLowSurrogate(codeUnit: string|number): boolean { + if(typeof codeUnit == 'string') { + codeUnit = codeUnit.charCodeAt(0); + } + + return codeUnit >= 0xDC00 && codeUnit <= 0xDFFF; +} + +export function isSentinel(char: string): boolean { + return char == SENTINEL_CODE_UNIT; +} + +/** + * Builds a Suggestion based on a Transform corresponding to a predictive-text op. + * + * Assumes that the Transform's `insert` property represents a completed word, + * as models generally delete the whole prefix, replacing it with the full lexical entry. + * @param transform + */ +export function transformToSuggestion(transform: Transform): Suggestion; +export function transformToSuggestion(transform: Transform, p: number): WithOutcome; +export function transformToSuggestion(transform: Transform, p?: number): Outcome { + let suggestion: Outcome = { + transform: transform, + transformId: transform.id, + displayAs: transform.insert + }; + + if(p === 0 || p) { + suggestion.p = p; } + return suggestion; +} - export function defaultApplyCasing(casing: CasingForm, text: string): string { - switch(casing) { - case 'lower': - return text.toLowerCase(); - case 'upper': - return text.toUpperCase(); - case 'initial': - // The length of the first code unit, as measured in code points. - let headUnitLength = 1; - - // Is the first character a high surrogate, indicating possible use of UTF-16 - // surrogate pairs? Also, is the string long enough for there to BE a pair? - if(text.length > 1 && isHighSurrogate(text.charAt(0))) { - // It's possible, so now we check for low surrogates. - if(isLowSurrogate(text.charCodeAt(1))) { - // We have a surrogate pair; this pair is the 'first' character. - headUnitLength = 2; - } +export function defaultApplyCasing(casing: CasingForm, text: string): string { + switch(casing) { + case 'lower': + return text.toLowerCase(); + case 'upper': + return text.toUpperCase(); + case 'initial': + // The length of the first code unit, as measured in code points. + let headUnitLength = 1; + + // Is the first character a high surrogate, indicating possible use of UTF-16 + // surrogate pairs? Also, is the string long enough for there to BE a pair? + if(text.length > 1 && isHighSurrogate(text.charAt(0))) { + // It's possible, so now we check for low surrogates. + if(isLowSurrogate(text.charCodeAt(1))) { + // We have a surrogate pair; this pair is the 'first' character. + headUnitLength = 2; } + } - // Capitalizes the first code unit of the string, leaving the rest intact. - return text.substring(0, headUnitLength).toUpperCase() .concat(text.substring(headUnitLength)); - } + // Capitalizes the first code unit of the string, leaving the rest intact. + return text.substring(0, headUnitLength).toUpperCase() .concat(text.substring(headUnitLength)); } } diff --git a/common/models/templates/src/index.ts b/common/models/templates/src/index.ts index a20b43f602..f828066846 100644 --- a/common/models/templates/src/index.ts +++ b/common/models/templates/src/index.ts @@ -1,7 +1,22 @@ -/// -/// +import { + SENTINEL_CODE_UNIT, applyTransform, buildMergedTransform, isHighSurrogate, isLowSurrogate, isSentinel, + transformToSuggestion, defaultApplyCasing +} from "./common.js"; +import PriorityQueue, { Comparator } from "./priority-queue.js"; +import QuoteBehavior from "./quote-behavior.js"; +import { Tokenization, tokenize, getLastPreCaretToken, wordbreak } from "./tokenization.js"; +import TrieModel, { TrieModelOptions } from "./trie-model.js"; -// Add all namespaces defined here to the global scope: -if (typeof module != 'undefined' && typeof module.exports != 'undefined') { - module.exports['models'] = models; -} +import { extendString } from "@keymanapp/web-utils"; + +// This package requires our string-extension functions. +extendString(); + +export { + SENTINEL_CODE_UNIT, applyTransform, buildMergedTransform, isHighSurrogate, isLowSurrogate, isSentinel, + transformToSuggestion, defaultApplyCasing, // "common.ts" + PriorityQueue, Comparator, // "priority-queue.ts" + QuoteBehavior, // "quote-behavior.ts", + Tokenization, tokenize, getLastPreCaretToken, wordbreak, // "tokenization.ts" + TrieModel, TrieModelOptions // "trie-model.ts" +}; diff --git a/common/models/templates/src/priority-queue.ts b/common/models/templates/src/priority-queue.ts index c372d881c5..8ecae0533f 100644 --- a/common/models/templates/src/priority-queue.ts +++ b/common/models/templates/src/priority-queue.ts @@ -4,216 +4,215 @@ * Defines a mildly abstracted priority queue implementation. */ -namespace models { +/** + * Used to compare two instances of a type. + * @returns + * - value < 0 if `a` should come before `b` + * - value > 0 if `b` should come before `a` + * - 0 if they should be treated equally. + */ +export type Comparator = (a: Type, b: Type) => number; + +export default class PriorityQueue { + + private comparator: Comparator; + private heap: Type[]; + /** - * Used to compare two instances of a type. - * @returns - * - value < 0 if `a` should come before `b` - * - value > 0 if `b` should come before `a` - * - 0 if they should be treated equally. + * Constructs an empty priority queue. + * @param comparator A `Comparator` returning negative values when and only when + * the first parameter should precede the second parameter. */ - export type Comparator = (a: Type, b: Type) => number; - - export class PriorityQueue { - - private comparator: Comparator; - private heap: Type[]; - - /** - * Constructs an empty priority queue. - * @param comparator A `Comparator` returning negative values when and only when - * the first parameter should precede the second parameter. - */ - constructor(comparator: Comparator, initialEntries: Type[] = []) { - // TODO: We may wish to allow options specifying a limit or threshold for adding - // items to the priority queue. Possibly both. - // - // When that time comes, consider a min-max heap. - // https://en.wikipedia.org/wiki/Min-max_heap - this.comparator = comparator; - - this.heap = Array.from(initialEntries); - this.heapify(); - } + constructor(comparator: Comparator, initialEntries: Type[] = []) { + // TODO: We may wish to allow options specifying a limit or threshold for adding + // items to the priority queue. Possibly both. + // + // When that time comes, consider a min-max heap. + // https://en.wikipedia.org/wiki/Min-max_heap + this.comparator = comparator; + + this.heap = Array.from(initialEntries); + this.heapify(); + } - private static leftChildIndex(index: number): number { - return index * 2 + 1; - } + private static leftChildIndex(index: number): number { + return index * 2 + 1; + } - private static rightChildIndex(index: number): number { - return index * 2 + 2; - } + private static rightChildIndex(index: number): number { + return index * 2 + 2; + } - private static parentIndex(index: number): number { - return Math.floor((index-1)/2); - } + private static parentIndex(index: number): number { + return Math.floor((index-1)/2); + } - /** - * Maintains internal state, rearranging the internal state until all heap constraints - * are properly satisfied. - * - O(N) when 'heapifying' the whole heap - * - O(N) worst-case for partial heap operations (as part of an enqueueAll) - *

- */ - private heapify(): void; - private heapify(start: number, end: number): void; - private heapify(start?: number, end?: number): void { - if(start == undefined || end == undefined) { - this.heapify(0, this.count - 1); - } + /** + * Maintains internal state, rearranging the internal state until all heap constraints + * are properly satisfied. + * - O(N) when 'heapifying' the whole heap + * - O(N) worst-case for partial heap operations (as part of an enqueueAll) + *

+ */ + private heapify(): void; + private heapify(start: number, end: number): void; + private heapify(start?: number, end?: number): void { + if(start == undefined || end == undefined) { + this.heapify(0, this.count - 1); + return; + } - // Use of 'indices' here is a bit of a customization. - // At the cost of (temporary) extra storage space, we can more efficiently enqueue - // multiple elements simultaneously. - let queuedIndices: number[] = []; - let lastParent = -1; - - for(let i = end; i >= start; i--) { - let parent = PriorityQueue.parentIndex(i); - if(this.siftDown(i) && parent < start && lastParent != parent) { - // We only need to queue examination for a heap node if its children have changed - // and it isn't already being examined. - queuedIndices.push(parent); - lastParent = parent; - } + // Use of 'indices' here is a bit of a customization. + // At the cost of (temporary) extra storage space, we can more efficiently enqueue + // multiple elements simultaneously. + let queuedIndices: number[] = []; + let lastParent = -1; + + for(let i = end; i >= start; i--) { + let parent = PriorityQueue.parentIndex(i); + if(this.siftDown(i) && parent < start && lastParent != parent) { + // We only need to queue examination for a heap node if its children have changed + // and it isn't already being examined. + queuedIndices.push(parent); + lastParent = parent; } + } - lastParent = -1; - while(queuedIndices.length > 0) { - let index = queuedIndices.shift(); - let parent = PriorityQueue.parentIndex(index); - if(this.siftDown(index) && parent >= 0 && lastParent != parent) { - // We only need to queue examination for a heap node if its children have changed. - queuedIndices.push(parent); - lastParent = parent; - } + lastParent = -1; + while(queuedIndices.length > 0) { + let index = queuedIndices.shift() as number; + let parent = PriorityQueue.parentIndex(index); + if(this.siftDown(index) && parent >= 0 && lastParent != parent) { + // We only need to queue examination for a heap node if its children have changed. + queuedIndices.push(parent); + lastParent = parent; } } + } - /** - * Returns the number of elements currently held by the priority queue. - */ - get count(): number { - return this.heap.length; - } + /** + * Returns the number of elements currently held by the priority queue. + */ + get count(): number { + return this.heap.length; + } - /** - * Returns the highest-priority item within the priority queue. - *

- * Is O(1). - */ - peek() { - return this.heap[0]; // undefined if it doesn't exist... which is completely correct. - } + /** + * Returns the highest-priority item within the priority queue. + *

+ * Is O(1). + */ + peek() { + return this.heap[0]; // undefined if it doesn't exist... which is completely correct. + } - /** - * Inserts a new element into the priority queue, placing it in order. - *

- * Is O(log N), where N = # of items in the priority queue. - * @param element - */ - enqueue(element: Type) { - let index = this.heap.length; - this.heap.push(element); - - let parent = PriorityQueue.parentIndex; - let parentIndex = parent(index); - while(index !== 0 && this.comparator(this.heap[index], this.heap[parentIndex]) < 0) { - let a = this.heap[index]; - this.heap[index] = this.heap[parentIndex]; - this.heap[parentIndex] = a; - - index = parentIndex; - parentIndex = parent(index); - } + /** + * Inserts a new element into the priority queue, placing it in order. + *

+ * Is O(log N), where N = # of items in the priority queue. + * @param element + */ + enqueue(element: Type) { + let index = this.heap.length; + this.heap.push(element); + + let parent = PriorityQueue.parentIndex; + let parentIndex = parent(index); + while(index !== 0 && this.comparator(this.heap[index], this.heap[parentIndex]) < 0) { + let a = this.heap[index]; + this.heap[index] = this.heap[parentIndex]; + this.heap[parentIndex] = a; + + index = parentIndex; + parentIndex = parent(index); } + } - /** - * Efficiently batch-enqueues multiple elements. - * Worst-case is the _better_ of the following: - * - O(`elements.count` + `heap.count`) - large element counts will trigger in-place - * heap reconstruction. - * - O(`elements.count` * log(`heap.count`)) - logarithmic when elements.count << heap.count - * @param elements A group of elements to enqueue simultaneously. - */ - enqueueAll(elements: Type[]) { - if(elements.length == 0) { - return; - } - - let firstIndex = this.count - this.heap = this.heap.concat(elements); - let firstParent = PriorityQueue.parentIndex(firstIndex); - - // The 'parent' of index 0 will return -1, which is illegal. - this.heapify(firstParent >= 0 ? firstParent : 0, PriorityQueue.parentIndex(this.count-1)); + /** + * Efficiently batch-enqueues multiple elements. + * Worst-case is the _better_ of the following: + * - O(`elements.count` + `heap.count`) - large element counts will trigger in-place + * heap reconstruction. + * - O(`elements.count` * log(`heap.count`)) - logarithmic when elements.count << heap.count + * @param elements A group of elements to enqueue simultaneously. + */ + enqueueAll(elements: Type[]) { + if(elements.length == 0) { + return; } - /** - * Removes the highest-priority element from the queue, returning it. - *

- * Is O(log N), where N = number of items in the priority queue. - */ - dequeue(): Type { - if(this.count == 0) { - return undefined; - } + let firstIndex = this.count + this.heap = this.heap.concat(elements); + let firstParent = PriorityQueue.parentIndex(firstIndex); - const root = this.heap[0]; - let tail = this.heap.pop(); - if(this.heap.length > 0) { - this.heap[0] = tail; - this.siftDown(0); - } + // The 'parent' of index 0 will return -1, which is illegal. + this.heapify(firstParent >= 0 ? firstParent : 0, PriorityQueue.parentIndex(this.count-1)); + } - return root; + /** + * Removes the highest-priority element from the queue, returning it. + *

+ * Is O(log N), where N = number of items in the priority queue. + */ + dequeue(): Type | undefined { + if(this.count == 0) { + return undefined; } - /** - * Compares the entry at the specified index against its children, - * propagating it downward within the heap until heap requirements are specified. - *

- * Is O(log N), where N = number of items in the priority queue. - * - * @param index The index of the top-most node that must be examined - * for repositioning. - * @returns `true` if a swap occurred, `false` otherwise. - */ - private siftDown(index: number): boolean { - let leftIndex = PriorityQueue.leftChildIndex(index); - let rightIndex = PriorityQueue.rightChildIndex(index); - let topMostIndex = index; - - if(leftIndex < this.heap.length && this.comparator(this.heap[leftIndex], this.heap[topMostIndex]) < 0) { - topMostIndex = leftIndex; - } + const root = this.heap[0]; + let tail = this.heap.pop() as Type; + if(this.heap.length > 0) { + this.heap[0] = tail; + this.siftDown(0); + } - if(rightIndex < this.heap.length && this.comparator(this.heap[rightIndex], this.heap[topMostIndex]) < 0) { - topMostIndex = rightIndex; - } + return root; + } - if(topMostIndex != index) { - let a = this.heap[index]; - this.heap[index] = this.heap[topMostIndex]; - this.heap[topMostIndex] = a; + /** + * Compares the entry at the specified index against its children, + * propagating it downward within the heap until heap requirements are specified. + *

+ * Is O(log N), where N = number of items in the priority queue. + * + * @param index The index of the top-most node that must be examined + * for repositioning. + * @returns `true` if a swap occurred, `false` otherwise. + */ + private siftDown(index: number): boolean { + let leftIndex = PriorityQueue.leftChildIndex(index); + let rightIndex = PriorityQueue.rightChildIndex(index); + let topMostIndex = index; - this.siftDown(topMostIndex); - return true; - } else { - return false; - } + if(leftIndex < this.heap.length && this.comparator(this.heap[leftIndex], this.heap[topMostIndex]) < 0) { + topMostIndex = leftIndex; } - /** - * Returns an array containing all entries of the priority queue. - * Altering the returned array will not affect the queue, but mutating - * the array's elements can cause unintended side effects. - * - * This function makes no guarantees on the ordering of the returned elements; - * they will almost certainly be unsorted. - */ - toArray(): Type[] { - return Array.from(this.heap); + if(rightIndex < this.heap.length && this.comparator(this.heap[rightIndex], this.heap[topMostIndex]) < 0) { + topMostIndex = rightIndex; } + + if(topMostIndex != index) { + let a = this.heap[index]; + this.heap[index] = this.heap[topMostIndex]; + this.heap[topMostIndex] = a; + + this.siftDown(topMostIndex); + return true; + } else { + return false; + } + } + + /** + * Returns an array containing all entries of the priority queue. + * Altering the returned array will not affect the queue, but mutating + * the array's elements can cause unintended side effects. + * + * This function makes no guarantees on the ordering of the returned elements; + * they will almost certainly be unsorted. + */ + toArray(): Type[] { + return Array.from(this.heap); } -} +} \ No newline at end of file diff --git a/common/models/templates/src/quote-behavior.ts b/common/models/templates/src/quote-behavior.ts index 7a95d04c49..2ca7bbd04c 100644 --- a/common/models/templates/src/quote-behavior.ts +++ b/common/models/templates/src/quote-behavior.ts @@ -1,38 +1,38 @@ -namespace models { - export enum QuoteBehavior { - noQuotes = "no-quotes", - useQuotes = "use-quotes", - default = "default-quotes" - } - - // For an "enum/namespace merge". See https://www.typescriptlang.org/docs/handbook/declaration-merging.html#merging-namespaces-with-classes - export namespace QuoteBehavior { - /** - * Applies the specified quote behavior to the provided `Transform`, mutating it as appropriate. - * @param transform The Transform to be mutated - * @param punctuation The active `LexicalModelPunctuation` settings - * @param defaultTo The default quote behavior to use (in case the current value is `.default`) - */ - export function apply(behavior: QuoteBehavior, text: string, punctuation: LexicalModelPunctuation, defaultTo: QuoteBehavior): string { - if(defaultTo == QuoteBehavior.default || !defaultTo) { - throw "Specified quote behavior may be ambiguous - default behavior not specified (may not be .default)"; - } - - if(behavior == QuoteBehavior.default) { - behavior = defaultTo; - } - - switch(behavior) { - case QuoteBehavior.noQuotes: - return text; - case QuoteBehavior.useQuotes: - let {open, close} = punctuation.quotesForKeepSuggestion; - - // This part's simple enough, at least. - return open + text + close; - default: - throw "Unsupported quote behavior state detected; implementation missing!"; - } +export enum QuoteBehavior { + noQuotes = "no-quotes", + useQuotes = "use-quotes", + default = "default-quotes" +} + +// For an "enum/namespace merge". See https://www.typescriptlang.org/docs/handbook/declaration-merging.html#merging-namespaces-with-classes +export namespace QuoteBehavior { + /** + * Applies the specified quote behavior to the provided `Transform`, mutating it as appropriate. + * @param transform The Transform to be mutated + * @param punctuation The active `LexicalModelPunctuation` settings + * @param defaultTo The default quote behavior to use (in case the current value is `.default`) + */ + export function apply(behavior: QuoteBehavior, text: string, punctuation: LexicalModelPunctuation, defaultTo: QuoteBehavior): string { + if(defaultTo == QuoteBehavior.default || !defaultTo) { + throw "Specified quote behavior may be ambiguous - default behavior not specified (may not be .default)"; + } + + if(behavior == QuoteBehavior.default) { + behavior = defaultTo; + } + + switch(behavior) { + case QuoteBehavior.noQuotes: + return text; + case QuoteBehavior.useQuotes: + let {open, close} = punctuation.quotesForKeepSuggestion; + + // This part's simple enough, at least. + return open + text + close; + default: + throw "Unsupported quote behavior state detected; implementation missing!"; } } -} \ No newline at end of file +} + +export default QuoteBehavior; \ No newline at end of file diff --git a/common/models/templates/src/tokenization.ts b/common/models/templates/src/tokenization.ts index 9993050eab..2aa5c392e6 100644 --- a/common/models/templates/src/tokenization.ts +++ b/common/models/templates/src/tokenization.ts @@ -1,129 +1,127 @@ // While we _could_ define this within @keymanapp/models-wordbreakers instead, it's probably -// better to leave that package as _just_ the wordbreakers. - -namespace models { - export interface Tokenization { - /** - * An array of tokens to the left of the caret. If the caret is in the middle of a token, - * only the part to the left of the caret is included. - */ - left: USVString[], - - /** - * An array of tokens to the right of the caret. If the caret is in the middle of a token, - * only the part to the right of the caret is included. - */ - right: USVString[], - - /** - * A flag indicating whether or not the caret's position in the context caused a token - * to be split. If `true`, the last entry of `left` is from the same token as the first - * entry of `right`. - */ - caretSplitsToken: boolean - } +// better to leave that package as _just_ the wordbreakers. - export function tokenize(wordBreaker: WordBreakingFunction, context?: Partial): Tokenization { - context = context || { - left: undefined, - startOfBuffer: undefined, - endOfBuffer: undefined - }; +export interface Tokenization { + /** + * An array of tokens to the left of the caret. If the caret is in the middle of a token, + * only the part to the left of the caret is included. + */ + left: USVString[], - let leftSpans = wordBreaker(context.left || '') || []; - let rightSpans = wordBreaker(context.right || '') || []; + /** + * An array of tokens to the right of the caret. If the caret is in the middle of a token, + * only the part to the right of the caret is included. + */ + right: USVString[], - let leftTail: Span; - if(leftSpans.length > 0) { - leftTail = leftSpans[leftSpans.length - 1]; - } + /** + * A flag indicating whether or not the caret's position in the context caused a token + * to be split. If `true`, the last entry of `left` is from the same token as the first + * entry of `right`. + */ + caretSplitsToken: boolean +} + +export function tokenize(wordBreaker: WordBreakingFunction, context?: Partial): Tokenization { + context = context || { + left: undefined, + startOfBuffer: undefined, + endOfBuffer: undefined + }; + + let leftSpans = wordBreaker(context.left || '') || []; + let rightSpans = wordBreaker(context.right || '') || []; + + let leftTail: Span; + if(leftSpans.length > 0) { + leftTail = leftSpans[leftSpans.length - 1]; + } - // Handle any desired special handling for directly-pre-caret scenarios - where for this - // _specific_ context, we should not make a token division where one normally would exist otherwise. - // - // One notable example: word-final apostrophe is tokenized separate from preceding text, but - // word-internal apostrophe is treated as part of the same word (i.e, English contractions). - // But, if the user is editing text and the caret is directly after a caret, there's a notable - // chance they may in the middle of typing a contraction. Refer to - // https://github.com/keymanapp/keyman/issues/6572. - if(leftSpans.length > 1) { - const leftTailBase = leftSpans[leftSpans.length - 2]; - - // If the final two pre-caret spans are adjacent - without intervening whitespace... - if(leftTailBase.end == leftTail.start) { - // Ideal: if(leftTailBase is standard-char-class && leftTail is single-quote-class) - // But we don't have character class access here; it's all wordbreaker-function internal. - // Upon inspection of the wordbreaker data definitions... the single-quote-class is ONLY "'". - // So... we'll just be lazy for now and append the `'`. - if(leftTail.text == "'") { - let mergedSpan: Span = { - text: leftTailBase.text + leftTail.text, - start: leftTailBase.start, - end: leftTail.end, - length: leftTailBase.length + leftTail.length - }; - - leftSpans.pop(); // leftTail - leftSpans.pop(); // leftTailBase - leftSpans.push(mergedSpan); - leftTail = mergedSpan; // don't forget to update the `leftTail` Span! - } + // Handle any desired special handling for directly-pre-caret scenarios - where for this + // _specific_ context, we should not make a token division where one normally would exist otherwise. + // + // One notable example: word-final apostrophe is tokenized separate from preceding text, but + // word-internal apostrophe is treated as part of the same word (i.e, English contractions). + // But, if the user is editing text and the caret is directly after a caret, there's a notable + // chance they may in the middle of typing a contraction. Refer to + // https://github.com/keymanapp/keyman/issues/6572. + if(leftSpans.length > 1) { + const leftTailBase = leftSpans[leftSpans.length - 2]; + + // If the final two pre-caret spans are adjacent - without intervening whitespace... + if(leftTailBase.end == leftTail!.start) { + // Ideal: if(leftTailBase is standard-char-class && leftTail is single-quote-class) + // But we don't have character class access here; it's all wordbreaker-function internal. + // Upon inspection of the wordbreaker data definitions... the single-quote-class is ONLY "'". + // So... we'll just be lazy for now and append the `'`. + if(leftTail!.text == "'") { + let mergedSpan: Span = { + text: leftTailBase.text + leftTail!.text, + start: leftTailBase.start, + end: leftTail!.end, + length: leftTailBase.length + leftTail!.length + }; + + leftSpans.pop(); // leftTail + leftSpans.pop(); // leftTailBase + leftSpans.push(mergedSpan); + leftTail = mergedSpan; // don't forget to update the `leftTail` Span! } } + } - // With any 'detokenization' cases already handled, we may now begin to build the return object. - let tokenization: Tokenization = { - left: leftSpans.map(span => span.text), - right: rightSpans.map(span => span.text), - - // A default initialization of the value. - caretSplitsToken: false - }; - - // Now the hard part - determining whether or not the caret caused a token split. - if(leftSpans.length > 0 && rightSpans.length > 0) { - let rightHead = rightSpans[0]; - - // If tokenization includes all characters on each side of the caret, - // we have a good candidate for a caret-splitting scenario. - let leftSuffixWordbreak = leftTail.end != context.left.length; - let rightPrefixWordbreak = rightHead.start != 0; - - if(leftSuffixWordbreak || rightPrefixWordbreak) { - // Bypass the final test, as we already know the caret didn't split a token. - // (The tokenization process already removed characters between the two.) - return tokenization; - } + // With any 'detokenization' cases already handled, we may now begin to build the return object. + let tokenization: Tokenization = { + left: leftSpans.map(span => span.text), + right: rightSpans.map(span => span.text), - // Worth note - some languages don't use wordbreaking characters. So, a final check: - // - // Does the wordbreaker split a merge of the 'two center' tokens? - // If not, then the caret is responsible for the split. - if(wordBreaker(leftTail.text + rightHead.text).length == 1) { - tokenization.caretSplitsToken = true; - } - } + // A default initialization of the value. + caretSplitsToken: false + }; - return tokenization; - } + // Now the hard part - determining whether or not the caret caused a token split. + if(leftSpans.length > 0 && rightSpans.length > 0) { + let rightHead = rightSpans[0]; - /** - * Get the last word of the phrase before the caret or nothing. - * @param fullLeftContext the entire left context of the string. - */ - export function getLastPreCaretToken(wordBreaker: WordBreakingFunction, context: Context): string { - let tokenization = tokenize(wordBreaker, context); - if (tokenization.left.length > 0) { - return tokenization.left.pop(); + // If tokenization includes all characters on each side of the caret, + // we have a good candidate for a caret-splitting scenario. + let leftSuffixWordbreak = leftTail!.end != context.left!.length; + let rightPrefixWordbreak = rightHead.start != 0; + + if(leftSuffixWordbreak || rightPrefixWordbreak) { + // Bypass the final test, as we already know the caret didn't split a token. + // (The tokenization process already removed characters between the two.) + return tokenization; } - return ''; + // Worth note - some languages don't use wordbreaking characters. So, a final check: + // + // Does the wordbreaker split a merge of the 'two center' tokens? + // If not, then the caret is responsible for the split. + if(wordBreaker(leftTail!.text + rightHead.text).length == 1) { + tokenization.caretSplitsToken = true; + } } - // While it is currently identical to getLastWord, this may change in the future. - // It's best not to write ourselves into a corner on this one, as disambiguating later - // would likely be pretty painful. - export function wordbreak(wordBreaker: WordBreakingFunction, context: Context): USVString { - return getLastPreCaretToken(wordBreaker, context); + return tokenization; +} + +/** + * Get the last word of the phrase before the caret or nothing. + * @param fullLeftContext the entire left context of the string. + */ +export function getLastPreCaretToken(wordBreaker: WordBreakingFunction, context: Context): string { + let tokenization = tokenize(wordBreaker, context); + if (tokenization.left.length > 0) { + return tokenization.left.pop() as string; } + + return ''; +} + +// While it is currently identical to getLastWord, this may change in the future. +// It's best not to write ourselves into a corner on this one, as disambiguating later +// would likely be pretty painful. +export function wordbreak(wordBreaker: WordBreakingFunction, context: Context): USVString { + return getLastPreCaretToken(wordBreaker, context); } \ No newline at end of file diff --git a/common/models/templates/src/trie-model.ts b/common/models/templates/src/trie-model.ts index 4eb6b80afe..990ab61dc6 100644 --- a/common/models/templates/src/trie-model.ts +++ b/common/models/templates/src/trie-model.ts @@ -24,10 +24,14 @@ // Worth noting: we're starting to get quite a 'library' of common model/LMLayer functionality. // Should probably make a 'lm-utils' submodule. -/// -/// -/// -/// + +// Allows the kmwstring bindings to resolve. +import { extendString } from "@keymanapp/web-utils"; +import { default as defaultWordBreaker } from "@keymanapp/models-wordbreakers"; + +import { applyTransform, isHighSurrogate, isSentinel, SENTINEL_CODE_UNIT, transformToSuggestion } from "./common.js"; +import { getLastPreCaretToken } from "./tokenization.js"; +import PriorityQueue from "./priority-queue.js"; /** * @file trie-model.ts @@ -35,524 +39,510 @@ * Defines a simple word list (unigram) model. */ - namespace models { - /** Upper bound on the amount of suggestions to generate. */ - const MAX_SUGGESTIONS = 12; +/** Upper bound on the amount of suggestions to generate. */ +const MAX_SUGGESTIONS = 12; +/** + * Additional arguments to pass into the model, in addition to the model + * parameters themselves. + */ +export interface TrieModelOptions { /** - * Additional arguments to pass into the model, in addition to the model - * parameters themselves. + * How to break words in a phrase. */ - interface TrieModelOptions { - /** - * How to break words in a phrase. - */ - wordBreaker?: WordBreakingFunction; - /** - * This should simplify a search term into a key. - */ - searchTermToKey?: (searchTerm: string) => string; - - /** - * Indicates that the model's written form has 'casing' behavior. - */ - languageUsesCasing?: boolean; - - /** - * Specifies a function used to apply the language's casing rules to a word. - */ - applyCasing?: CasingFunction; - - /** - * Any punctuation to expose to the user. - */ - punctuation?: LexicalModelPunctuation; - } - + wordBreaker?: WordBreakingFunction; /** - * Used to determine the probability of an entry from the trie. + * This should simplify a search term into a key. */ - type TextWithProbability = { - text: string; - // TODO: use negative-log scaling instead? - p: number; // real-number weight, from 0 to 1 - } + searchTermToKey?: (searchTerm: string) => string; /** - * @class TrieModel - * - * Defines a trie-backed word list model, or the unigram model. - * Unigram models throw away all preceding words, and search - * for the next word exclusively. As such, they can perform simple - * prefix searches within words, however they are not very good - * at predicting the next word. + * Indicates that the model's written form has 'casing' behavior. */ - export class TrieModel implements LexicalModel { - configuration: Configuration; - private _trie: Trie; - readonly breakWords: WordBreakingFunction; - readonly punctuation?: LexicalModelPunctuation; - readonly languageUsesCasing?: boolean; - - readonly applyCasing?: CasingFunction; - - constructor(trieData: object, options: TrieModelOptions = {}) { - this.languageUsesCasing = options.languageUsesCasing; - this.applyCasing = options.applyCasing; - - this._trie = new Trie( - trieData['root'], - trieData['totalWeight'], - options.searchTermToKey as Wordform2Key || defaultSearchTermToKey - ); - this.breakWords = options.wordBreaker || getDefaultWordBreaker(); - this.punctuation = options.punctuation; - } + languageUsesCasing?: boolean; - configure(capabilities: Capabilities): Configuration { - return this.configuration = { - leftContextCodePoints: capabilities.maxLeftContextCodePoints, - rightContextCodePoints: capabilities.maxRightContextCodePoints - }; - } + /** + * Specifies a function used to apply the language's casing rules to a word. + */ + applyCasing?: CasingFunction; - toKey(text: USVString): USVString { - return this._trie.toKey(text); - } + /** + * Any punctuation to expose to the user. + */ + punctuation?: LexicalModelPunctuation; +} - predict(transform: Transform, context: Context): Distribution { - // Special-case the empty buffer/transform: return the top suggestions. - if (!transform.insert && !context.left && !context.right && context.startOfBuffer && context.endOfBuffer) { - return makeDistribution(this._trie.firstN(MAX_SUGGESTIONS).map(({text, p}) => ({ - transform: { - insert: text, - deleteLeft: 0 - }, - displayAs: text, - p: p - }))); - } +/** + * Used to determine the probability of an entry from the trie. + */ +type TextWithProbability = { + text: string; + // TODO: use negative-log scaling instead? + p: number; // real-number weight, from 0 to 1 +} - // Compute the results of the keystroke: - let newContext = models.applyTransform(transform, context); +/** + * @class TrieModel + * + * Defines a trie-backed word list model, or the unigram model. + * Unigram models throw away all preceding words, and search + * for the next word exclusively. As such, they can perform simple + * prefix searches within words, however they are not very good + * at predicting the next word. + */ +export default class TrieModel implements LexicalModel { + configuration?: Configuration; + private _trie: Trie; + readonly breakWords: WordBreakingFunction; + readonly punctuation?: LexicalModelPunctuation; + readonly languageUsesCasing?: boolean; + + readonly applyCasing?: CasingFunction; + + constructor(trieData: {root: Node, totalWeight: number}, options: TrieModelOptions = {}) { + this.languageUsesCasing = options.languageUsesCasing; + this.applyCasing = options.applyCasing; + + this._trie = new Trie( + trieData['root'], + trieData['totalWeight'], + options.searchTermToKey as Wordform2Key || defaultSearchTermToKey + ); + this.breakWords = options.wordBreaker || defaultWordBreaker; + this.punctuation = options.punctuation; + } - // Computes the different in word length after applying the transform above. - let leftDelOffset = transform.deleteLeft - transform.insert.kmwLength(); + configure(capabilities: Capabilities): Configuration { + return this.configuration = { + leftContextCodePoints: capabilities.maxLeftContextCodePoints, + rightContextCodePoints: capabilities.maxRightContextCodePoints ?? 0 + }; + } - // All text to the left of the cursor INCLUDING anything that has - // just been typed. - let prefix = models.getLastPreCaretToken(this.breakWords, newContext); + toKey(text: USVString): USVString { + return this._trie.toKey(text); + } - // Return suggestions from the trie. - return makeDistribution(this._trie.lookup(prefix).map(({text, p}) => - models.transformToSuggestion({ + predict(transform: Transform, context: Context): Distribution { + // Special-case the empty buffer/transform: return the top suggestions. + if (!transform.insert && !context.left && !context.right && context.startOfBuffer && context.endOfBuffer) { + return makeDistribution(this._trie.firstN(MAX_SUGGESTIONS).map(({text, p}) => ({ + transform: { insert: text, - // Delete whatever the prefix that the user wrote. - deleteLeft: leftDelOffset + prefix.kmwLength() - // Note: a separate capitalization/orthography engine can take this - // result and transform it as needed. + deleteLeft: 0 }, - p - ))); + displayAs: text, + p: p + }))); + } - /* Helper */ + // Compute the results of the keystroke: + let newContext = applyTransform(transform, context); - function makeDistribution(suggestions: WithOutcome[]): Distribution { - let distribution: Distribution = []; + // Computes the different in word length after applying the transform above. + let leftDelOffset = transform.deleteLeft - transform.insert.kmwLength(); - for(let s of suggestions) { - distribution.push({sample: s, p: s.p}); - } + // All text to the left of the cursor INCLUDING anything that has + // just been typed. + let prefix = getLastPreCaretToken(this.breakWords, newContext); - return distribution; + // Return suggestions from the trie. + return makeDistribution(this._trie.lookup(prefix).map(({text, p}) => + transformToSuggestion({ + insert: text, + // Delete whatever the prefix that the user wrote. + deleteLeft: leftDelOffset + prefix.kmwLength() + // Note: a separate capitalization/orthography engine can take this + // result and transform it as needed. + }, + p + ))); + + /* Helper */ + + function makeDistribution(suggestions: WithOutcome[]): Distribution { + let distribution: Distribution = []; + + for(let s of suggestions) { + distribution.push({sample: s, p: s.p}); } - } - get wordbreaker(): WordBreakingFunction { - return this.breakWords; + return distribution; } + } - public traverseFromRoot(): LexiconTraversal { - return new TrieModel.Traversal(this._trie['root'], ''); - } + get wordbreaker(): WordBreakingFunction { + return this.breakWords; + } - private static Traversal = class implements LexiconTraversal { - /** - * The lexical prefix corresponding to the current traversal state. - */ - prefix: String; - - /** - * The current traversal node. Serves as the 'root' of its own sub-Trie, - * and we cannot navigate back to its parent. - */ - root: Node; - - constructor(root: Node, prefix: string) { - this.root = root; - this.prefix = prefix; - } + public traverseFromRoot(): LexiconTraversal { + return new TrieModel.Traversal(this._trie['root'], ''); + } - *children(): Generator<{char: string, traversal: () => LexiconTraversal}> { - let root = this.root; - - if(root.type == 'internal') { - for(let entry of root.values) { - let entryNode = root.children[entry]; - - // UTF-16 astral plane check. - if(models.isHighSurrogate(entry)) { - // First code unit of a UTF-16 code point. - // For now, we'll just assume the second always completes such a char. - // - // Note: Things get nasty here if this is only sometimes true; in the future, - // we should compile-time enforce that this assumption is always true if possible. - if(entryNode.type == 'internal') { - let internalNode = entryNode; - for(let lowSurrogate of internalNode.values) { - let prefix = this.prefix + entry + lowSurrogate; - yield { - char: entry + lowSurrogate, - traversal: function() { return new TrieModel.Traversal(internalNode.children[lowSurrogate], prefix) } - } - } - } else { - // Determine how much of the 'leaf' entry has no Trie nodes, emulate them. - let fullText = entryNode.entries[0].key; - entry = entry + fullText[this.prefix.length + 1]; // The other half of the non-BMP char. - let prefix = this.prefix + entry; + private static Traversal = class implements LexiconTraversal { + /** + * The lexical prefix corresponding to the current traversal state. + */ + prefix: String; + + /** + * The current traversal node. Serves as the 'root' of its own sub-Trie, + * and we cannot navigate back to its parent. + */ + root: Node; + + constructor(root: Node, prefix: string) { + this.root = root; + this.prefix = prefix; + } + *children(): Generator<{char: string, traversal: () => LexiconTraversal}> { + let root = this.root; + + if(root.type == 'internal') { + for(let entry of root.values) { + let entryNode = root.children[entry]; + + // UTF-16 astral plane check. + if(isHighSurrogate(entry)) { + // First code unit of a UTF-16 code point. + // For now, we'll just assume the second always completes such a char. + // + // Note: Things get nasty here if this is only sometimes true; in the future, + // we should compile-time enforce that this assumption is always true if possible. + if(entryNode.type == 'internal') { + let internalNode = entryNode; + for(let lowSurrogate of internalNode.values) { + let prefix = this.prefix + entry + lowSurrogate; yield { - char: entry, - traversal: function () {return new TrieModel.Traversal(entryNode, prefix)} + char: entry + lowSurrogate, + traversal: function() { return new TrieModel.Traversal(internalNode.children[lowSurrogate], prefix) } } } - } else if(models.isSentinel(entry)) { - continue; - } else if(!entry) { - // Prevent any accidental 'null' or 'undefined' entries from having an effect. - continue; } else { + // Determine how much of the 'leaf' entry has no Trie nodes, emulate them. + let fullText = entryNode.entries[0].key; + entry = entry + fullText[this.prefix.length + 1]; // The other half of the non-BMP char. let prefix = this.prefix + entry; + yield { char: entry, - traversal: function() { return new TrieModel.Traversal(entryNode, prefix)} + traversal: function () {return new TrieModel.Traversal(entryNode, prefix)} } } + } else if(isSentinel(entry)) { + continue; + } else if(!entry) { + // Prevent any accidental 'null' or 'undefined' entries from having an effect. + continue; + } else { + let prefix = this.prefix + entry; + yield { + char: entry, + traversal: function() { return new TrieModel.Traversal(entryNode, prefix)} + } } + } - return; - } else { // type == 'leaf' - let prefix = this.prefix; + return; + } else { // type == 'leaf' + let prefix = this.prefix; - let children = root.entries.filter(function(entry) { - return entry.key != prefix && prefix.length < entry.key.length; - }) + let children = root.entries.filter(function(entry) { + return entry.key != prefix && prefix.length < entry.key.length; + }) - for(let {key} of children) { - let nodeKey = key[prefix.length]; + for(let {key} of children) { + let nodeKey = key[prefix.length]; - if(models.isHighSurrogate(nodeKey)) { - // Merge the other half of an SMP char in! - nodeKey = nodeKey + key[prefix.length+1]; - } - yield { - char: nodeKey, - traversal: function() { return new TrieModel.Traversal(root, prefix + nodeKey)} - } - }; - return; - } + if(isHighSurrogate(nodeKey)) { + // Merge the other half of an SMP char in! + nodeKey = nodeKey + key[prefix.length+1]; + } + yield { + char: nodeKey, + traversal: function() { return new TrieModel.Traversal(root, prefix + nodeKey)} + } + }; + return; } + } - get entries(): string[] { - if(this.root.type == 'leaf') { - let prefix = this.prefix; - let matches = this.root.entries.filter(function(entry) { - return entry.key == prefix; - }); - - return matches.map(function(value) { return value.content }); + get entries(): string[] { + if(this.root.type == 'leaf') { + let prefix = this.prefix; + let matches = this.root.entries.filter(function(entry) { + return entry.key == prefix; + }); + + return matches.map(function(value) { return value.content }); + } else { + let matchingLeaf = this.root.children[SENTINEL_CODE_UNIT]; + if(matchingLeaf && matchingLeaf.type == 'leaf') { + return matchingLeaf.entries.map(function(value) { return value.content }); } else { - let matchingLeaf = this.root.children[models.SENTINEL_CODE_UNIT]; - if(matchingLeaf && matchingLeaf.type == 'leaf') { - return matchingLeaf.entries.map(function(value) { return value.content }); - } else { - return []; - } + return []; } } } - }; + } +}; - ///////////////////////////////////////////////////////////////////////////////// - // What remains in this file is the trie implementation proper. Note: to // - // reduce bundle size, any functions/methods related to creating the trie have // - // been removed. // - ///////////////////////////////////////////////////////////////////////////////// +///////////////////////////////////////////////////////////////////////////////// +// What remains in this file is the trie implementation proper. Note: to // +// reduce bundle size, any functions/methods related to creating the trie have // +// been removed. // +///////////////////////////////////////////////////////////////////////////////// - /** - * An **opaque** type for a string that is exclusively used as a search key in - * the trie. There should be a function that converts arbitrary strings - * (queries) and converts them into a standard search key for a given language - * model. - * - * Fun fact: This opaque type has ALREADY saved my bacon and found a bug! - */ - type SearchKey = string & { _: 'SearchKey'}; +/** + * An **opaque** type for a string that is exclusively used as a search key in + * the trie. There should be a function that converts arbitrary strings + * (queries) and converts them into a standard search key for a given language + * model. + * + * Fun fact: This opaque type has ALREADY saved my bacon and found a bug! + */ +type SearchKey = string & { _: 'SearchKey'}; - /** - * The priority queue will always pop the most weighted item. There can only - * be two kinds of items right now: nodes, and entries; both having a weight - * attribute. - */ - type Weighted = Node | Entry; +/** + * The priority queue will always pop the most weighted item. There can only + * be two kinds of items right now: nodes, and entries; both having a weight + * attribute. + */ +type Weighted = Node | Entry; - /** - * A function that converts a string (word form or query) into a search key - * (secretly, this is also a string). - */ - interface Wordform2Key { - (wordform: string): SearchKey; - } +/** + * A function that converts a string (word form or query) into a search key + * (secretly, this is also a string). + */ +interface Wordform2Key { + (wordform: string): SearchKey; +} - // The following trie implementation has been (heavily) derived from trie-ing - // by Conrad Irwin. - // trie-ing is copyright (C) 2015–2017 Conrad Irwin. - // Distributed under the terms of the MIT license: - // https://github.com/ConradIrwin/trie-ing/blob/df55d7af7068d357829db9e0a7faa8a38add1d1d/LICENSE +// The following trie implementation has been (heavily) derived from trie-ing +// by Conrad Irwin. +// trie-ing is copyright (C) 2015–2017 Conrad Irwin. +// Distributed under the terms of the MIT license: +// https://github.com/ConradIrwin/trie-ing/blob/df55d7af7068d357829db9e0a7faa8a38add1d1d/LICENSE - type Node = InternalNode | Leaf; +type Node = InternalNode | Leaf; +/** + * An internal node in the trie. Internal nodes NEVER contain entries; if an + * internal node should contain an entry, then it has a dummy leaf node (see + * below), that can be accessed by node.children["\uFDD0"]. + */ +interface InternalNode { + type: 'internal'; + weight: number; + /** Maintains the keys of children in descending order of weight. */ + values: string[]; // TODO: As an optimization, "values" can be a single string! /** - * An internal node in the trie. Internal nodes NEVER contain entries; if an - * internal node should contain an entry, then it has a dummy leaf node (see - * below), that can be accessed by node.children["\uFDD0"]. + * Maps a single UTF-16 code unit to a child node in the trie. This child + * node may be a leaf or an internal node. The keys of this object are kept + * in sorted order in the .values array. */ - interface InternalNode { - type: 'internal'; - weight: number; - /** Maintains the keys of children in descending order of weight. */ - values: string[]; // TODO: As an optimization, "values" can be a single string! - /** - * Maps a single UTF-16 code unit to a child node in the trie. This child - * node may be a leaf or an internal node. The keys of this object are kept - * in sorted order in the .values array. - */ - children: { [codeunit: string]: Node }; - } - /** Only leaf nodes actually contain entries (i.e., the words proper). */ - interface Leaf { - type: 'leaf'; - weight: number; - entries: Entry[]; - } + children: { [codeunit: string]: Node }; +} +/** Only leaf nodes actually contain entries (i.e., the words proper). */ +interface Leaf { + type: 'leaf'; + weight: number; + entries: Entry[]; +} + +/** + * An entry in the prefix trie (stored in leaf nodes exclusively!) + */ +interface Entry { + /** The actual word form, stored in the trie. */ + content: string; + /** A search key that usually simplifies the word form, for ease of search. */ + key: SearchKey; + weight: number; +} +/** + * Wrapper class for the trie and its nodes. + */ +class Trie { + private root: Node; + /** The total weight of the entire trie. */ + private totalWeight: number; /** - * An entry in the prefix trie (stored in leaf nodes exclusively!) + * Converts arbitrary strings to a search key. The trie is built up of + * search keys; not each entry's word form! */ - interface Entry { - /** The actual word form, stored in the trie. */ - content: string; - /** A search key that usually simplifies the word form, for ease of search. */ - key: SearchKey; - weight: number; + toKey: Wordform2Key; + + constructor(root: Node, totalWeight: number, wordform2key: Wordform2Key) { + this.root = root; + this.toKey = wordform2key; + this.totalWeight = totalWeight; } /** - * Wrapper class for the trie and its nodes. + * Lookups an arbitrary prefix (a query) in the trie. Returns the top 3 + * results in sorted order. + * + * @param prefix */ - class Trie { - private root: Node; - /** The total weight of the entire trie. */ - private totalWeight: number; - /** - * Converts arbitrary strings to a search key. The trie is built up of - * search keys; not each entry's word form! - */ - toKey: Wordform2Key; - - constructor(root: Node, totalWeight: number, wordform2key: Wordform2Key) { - this.root = root; - this.toKey = wordform2key; - this.totalWeight = totalWeight; - } - - /** - * Lookups an arbitrary prefix (a query) in the trie. Returns the top 3 - * results in sorted order. - * - * @param prefix - */ - lookup(prefix: string): TextWithProbability[] { - let searchKey = this.toKey(prefix); - let lowestCommonNode = findPrefix(this.root, searchKey); - if (lowestCommonNode === null) { - return []; - } - - return getSortedResults(lowestCommonNode, searchKey, this.totalWeight); + lookup(prefix: string): TextWithProbability[] { + let searchKey = this.toKey(prefix); + let lowestCommonNode = findPrefix(this.root, searchKey); + if (lowestCommonNode === null) { + return []; } - /** - * Returns the top N suggestions from the trie. - * @param n How many suggestions, maximum, to return. - */ - firstN(n: number): TextWithProbability[] { - return getSortedResults(this.root, '' as SearchKey, this.totalWeight, n); - } + return getSortedResults(lowestCommonNode, searchKey, this.totalWeight); } /** - * Finds the deepest descendent in the trie with the given prefix key. - * - * This means that a search in the trie for a given prefix has a best-case - * complexity of O(m) where m is the length of the prefix. - * - * @param key The prefix to search for. - * @param index The index in the prefix. Initially 0. + * Returns the top N suggestions from the trie. + * @param n How many suggestions, maximum, to return. */ - function findPrefix(node: Node, key: SearchKey, index: number = 0): Node | null { - // An important note - the Trie itself is built on a per-JS-character basis, - // not on a UTF-8 character-code basis. - if (node.type === 'leaf' || index === key.length) { - return node; - } + firstN(n: number): TextWithProbability[] { + return getSortedResults(this.root, '' as SearchKey, this.totalWeight, n); + } +} - // So, for SMP models, we need to match each char of the supplementary pair - // in sequence. Each has its own node in the Trie. - let char = key[index]; - if (node.children[char]) { - return findPrefix(node.children[char], key, index + 1); - } +/** + * Finds the deepest descendent in the trie with the given prefix key. + * + * This means that a search in the trie for a given prefix has a best-case + * complexity of O(m) where m is the length of the prefix. + * + * @param key The prefix to search for. + * @param index The index in the prefix. Initially 0. + */ +function findPrefix(node: Node, key: SearchKey, index: number = 0): Node | null { + // An important note - the Trie itself is built on a per-JS-character basis, + // not on a UTF-8 character-code basis. + if (node.type === 'leaf' || index === key.length) { + return node; + } - return null; + // So, for SMP models, we need to match each char of the supplementary pair + // in sequence. Each has its own node in the Trie. + let char = key[index]; + if (node.children[char]) { + return findPrefix(node.children[char], key, index + 1); } - /** - * Returns all entries matching the given prefix, in descending order of - * weight. - * - * @param prefix the prefix to match. - * @param results the current results - * @param queue - */ - function getSortedResults(node: Node, prefix: SearchKey, N: number, limit = MAX_SUGGESTIONS): TextWithProbability[] { - let queue = new PriorityQueue(function(a: Weighted, b: Weighted) { - // In case of Trie compilation issues that emit `null` or `undefined` - return (b ? b.weight : 0) - (a ? a.weight : 0); - }); - let results: TextWithProbability[] = []; - - if (node.type === 'leaf') { - // Assuming the values are sorted, we can just add all of the values in the - // leaf, until we reach the limit. - for (let item of node.entries) { - // String.startsWith is not supported on certain Android (5.0) devices we wish to support. - // Requires a minimum of Chrome 36, as opposed to 5.0's default of 35. - if (item.key.indexOf(prefix) == 0) { - let { content, weight } = item; - results.push({ - text: content, - p: weight / N - }); - - if (results.length >= limit) { - return results; - } + return null; +} + +/** + * Returns all entries matching the given prefix, in descending order of + * weight. + * + * @param prefix the prefix to match. + * @param results the current results + * @param queue + */ +function getSortedResults(node: Node, prefix: SearchKey, N: number, limit = MAX_SUGGESTIONS): TextWithProbability[] { + let queue = new PriorityQueue(function(a: Weighted, b: Weighted) { + // In case of Trie compilation issues that emit `null` or `undefined` + return (b ? b.weight : 0) - (a ? a.weight : 0); + }); + let results: TextWithProbability[] = []; + + if (node.type === 'leaf') { + // Assuming the values are sorted, we can just add all of the values in the + // leaf, until we reach the limit. + for (let item of node.entries) { + // String.startsWith is not supported on certain Android (5.0) devices we wish to support. + // Requires a minimum of Chrome 36, as opposed to 5.0's default of 35. + if (item.key.indexOf(prefix) == 0) { + let { content, weight } = item; + results.push({ + text: content, + p: weight / N + }); + + if (results.length >= limit) { + return results; } } - } else { - queue.enqueue(node); - let next: Weighted; - - while (next = queue.dequeue()) { - if (isNode(next)) { - // When a node is next up in the queue, that means that next least - // likely suggestion is among its decsendants. - // So we search all of its descendants! - if (next.type === 'leaf') { - queue.enqueueAll(next.entries); - } else { - // XXX: alias `next` so that TypeScript can be SURE that internal is - // in fact an internal node. Because of the callback binding to the - // original definition of node (i.e., a Node | Entry), this will not - // type-check otherwise. - let internal = next; - queue.enqueueAll(next.values.map(char => { - return internal.children[char]; - })); - } + } + } else { + queue.enqueue(node); + let next: Weighted | undefined; + + while (next = queue.dequeue()) { + if (isNode(next)) { + // When a node is next up in the queue, that means that next least + // likely suggestion is among its decsendants. + // So we search all of its descendants! + if (next.type === 'leaf') { + queue.enqueueAll(next.entries); } else { - // When an entry is up next in the queue, we just add its contents to - // the results! - results.push({ - text: next.content, - p: next.weight / N - }); - if (results.length >= limit) { - return results; - } + // XXX: alias `next` so that TypeScript can be SURE that internal is + // in fact an internal node. Because of the callback binding to the + // original definition of node (i.e., a Node | Entry), this will not + // type-check otherwise. + let internal = next; + queue.enqueueAll(next.values.map(char => { + return internal.children[char]; + })); + } + } else { + // When an entry is up next in the queue, we just add its contents to + // the results! + results.push({ + text: next.content, + p: next.weight / N + }); + if (results.length >= limit) { + return results; } } } - return results; - } + return results; - /** TypeScript type guard that returns whether the thing is a Node. */ - function isNode(x: Entry | Node): x is Node { - return 'type' in x; - } +} +/** TypeScript type guard that returns whether the thing is a Node. */ +function isNode(x: Entry | Node): x is Node { + return 'type' in x; +} + +/** + * Converts wordforms into an indexable form. It does this by + * normalizing into NFD, removing diacritics, and then converting + * the result to lowercase. + * + * This is a very naïve implementation, that I only think will work on + * some languages that use the Latin script. As of 2020-04-08, only + * 4 out of 11 (36%) of published language models use the Latin script, + * so this might not actually be a great default. + * + * This uses String.prototype.normalize() to convert normalize into NFD. + * NFD is an easy way to separate a Latin character from its diacritics; + * Even then, some Latin-based orthographies use code points that, + * under NFD normalization, do NOT decompose into an ASCII letter and a + * combining diacritical mark (e.g., SENĆOŦEN). + * + * Use this only in early iterations of the model. For a production lexical + * model, you SHOULD write/generate your own key function, tailored to your + * language. + */ +function defaultSearchTermToKey(wordform: string): SearchKey { /** - * Converts wordforms into an indexable form. It does this by - * normalizing into NFD, removing diacritics, and then converting - * the result to lowercase. - * - * This is a very naïve implementation, that I only think will work on - * some languages that use the Latin script. As of 2020-04-08, only - * 4 out of 11 (36%) of published language models use the Latin script, - * so this might not actually be a great default. + * N.B.: this is (slightly) DIFFERENT than the version in + * keymanapp/lexical-model-compiler/build-trie + * as this is for compatibility for models built + * BEFORE the searchTermToKey function was bundled with + * all models. * - * This uses String.prototype.normalize() to convert normalize into NFD. - * NFD is an easy way to separate a Latin character from its diacritics; - * Even then, some Latin-based orthographies use code points that, - * under NFD normalization, do NOT decompose into an ASCII letter and a - * combining diacritical mark (e.g., SENĆOŦEN). - * - * Use this only in early iterations of the model. For a production lexical - * model, you SHOULD write/generate your own key function, tailored to your - * language. + * This compatibility version lowercases AFTER removing diacritics; + * the new version (bundled in future models) lowercases, + * NFD normalizes, THEN removes diacritics. */ - function defaultSearchTermToKey(wordform: string): SearchKey { - /** - * N.B.: this is (slightly) DIFFERENT than the version in - * keymanapp/lexical-model-compiler/build-trie - * as this is for compatibility for models built - * BEFORE the searchTermToKey function was bundled with - * all models. - * - * This compatibility version lowercases AFTER removing diacritics; - * the new version (bundled in future models) lowercases, - * NFD normalizes, THEN removes diacritics. - */ - return wordform - .normalize('NFD') - // Remove all combining diacritics (if input is in NFD) - // common to Latin-orthographies. - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() as SearchKey; - } - - function getDefaultWordBreaker() { - let namespace: {}; - // @ts-ignore - if (typeof wordBreakers !== 'undefined') { - // @ts-ignore - namespace = wordBreakers; - } else { - namespace = require('@keymanapp/models-wordBreakers').wordBreakers; - } - return namespace['default']; - } + return wordform + .normalize('NFD') + // Remove all combining diacritics (if input is in NFD) + // common to Latin-orthographies. + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() as SearchKey; } diff --git a/common/models/templates/test/helpers.js b/common/models/templates/test/helpers.js index 268e24b7dd..40aca6ffbb 100644 --- a/common/models/templates/test/helpers.js +++ b/common/models/templates/test/helpers.js @@ -1,5 +1,5 @@ -const path = require('path'); -const assert = require('assert'); +import path from 'path'; +import { assert } from 'chai'; var _ = global; @@ -9,7 +9,12 @@ var _ = global; // Ensure that we can successfully load the module & apply kmwLength, as it's // needed for some of the unit tests. -require('../build/index.bundled.js'); + +// Verify that the KMW string extensions are loaded via side-effect. +import * as models from '@keymanapp/models-templates/lib'; + +import { createRequire } from "module"; +import { fileURLToPath } from 'url'; assert.ok('💩'.kmwLength); @@ -17,6 +22,13 @@ assert.ok('💩'.kmwLength); * Load JSON fixtures from a well-known place. */ _.jsonFixture = function (name) { + // The most straight-forward way... is to use CommonJS-style require to load JSON. + // Fortunately, Node provides the tools needed to recreate it. + const require = createRequire(import.meta.url); + + // ES-module mode also leaves out `__dirname`, so we rebuild that too. + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); return require(path.join(__dirname, 'fixtures', `${name}.json`)); } diff --git a/common/models/templates/test/test-common.js b/common/models/templates/test/test-common.js index 7dfc565369..dfd5c875cb 100644 --- a/common/models/templates/test/test-common.js +++ b/common/models/templates/test/test-common.js @@ -2,8 +2,8 @@ * Unit tests for common utility functions/methods. */ -var assert = require('chai').assert; -var models = require('../build/index.bundled.js').models; +import { assert } from 'chai'; +import * as models from '@keymanapp/models-templates'; describe('Common utility functions', function() { // TODO: unit tests for other common utility functions diff --git a/common/models/templates/test/test-priority-queue.js b/common/models/templates/test/test-priority-queue.js index b004027d84..5fdd0caa3d 100644 --- a/common/models/templates/test/test-priority-queue.js +++ b/common/models/templates/test/test-priority-queue.js @@ -2,8 +2,8 @@ * Unit tests for the priority queue. */ -var assert = require('chai').assert; -var PriorityQueue = require('../build/index.bundled.js').models.PriorityQueue; +import { assert } from 'chai'; +import { PriorityQueue } from '@keymanapp/models-templates'; describe('Priority queue', function() { it('can act as a min-heap', function () { diff --git a/common/models/templates/test/test-quote-behavior.js b/common/models/templates/test/test-quote-behavior.js index dd5408f6a9..c6348d651d 100644 --- a/common/models/templates/test/test-quote-behavior.js +++ b/common/models/templates/test/test-quote-behavior.js @@ -2,8 +2,8 @@ * Unit tests for quote behaviors. */ -var assert = require('chai').assert; -var QuoteBehavior = require('../build/index.bundled.js').models.QuoteBehavior; +import { assert } from 'chai'; +import { QuoteBehavior } from '@keymanapp/models-templates'; describe('Quote behaviors', function() { describe('Script directionality', function() { diff --git a/common/models/templates/test/test-tokenization.js b/common/models/templates/test/test-tokenization.js index a7ae0835e0..6c1ff746e5 100644 --- a/common/models/templates/test/test-tokenization.js +++ b/common/models/templates/test/test-tokenization.js @@ -2,9 +2,9 @@ * Unit tests for common utility functions/methods. */ -var assert = require('chai').assert; -var models = require('../build/index.bundled.js').models; -var wordBreakers = require('@keymanapp/models-wordbreakers').wordBreakers; +import { assert } from 'chai'; +import * as models from "@keymanapp/models-templates"; +import * as wordBreakers from "@keymanapp/models-wordbreakers"; describe('Tokenization functions', function() { describe('tokenize', function() { @@ -210,7 +210,7 @@ describe('Tokenization functions', function() { // Step 2: Allow shifting a defined 'constant' span without mutating the definition. let shiftSpan = function(span, delta) { // Avoid mutating the parameter! - shiftedSpan = { + let shiftedSpan = { text: span.text, start: span.start + delta, end: span.end + delta, diff --git a/common/models/templates/test/test-trie-model.js b/common/models/templates/test/test-trie-model.js index b48dc254ec..e63c66148b 100644 --- a/common/models/templates/test/test-trie-model.js +++ b/common/models/templates/test/test-trie-model.js @@ -2,8 +2,8 @@ * Unit tests for the Trie prediction model. */ -var assert = require('chai').assert; -var TrieModel = require('../build/index.bundled.js').models.TrieModel; +import { assert } from 'chai'; +import { TrieModel } from '@keymanapp/models-templates'; describe('LMLayerWorker trie model for word lists', function() { describe('instantiation', function () { diff --git a/common/models/templates/test/test-trie-traversal.js b/common/models/templates/test/test-trie-traversal.js index 31f52f854a..d3d0496316 100644 --- a/common/models/templates/test/test-trie-traversal.js +++ b/common/models/templates/test/test-trie-traversal.js @@ -2,8 +2,8 @@ * Unit tests for the Trie prediction model. */ -var assert = require('chai').assert; -var TrieModel = require('../build/index.bundled.js').models.TrieModel; +import { assert } from 'chai'; +import { TrieModel } from '@keymanapp/models-templates'; // Useful for tests related to strings with supplementary pairs. var smpForUnicode = function(code){ @@ -23,7 +23,7 @@ describe('Trie traversal abstractions', function() { let rootKeys = ['t', 'o', 'a', 'i', 'w', 'h', 'f', 'b', 'n', 'y', 's', 'm', 'u', 'c', 'd', 'l', 'e', 'j', 'p', 'g', 'v', 'k', 'r', 'q'] - for(child of rootTraversal.children()) { + for(let child of rootTraversal.children()) { let keyIndex = rootKeys.indexOf(child.char); assert.notEqual(keyIndex, -1); rootKeys.splice(keyIndex, 1); @@ -43,7 +43,7 @@ describe('Trie traversal abstractions', function() { let tSuccess = false; let hSuccess = false; let eSuccess = false; - for(child of rootTraversal.children()) { + for(let child of rootTraversal.children()) { if(child.char == 't') { tSuccess = true; let traversalInner1 = child.traversal(); @@ -51,7 +51,7 @@ describe('Trie traversal abstractions', function() { assert.isArray(child.traversal().entries); assert.isEmpty(child.traversal().entries); - for(tChild of traversalInner1.children()) { + for(let tChild of traversalInner1.children()) { if(tChild.char == 'h') { hSuccess = true; let traversalInner2 = tChild.traversal(); @@ -59,7 +59,7 @@ describe('Trie traversal abstractions', function() { assert.isEmpty(tChild.traversal().entries); assert.isArray(tChild.traversal().entries); - for(hChild of traversalInner2.children()) { + for(let hChild of traversalInner2.children()) { if(hChild.char == 'e') { eSuccess = true; let traversalInner3 = hChild.traversal(); @@ -68,7 +68,7 @@ describe('Trie traversal abstractions', function() { assert.isDefined(traversalInner3.entries); assert.equal(traversalInner3.entries[0], "the"); - for(eChild of traversalInner3.children()) { + for(let eChild of traversalInner3.children()) { let keyIndex = eKeys.indexOf(eChild.char); assert.notEqual(keyIndex, -1, "Did not find char '" + eChild.char + "' in array!"); eKeys.splice(keyIndex, 1); @@ -96,21 +96,21 @@ describe('Trie traversal abstractions', function() { // 't', 'r', 'o' have siblings, but these don't. let leafChildSequence = ['u', 'b', 'l', 'e']; let eSuccess = false; - for(child of rootTraversal.children()) { + for(let child of rootTraversal.children()) { if(child.char == 't') { let traversalInner1 = child.traversal(); assert.isDefined(traversalInner1); assert.isArray(child.traversal().entries); assert.isEmpty(child.traversal().entries); - for(tChild of traversalInner1.children()) { + for(let tChild of traversalInner1.children()) { if(tChild.char == 'r') { let traversalInner2 = tChild.traversal(); assert.isDefined(traversalInner2); assert.isArray(tChild.traversal().entries); assert.isEmpty(tChild.traversal().entries); - for(rChild of traversalInner2.children()) { + for(let rChild of traversalInner2.children()) { if(rChild.char == 'o') { let curChild = rChild; @@ -176,7 +176,7 @@ describe('Trie traversal abstractions', function() { let aSuccess = false; let pSuccess = false; let eSuccess = false; - for(child of rootTraversal.children()) { + for(let child of rootTraversal.children()) { if(child.char == smpA) { aSuccess = true; let traversalInner1 = child.traversal(); @@ -184,7 +184,7 @@ describe('Trie traversal abstractions', function() { assert.isArray(child.traversal().entries); assert.isEmpty(child.traversal().entries); - for(aChild of traversalInner1.children()) { + for(let aChild of traversalInner1.children()) { if(aChild.char == smpP) { pSuccess = true; let traversalInner2 = aChild.traversal(); @@ -192,7 +192,7 @@ describe('Trie traversal abstractions', function() { assert.isArray(aChild.traversal().entries); assert.isEmpty(aChild.traversal().entries); - for(pChild of traversalInner2.children()) { + for(let pChild of traversalInner2.children()) { let keyIndex = pKeys.indexOf(pChild.char); assert.notEqual(keyIndex, -1, "Did not find char '" + pChild.char + "' in array!"); pKeys.splice(keyIndex, 1); diff --git a/common/models/templates/tsconfig.bundled.json b/common/models/templates/tsconfig.bundled.json deleted file mode 100644 index fd8dc5f65a..0000000000 --- a/common/models/templates/tsconfig.bundled.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - // This variant of the tsconfig.json exists to create a 'leaf', 'bundled' - // version of the models/templates build product. The same reference - // cannot be prepended twice in a composite tsc build, posing problems - // for certain down-line builds if the two tsconfigs are not differentiated. - "extends": "./tsconfig.json", - "compilerOptions": { - "outFile": "build/index.bundled.js", - }, - "references": [ - { "path": "../../web/keyman-version", "prepend": true}, - { "path": "../../web/utils", "prepend": true }, - { "path": "../types" } - ], - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "test" - ] -} diff --git a/common/models/templates/tsconfig.json b/common/models/templates/tsconfig.json index 8cc9c6af1c..9284bddd20 100644 --- a/common/models/templates/tsconfig.json +++ b/common/models/templates/tsconfig.json @@ -1,16 +1,26 @@ { "extends": "../../../tsconfig-base.json", "compilerOptions": { + "allowJs": false, "declaration": true, - "module": "none", - "outFile": "./build/index.js", + "module": "es6", + "moduleResolution": "node16", "sourceMap": true, + "sourceRoot": "/common/models/templates/src", + "inlineSources": true, + "strict": true, "lib": ["es6"], + "types": ["node"], "target": "es5", + "baseUrl": "./", + "outDir": "build/obj", + "tsBuildInfoFile": "build/obj/tsconfig.tsbuildinfo", + "rootDir": "./src" }, "references": [ + { "path": "../types" }, { "path": "../../web/utils" }, - { "path": "../types" } + { "path": "../../models/wordbreakers" } ], "include": [ "src/**/*.ts" diff --git a/common/models/wordbreakers/build-bundler.js b/common/models/wordbreakers/build-bundler.js new file mode 100644 index 0000000000..49693c0d2a --- /dev/null +++ b/common/models/wordbreakers/build-bundler.js @@ -0,0 +1,33 @@ +/* + * Note: while this file is not meant to exist long-term, it provides a nice + * low-level proof-of-concept for esbuild bundling of the various Web submodules. + * + * Add some extra code at the end of src/index.ts and run it to verify successful bundling! + */ + +import esbuild from 'esbuild'; +import { spawn } from 'child_process'; + +await esbuild.build({ + bundle: true, + sourcemap: true, + format: "esm", + nodePaths: ['..'], + entryPoints: ['build/obj/index.js'], + outfile: "build/lib/index.mjs", + tsconfig: 'tsconfig.json', + target: "es5" +}); + + +// Bundled CommonJS (classic Node) module version +esbuild.buildSync({ + bundle: true, + sourcemap: true, + format: "cjs", + nodePaths: ['..'], + entryPoints: ['build/obj/index.js'], + outfile: "build/lib/index.cjs", + tsconfig: 'tsconfig.json', + target: "es5" +}); \ No newline at end of file diff --git a/common/models/wordbreakers/build.sh b/common/models/wordbreakers/build.sh new file mode 100755 index 0000000000..84ca1bfafe --- /dev/null +++ b/common/models/wordbreakers/build.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Compile our sourcemap-path remapping module for use by Web builds, releases, etc. +# +set -eu + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(greadlink -f "${BASH_SOURCE[0]}" 2>/dev/null || readlink -f "${BASH_SOURCE[0]}")" +. "$(dirname "$THIS_SCRIPT")/../../../resources/build/build-utils.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" + +# This script runs from its own folder +cd "$(dirname "$THIS_SCRIPT")" + +################################ Main script ################################ + +builder_describe "Builds the predictive-text wordbreaker implementation module" \ + "clean" \ + "configure" \ + "build" \ + "test" + +builder_describe_outputs \ + configure /node_modules \ + build build/lib/index.mjs + +builder_parse "$@" + +### CONFIGURE ACTIONS + +if builder_start_action configure; then + verify_npm_setup + builder_finish_action success configure +fi + +### CLEAN ACTIONS + +if builder_start_action clean; then + rm -rf build/ + builder_finish_action success clean +fi + +### BUILD ACTIONS + +if builder_start_action build; then + npm run tsc -- -b + node build-bundler.js + + # Declaration bundling. + npm run tsc -- --emitDeclarationOnly --outFile ./build/lib/index.d.ts + + builder_finish_action success build +fi + +if builder_start_action test; then + npm run mocha + + builder_finish_action success test +fi \ No newline at end of file diff --git a/common/models/wordbreakers/package.json b/common/models/wordbreakers/package.json index 3c2eaa1028..417c9258b6 100644 --- a/common/models/wordbreakers/package.json +++ b/common/models/wordbreakers/package.json @@ -14,7 +14,16 @@ ], "homepage": "https://github.com/keymanapp/keyman", "license": "MIT", - "main": "build/index.js", + "main": "build/obj/index.js", + "types": "build/obj/index.d.ts", + "exports": { + ".": "./build/obj/index.js", + "./lib": { + "import": "./build/lib/index.mjs", + "require": "./build/lib/index.cjs" + }, + "./obj/*.js": "./build/obj/*.js" + }, "directories": { "lib": "lib", "test": "test" @@ -30,16 +39,19 @@ "url": "git+https://github.com/keymanapp/keyman.git" }, "scripts": { - "build": "tsc", + "build": "gosh ./build.sh", + "tsc": "tsc", "prepublish": "npm run build", "pretest": "npm run build", - "test": "mocha" + "test": "mocha", + "mocha": "mocha" }, "bugs": { "url": "https://github.com/keymanapp/keyman/issues" }, "devDependencies": { "@keymanapp/models-types": "*", + "@keymanapp/resources-gosh": "*", "@types/chai": "^4.2.11", "@types/mocha": "^7.0.2", "chai": "^4.3.4", @@ -47,6 +59,7 @@ "ts-node": "^10.9.1", "typescript": "^4.9.5" }, + "type": "module", "dependencies": { "@types/node": "^14.0.3" } diff --git a/common/models/wordbreakers/src/ascii.ts b/common/models/wordbreakers/src/ascii.ts index e321f387d4..902a6e7058 100644 --- a/common/models/wordbreakers/src/ascii.ts +++ b/common/models/wordbreakers/src/ascii.ts @@ -1,40 +1,39 @@ /// -namespace wordBreakers { - /** - * Splits ASCII words. - * - * @param phrase - */ - export function ascii(phrase: string): Span[] { - let matchWord = /[A-Za-z0-9']+/g; - let words: Span[] = []; - let match: RegExpExecArray | null; - while ((match = matchWord.exec(phrase)) !== null) { - words.push(new RegExpDerivedSpan(match[0], match.index)); - } - return words; +/** + * Splits ASCII words. + * + * @param phrase + */ +export default function ascii(phrase: string): Span[] { + let matchWord = /[A-Za-z0-9']+/g; + let words: Span[] = []; + let match: RegExpExecArray | null; + while ((match = matchWord.exec(phrase)) !== null) { + words.push(new RegExpDerivedSpan(match[0], match.index)); } - /** - * A concrete span class that derives its properties from the result of - * RegExp.exec() array. - */ - class RegExpDerivedSpan implements Span { - readonly text: string; - readonly start: number; + return words; +} + +/** + * A concrete span class that derives its properties from the result of + * RegExp.exec() array. + */ +class RegExpDerivedSpan implements Span { + readonly text: string; + readonly start: number; - constructor(text: string, start: number) { - this.text = text; - this.start = start; - } + constructor(text: string, start: number) { + this.text = text; + this.start = start; + } - get length(): number { - return this.text.length; - } + get length(): number { + return this.text.length; + } - get end(): number { - return this.start + this.text.length; - } + get end(): number { + return this.start + this.text.length; } } diff --git a/common/models/wordbreakers/src/default/data.ts b/common/models/wordbreakers/src/default/data.ts index 16a4d9beb3..abd128f020 100644 --- a/common/models/wordbreakers/src/default/data.ts +++ b/common/models/wordbreakers/src/default/data.ts @@ -1,6 +1,5 @@ -namespace wordBreakers { -export namespace data { // Automatically generated file. DO NOT MODIFY. + /** * Valid values for a word break property. */ @@ -1801,5 +1800,3 @@ export const WORD_BREAK_PROPERTY: [number, WordBreakProperty][] = [ [/*start*/ 0xE0100, WordBreakProperty.Extend], [/*start*/ 0xE01F0, WordBreakProperty.Other], ]; -} -} diff --git a/common/models/wordbreakers/src/default/index.ts b/common/models/wordbreakers/src/default/index.ts index 715b722932..65dbd0faf0 100644 --- a/common/models/wordbreakers/src/default/index.ts +++ b/common/models/wordbreakers/src/default/index.ts @@ -1,625 +1,610 @@ -// Include the word-breaking data here: -/// -namespace wordBreakers { +import { WordBreakProperty, WORD_BREAK_PROPERTY, I, propertyMap } from "./data.js"; + +/** + * A set of options used to customize and extend the behavior of the default + * Unicode wordbreaker. + */ +export interface DefaultWordBreakerOptions { /** - * A set of options used to customize and extend the behavior of the default - * Unicode wordbreaker. + * Allows addition of custom wordbreaking rules, which will be applied + * after WB1-WB4 and before all other default wordbreaking rules. + * + * @see `WordbreakerRule` */ - export interface DefaultWordBreakerOptions { - /** - * Allows addition of custom wordbreaking rules, which will be applied - * after WB1-WB4 and before all other default wordbreaking rules. - * - * @see `WordbreakerRule` - */ - rules?: WordbreakerRule[]; - - /** - * Allows assignment of characters to different word-breaking properties than - * their standard word-breaking assignment, including to custom properties - * specified within `customProperties`. - * @param char - */ - propertyMapping?(char: string): string; - - /** - * Allows definition of extra word-breaking properties for use with custom - * rules. - */ - customProperties?: string[]; - } + rules?: WordbreakerRule[]; /** - * Word breaker based on Unicode Standard Annex #29, Section 4.1: - * Default Word Boundary Specification. - * - * @see http://unicode.org/reports/tr29/#Word_Boundaries - * @see https://github.com/eddieantonio/unicode-default-word-boundary/tree/v12.0.0 + * Allows assignment of characters to different word-breaking properties than + * their standard word-breaking assignment, including to custom properties + * specified within `customProperties`. + * @param char */ - export function default_(text: string, options?: DefaultWordBreakerOptions): Span[] { - let boundaries = findBoundaries(text, options); - if (boundaries.length == 0) { - return []; - } - - // All non-empty strings have at least TWO boundaries: at the start and at the end of - // the string. - let spans = []; - for (let i = 0; i < boundaries.length - 1; i++) { - let start = boundaries[i]; - let end = boundaries[i + 1]; - let span = new LazySpan(text, start, end); - - if (isNonSpace(span.text, options)) { - spans.push(span); - // Preserve a sequence-final space if it exists. Needed to signal "end of word". - } else if (i == boundaries.length - 2) { // if "we just checked the final boundary"... - // We don't want to return the whitespace itself; the correct token is simply ''. - span = new LazySpan(text, end, end); - spans.push(span); - } + propertyMapping?(char: string): string; + + /** + * Allows definition of extra word-breaking properties for use with custom + * rules. + */ + customProperties?: string[]; +} + +/** + * Word breaker based on Unicode Standard Annex #29, Section 4.1: + * Default Word Boundary Specification. + * + * @see http://unicode.org/reports/tr29/#Word_Boundaries + * @see https://github.com/eddieantonio/unicode-default-word-boundary/tree/v12.0.0 + */ +export default function default_(text: string, options?: DefaultWordBreakerOptions): Span[] { + let boundaries = findBoundaries(text, options); + if (boundaries.length == 0) { + return []; + } + + // All non-empty strings have at least TWO boundaries: at the start and at the end of + // the string. + let spans = []; + for (let i = 0; i < boundaries.length - 1; i++) { + let start = boundaries[i]; + let end = boundaries[i + 1]; + let span = new LazySpan(text, start, end); + + if (isNonSpace(span.text, options)) { + spans.push(span); + // Preserve a sequence-final space if it exists. Needed to signal "end of word". + } else if (i == boundaries.length - 2) { // if "we just checked the final boundary"... + // We don't want to return the whitespace itself; the correct token is simply ''. + span = new LazySpan(text, end, end); + spans.push(span); } - return spans; + } + return spans; +} + +/** + * A span that does not cut out the substring until it absolutely has to! + */ +class LazySpan implements Span { + private _source: string; + readonly start: number; + readonly end: number; + constructor(source: string, start: number, end: number) { + this._source = source; + this.start = start; + this.end = end; + } + + get text(): string { + return this._source.substring(this.start, this.end); } - // Utilities // - import WordBreakProperty = wordBreakers.data.WordBreakProperty; - import WORD_BREAK_PROPERTY = wordBreakers.data.WORD_BREAK_PROPERTY; - import I = wordBreakers.data.I; + get length(): number { + return this.end - this.start; + } +} +/** + * An abstraction supporting custom wordbreaker boundary rules. While this doesn't provide + * support for more complex rules like WB4, WB15, or WB16, this is sufficient for all other + * default word-breaking rules and can be used to define custom rules of similar structure. + * + * @see https://unicode.org/reports/tr29/#WB_Rule_Macros + */ +export interface WordbreakerRule { /** - * A span that does not cut out the substring until it absolutely has to! + * Indicates whether or not the rule applies in the specified context. + * @param context */ - class LazySpan implements Span { - private _source: string; - readonly start: number; - readonly end: number; - constructor(source: string, start: number, end: number) { - this._source = source; - this.start = start; - this.end = end; - } + match(context: BreakerContext): boolean; - get text(): string { - return this._source.substring(this.start, this.end); - } + /** + * Indicates whether or not the rule indicates a word boundary at the context's site when it matches. + */ + breakIfMatch: boolean; +} - get length(): number { - return this.end - this.start; +/** + * Provides a useful presentation for wordbreaker's context for use in word-breaking rules. + * + * @see https://unicode.org/reports/tr29/#Word_Boundary_Rules + */ +export class BreakerContext { + // Referenced by this object in order to facilitate `lookahead` maintenance. + private readonly text: string; + readonly options?: DefaultWordBreakerOptions; + + /** + * Represents the property of character immediately preceding `left`'s character. + */ + readonly lookbehind: WordBreakProperty = WordBreakProperty.sot; + + /** + * Represents the property of the character immediately preceding the potential word boundary. + */ + readonly left: WordBreakProperty = WordBreakProperty.sot; + + /** + * Represents the property of the character immediately following the potential word boundary. + */ + readonly right: WordBreakProperty = WordBreakProperty.sot; + + /** + * Represents the property of the character immediately following `right`'s character. + */ + readonly lookahead: WordBreakProperty; // Always initialized by constructor. + + /** + * Initializes the word-breaking context at the start of the word-breaker's boundary-detection + * algorithm. + * @param text The text to be word-broken + * @param lookaheadPos The position corresponding to `lookahead`. + */ + constructor(text: string, options: DefaultWordBreakerOptions | undefined, lookaheadPos: number); + /** + * Used internally by the boundary-detection algorithm during context-shifting operations. + * @param text + * @param lookbehind + * @param left + * @param right + * @param lookahead + */ + constructor(text: string, + options: DefaultWordBreakerOptions | undefined, + lookbehind: WordBreakProperty, + left: WordBreakProperty, + right: WordBreakProperty, + lookahead: WordBreakProperty); + constructor(text: string, + options: DefaultWordBreakerOptions | undefined, + prop1: WordBreakProperty | number, + prop2?: WordBreakProperty, + prop3?: WordBreakProperty, + prop4?: WordBreakProperty) { + this.text = text; + this.options = options; + + if(arguments.length == 3) { + this.lookahead = this.wordbreakPropertyAt(prop1);// prop1; + } else /*if(arguments.length == 6)*/ { + this.lookbehind = prop1 as WordBreakProperty; + this.left = prop2 as WordBreakProperty; + this.right = prop3 as WordBreakProperty; + this.lookahead = prop4 as WordBreakProperty; } } /** - * An abstraction supporting custom wordbreaker boundary rules. While this doesn't provide - * support for more complex rules like WB4, WB15, or WB16, this is sufficient for all other - * default word-breaking rules and can be used to define custom rules of similar structure. - * - * @see https://unicode.org/reports/tr29/#WB_Rule_Macros + * The general use-case when shifting boundary-check position if WB4 is not active. + * @param lookahead The WordBreakProperty for the character to become `lookahead`. + * @returns */ - export interface WordbreakerRule { - /** - * Indicates whether or not the rule applies in the specified context. - * @param context - */ - match(context: BreakerContext): boolean; - - /** - * Indicates whether or not the rule indicates a word boundary at the context's site when it matches. - */ - breakIfMatch: boolean; + public next(lookaheadPos: number): BreakerContext { + let newLookahead = this.wordbreakPropertyAt(lookaheadPos); + return new BreakerContext(this.text, this.options, this.left, this.right, this.lookahead, newLookahead); } /** - * Provides a useful presentation for wordbreaker's context for use in word-breaking rules. - * - * @see https://unicode.org/reports/tr29/#Word_Boundary_Rules + * Used for WB4: when ignoring characters before an intervening linebreak, we + * replace `right` with the current `lookahead`, without affecting `lookbehind` + * or `left`. A new `lookahead` is then needed. + * @param lookahead + * @returns */ - export class BreakerContext { - // Referenced by this object in order to facilitate `lookahead` maintenance. - private readonly text: string; - readonly options?: DefaultWordBreakerOptions; - - /** - * Represents the property of character immediately preceding `left`'s character. - */ - readonly lookbehind: WordBreakProperty = WordBreakProperty.sot; - - /** - * Represents the property of the character immediately preceding the potential word boundary. - */ - readonly left: WordBreakProperty = WordBreakProperty.sot; - - /** - * Represents the property of the character immediately following the potential word boundary. - */ - readonly right: WordBreakProperty = WordBreakProperty.sot; - - /** - * Represents the property of the character immediately following `right`'s character. - */ - readonly lookahead: WordBreakProperty; // Always initialized by constructor. - - /** - * Initializes the word-breaking context at the start of the word-breaker's boundary-detection - * algorithm. - * @param text The text to be word-broken - * @param lookaheadPos The position corresponding to `lookahead`. - */ - constructor(text: string, options: DefaultWordBreakerOptions | undefined, lookaheadPos: number); - /** - * Used internally by the boundary-detection algorithm during context-shifting operations. - * @param text - * @param lookbehind - * @param left - * @param right - * @param lookahead - */ - constructor(text: string, - options: DefaultWordBreakerOptions | undefined, - lookbehind: WordBreakProperty, - left: WordBreakProperty, - right: WordBreakProperty, - lookahead: WordBreakProperty); - constructor(text: string, - options: DefaultWordBreakerOptions | undefined, - prop1: WordBreakProperty | number, - prop2?: WordBreakProperty, - prop3?: WordBreakProperty, - prop4?: WordBreakProperty) { - this.text = text; - this.options = options; - - if(arguments.length == 3) { - this.lookahead = this.wordbreakPropertyAt(prop1);// prop1; - } else /*if(arguments.length == 6)*/ { - this.lookbehind = prop1 as WordBreakProperty; - this.left = prop2 as WordBreakProperty; - this.right = prop3 as WordBreakProperty; - this.lookahead = prop4 as WordBreakProperty; - } - } + public ignoringRight(lookaheadPos: number) { + let newLookahead = this.wordbreakPropertyAt(lookaheadPos); + return new BreakerContext(this.text, this.options, this.lookbehind, this.left, this.lookahead, newLookahead); + } - /** - * The general use-case when shifting boundary-check position if WB4 is not active. - * @param lookahead The WordBreakProperty for the character to become `lookahead`. - * @returns - */ - public next(lookaheadPos: number): BreakerContext { - let newLookahead = this.wordbreakPropertyAt(lookaheadPos); - return new BreakerContext(this.text, this.options, this.left, this.right, this.lookahead, newLookahead); - } - - /** - * Used for WB4: when ignoring characters before an intervening linebreak, we - * replace `right` with the current `lookahead`, without affecting `lookbehind` - * or `left`. A new `lookahead` is then needed. - * @param lookahead - * @returns - */ - public ignoringRight(lookaheadPos: number) { - let newLookahead = this.wordbreakPropertyAt(lookaheadPos); - return new BreakerContext(this.text, this.options, this.lookbehind, this.left, this.lookahead, newLookahead); - } - - /** - * Used for WB4: when ignoring characters after an intervening linebreak, it's - * `lookahead` that gets replaced without shifting the other tracked properties. - * @param lookahead - * @returns - */ - public ignoringLookahead(lookaheadPos: number) { - let newLookahead = this.wordbreakPropertyAt(lookaheadPos); - return new BreakerContext(this.text, this.options, this.lookbehind, this.left, this.right, newLookahead); - } - - /** - * Return the value of the Word_Break property at the given string index. - * @param pos position in the text. - */ - private wordbreakPropertyAt(pos: number) { - if (pos < 0) { - return WordBreakProperty.sot; // Always "start of string" before the string starts! - } else if (pos >= this.text.length) { - return WordBreakProperty.eot; // Always "end of string" after the string ends! - } else if (isStartOfSurrogatePair(this.text[pos])) { - // Surrogate pairs the next TWO items from the string! - return property(this.text[pos] + this.text[pos + 1]); - } - return property(this.text[pos], this.options); - } - - /** - * Returns `true` if and only if each member of the context has a property included within - * its corresponding set (when specified). Any set may be replaced with null to disable - * a check against its corresponding property. - * @param lookbehindSet - * @param leftSet - * @param rightSet - * @param lookaheadSet - */ - public match(lookbehindSet: WordBreakProperty[] | null, - leftSet: WordBreakProperty[] | null, - rightSet: WordBreakProperty[] | null, - lookaheadSet: WordBreakProperty[] | null) : boolean { - let result: boolean = lookbehindSet?.includes(this.lookbehind) ?? true; - result = result && (leftSet?.includes(this.left) ?? true); - result = result && (rightSet?.includes(this.right) ?? true); - return result && (lookaheadSet?.includes(this.lookahead) ?? true); - } - - /** - * Returns `true` if and only if each member of the context has a property included within - * its corresponding set (when specified). Any set may be replaced with null to disable - * a check against its corresponding property. - * - * Names should match those found at https://unicode.org/reports/tr29/#Word_Boundary_Rules - * or defined in the word-breaker customization options; matching is case-insensitive. - * Also includes two extra properties: - * - `sot` - start of text - * - `eot` - end of text - * @param lookbehindSet - * @param leftSet - * @param rightSet - * @param lookaheadSet - */ - public propertyMatch(lookbehindSet: string[] | null, - leftSet: string[] | null, - rightSet: string[] | null, - lookaheadSet: string[] | null) : boolean { - const propMapper = (name: string) => propertyVal(name, this.options); - return this.match(lookbehindSet?.map(propMapper) as WordBreakProperty[] | null, - leftSet?.map(propMapper) as WordBreakProperty[] | null, - rightSet?.map(propMapper) as WordBreakProperty[] | null, - lookaheadSet?.map(propMapper) as WordBreakProperty[] | null); - } + /** + * Used for WB4: when ignoring characters after an intervening linebreak, it's + * `lookahead` that gets replaced without shifting the other tracked properties. + * @param lookahead + * @returns + */ + public ignoringLookahead(lookaheadPos: number) { + let newLookahead = this.wordbreakPropertyAt(lookaheadPos); + return new BreakerContext(this.text, this.options, this.lookbehind, this.left, this.right, newLookahead); + } + + /** + * Return the value of the Word_Break property at the given string index. + * @param pos position in the text. + */ + private wordbreakPropertyAt(pos: number) { + if (pos < 0) { + return WordBreakProperty.sot; // Always "start of string" before the string starts! + } else if (pos >= this.text.length) { + return WordBreakProperty.eot; // Always "end of string" after the string ends! + } else if (isStartOfSurrogatePair(this.text[pos])) { + // Surrogate pairs the next TWO items from the string! + return property(this.text[pos] + this.text[pos + 1]); } + return property(this.text[pos], this.options); + } /** - * Returns true when the chunk does not solely consist of whitespace. - * - * @param chunk a chunk of text. Starts and ends at word boundaries. + * Returns `true` if and only if each member of the context has a property included within + * its corresponding set (when specified). Any set may be replaced with null to disable + * a check against its corresponding property. + * @param lookbehindSet + * @param leftSet + * @param rightSet + * @param lookaheadSet */ - function isNonSpace(chunk: string, options?: DefaultWordBreakerOptions): boolean { - return !Array.from(chunk).map((char) => property(char, options)).every(wb => ( - wb === WordBreakProperty.CR || - wb === WordBreakProperty.LF || - wb === WordBreakProperty.Newline || - wb === WordBreakProperty.WSegSpace - )); + public match(lookbehindSet: WordBreakProperty[] | null, + leftSet: WordBreakProperty[] | null, + rightSet: WordBreakProperty[] | null, + lookaheadSet: WordBreakProperty[] | null) : boolean { + let result: boolean = lookbehindSet?.includes(this.lookbehind) ?? true; + result = result && (leftSet?.includes(this.left) ?? true); + result = result && (rightSet?.includes(this.right) ?? true); + return result && (lookaheadSet?.includes(this.lookahead) ?? true); } /** - * Yields a series of string indices where a word break should - * occur. That is, there should be a break BEFORE each string - * index yielded by this generator. + * Returns `true` if and only if each member of the context has a property included within + * its corresponding set (when specified). Any set may be replaced with null to disable + * a check against its corresponding property. * - * @param text Text to find word boundaries in. + * Names should match those found at https://unicode.org/reports/tr29/#Word_Boundary_Rules + * or defined in the word-breaker customization options; matching is case-insensitive. + * Also includes two extra properties: + * - `sot` - start of text + * - `eot` - end of text + * @param lookbehindSet + * @param leftSet + * @param rightSet + * @param lookaheadSet */ - function findBoundaries(text: string, options?: DefaultWordBreakerOptions): number[] { - // WB1 and WB2: no boundaries if given an empty string. - if (text.length === 0) { - // There are no boundaries in an empty string! - return []; - } - - if(options && !options.rules) { - options.rules = []; - } - - // This algorithm works by maintaining a sliding window of four SCALAR VALUES. - // - // - Scalar values? JavaScript strings are NOT actually a string of - // Unicode code points; some characters are made up of TWO - // JavaScript indices. e.g., - // "💩".length === 2; - // "💩"[0] === '\uD83D'; - // "💩"[1] === '\uDCA9'; - // - // These characters that are represented by TWO indices are - // called "surrogate pairs". Since we don't want to be in the - // "middle" of a character, make sure we're always advancing - // by scalar values, and NOT indices. That means, we sometimes - // need to advance by TWO indices, not just one. - // - Four values? Some rules look at what's to the left of - // left, and some look at what's to the right of right. So - // keep track of this! - - let boundaries = []; - - let rightPos: number; - let lookaheadPos = 0; // lookahead, one scalar value to the right of right. - // Before the start of the string is also the start of the string. - let state = new BreakerContext(text, options, lookaheadPos); - // Count RIs to make sure we're not splitting emoji flags: - let nConsecutiveRegionalIndicators = 0; - - do { - // Shift all positions, one scalar value to the right. - rightPos = lookaheadPos; - lookaheadPos = positionAfter(lookaheadPos); - // Shift all properties, one scalar value to the right. - state = state.next(lookaheadPos); + public propertyMatch(lookbehindSet: string[] | null, + leftSet: string[] | null, + rightSet: string[] | null, + lookaheadSet: string[] | null) : boolean { + const propMapper = (name: string) => propertyVal(name, this.options); + return this.match(lookbehindSet?.map(propMapper) as WordBreakProperty[] | null, + leftSet?.map(propMapper) as WordBreakProperty[] | null, + rightSet?.map(propMapper) as WordBreakProperty[] | null, + lookaheadSet?.map(propMapper) as WordBreakProperty[] | null); + } + } - // Break at the start and end of text, unless the text is empty. - // WB1: Break at start of text... - if (state.match(null, [WordBreakProperty.sot], null, null)) { - boundaries.push(rightPos); - continue; - } - // WB2: Break at the end of text... - if (state.match(null, null, [WordBreakProperty.eot], null)) { - boundaries.push(rightPos); - break; // Reached the end of the string. We're done! - } - // WB3: Do not break within CRLF: - if (state.match(null, [WordBreakProperty.CR], [WordBreakProperty.LF], null)) { - continue; - } +/** + * Returns true when the chunk does not solely consist of whitespace. + * + * @param chunk a chunk of text. Starts and ends at word boundaries. + */ +function isNonSpace(chunk: string, options?: DefaultWordBreakerOptions): boolean { + return !Array.from(chunk).map((char) => property(char, options)).every(wb => ( + wb === WordBreakProperty.CR || + wb === WordBreakProperty.LF || + wb === WordBreakProperty.Newline || + wb === WordBreakProperty.WSegSpace + )); +} - // WB3b: Otherwise, break after... - const NEWLINE_SET = [WordBreakProperty.Newline, WordBreakProperty.CR, WordBreakProperty.LF]; - if(state.match(null, NEWLINE_SET, null, null)) { - boundaries.push(rightPos); - continue; - } - // WB3a: ...and before newlines - if (state.match(null, null, NEWLINE_SET, null)) { - boundaries.push(rightPos); - continue; - } +/** + * Yields a series of string indices where a word break should + * occur. That is, there should be a break BEFORE each string + * index yielded by this generator. + * + * @param text Text to find word boundaries in. + */ +function findBoundaries(text: string, options?: DefaultWordBreakerOptions): number[] { + // WB1 and WB2: no boundaries if given an empty string. + if (text.length === 0) { + // There are no boundaries in an empty string! + return []; + } - // TODO: WB3c is not implemented, due to its complex, error-prone - // implementation, requiring a ginormous regexp, and the fact that - // the only thing it does is prevent big emoji sequences from being - // split up, like 🧚🏼‍♂️ - // https://www.unicode.org/Public/emoji/12.0/emoji-zwj-sequences.txt + if(options && !options.rules) { + options.rules = []; + } - // WB3d: Keep horizontal whitespace together - if (state.match(null, [WordBreakProperty.WSegSpace], [WordBreakProperty.WSegSpace], null)) { - continue; - } + // This algorithm works by maintaining a sliding window of four SCALAR VALUES. + // + // - Scalar values? JavaScript strings are NOT actually a string of + // Unicode code points; some characters are made up of TWO + // JavaScript indices. e.g., + // "💩".length === 2; + // "💩"[0] === '\uD83D'; + // "💩"[1] === '\uDCA9'; + // + // These characters that are represented by TWO indices are + // called "surrogate pairs". Since we don't want to be in the + // "middle" of a character, make sure we're always advancing + // by scalar values, and NOT indices. That means, we sometimes + // need to advance by TWO indices, not just one. + // - Four values? Some rules look at what's to the left of + // left, and some look at what's to the right of right. So + // keep track of this! + + let boundaries = []; + + let rightPos: number; + let lookaheadPos = 0; // lookahead, one scalar value to the right of right. + // Before the start of the string is also the start of the string. + let state = new BreakerContext(text, options, lookaheadPos); + // Count RIs to make sure we're not splitting emoji flags: + let nConsecutiveRegionalIndicators = 0; + + do { + // Shift all positions, one scalar value to the right. + rightPos = lookaheadPos; + lookaheadPos = positionAfter(lookaheadPos); + // Shift all properties, one scalar value to the right. + state = state.next(lookaheadPos); + + // Break at the start and end of text, unless the text is empty. + // WB1: Break at start of text... + if (state.match(null, [WordBreakProperty.sot], null, null)) { + boundaries.push(rightPos); + continue; + } + // WB2: Break at the end of text... + if (state.match(null, null, [WordBreakProperty.eot], null)) { + boundaries.push(rightPos); + break; // Reached the end of the string. We're done! + } + // WB3: Do not break within CRLF: + if (state.match(null, [WordBreakProperty.CR], [WordBreakProperty.LF], null)) { + continue; + } - // WB4: Ignore format and extend characters - // This is to keep grapheme clusters together! - // See: Section 6.2: https://unicode.org/reports/tr29/#Grapheme_Cluster_and_Format_Rules - // N.B.: The rule about "except after sot, CR, LF, and - // Newline" already been by WB1, WB2, WB3a, and WB3b above. - const SET_WB4_IGNORE = [WordBreakProperty.Format, WordBreakProperty.Extend, WordBreakProperty.ZWJ]; - while (state.match(null, null, SET_WB4_IGNORE, null)) { - // Continue advancing in the string, as if these - // characters do not exist. DO NOT update left and - // lookbehind however! - [rightPos, lookaheadPos] = [lookaheadPos, positionAfter(lookaheadPos)]; - state = state.ignoringRight(lookaheadPos); - } - // In ignoring the characters in the previous loop, we could - // have fallen off the end of the string, so end the loop - // prematurely if that happens! - if (state.right === WordBreakProperty.eot) { - boundaries.push(rightPos); - break; - } - // WB4 (continued): Lookahead must ALSO ignore these format, - // extend, ZWJ characters! - while (state.match(null, null, null, SET_WB4_IGNORE)) { - // Continue advancing in the string, as if these - // characters do not exist. DO NOT update left and right, - // however! - lookaheadPos = positionAfter(lookaheadPos); - state = state.ignoringLookahead(lookaheadPos); - } + // WB3b: Otherwise, break after... + const NEWLINE_SET = [WordBreakProperty.Newline, WordBreakProperty.CR, WordBreakProperty.LF]; + if(state.match(null, NEWLINE_SET, null, null)) { + boundaries.push(rightPos); + continue; + } + // WB3a: ...and before newlines + if (state.match(null, null, NEWLINE_SET, null)) { + boundaries.push(rightPos); + continue; + } - // See: https://unicode.org/reports/tr29/#WB_Rule_Macros - const SET_AHLETTER = [WordBreakProperty.ALetter, WordBreakProperty.Hebrew_Letter]; - const SET_MIDNUMLETQ = [WordBreakProperty.MidNumLet, WordBreakProperty.Single_Quote]; - - // Custom rules may override the base ruleset aside from the first few fundamental ones. - if(options?.rules) { - let customMatch: boolean = false; - for(const rule of options.rules) { - customMatch = rule.match(state); - if(customMatch) { - if(rule.breakIfMatch) { - boundaries.push(rightPos); - } - break; // as customMatch == true here, this will trigger the `continue` that follows. - } - } + // TODO: WB3c is not implemented, due to its complex, error-prone + // implementation, requiring a ginormous regexp, and the fact that + // the only thing it does is prevent big emoji sequences from being + // split up, like 🧚🏼‍♂️ + // https://www.unicode.org/Public/emoji/12.0/emoji-zwj-sequences.txt + + // WB3d: Keep horizontal whitespace together + if (state.match(null, [WordBreakProperty.WSegSpace], [WordBreakProperty.WSegSpace], null)) { + continue; + } + + // WB4: Ignore format and extend characters + // This is to keep grapheme clusters together! + // See: Section 6.2: https://unicode.org/reports/tr29/#Grapheme_Cluster_and_Format_Rules + // N.B.: The rule about "except after sot, CR, LF, and + // Newline" already been by WB1, WB2, WB3a, and WB3b above. + const SET_WB4_IGNORE = [WordBreakProperty.Format, WordBreakProperty.Extend, WordBreakProperty.ZWJ]; + while (state.match(null, null, SET_WB4_IGNORE, null)) { + // Continue advancing in the string, as if these + // characters do not exist. DO NOT update left and + // lookbehind however! + [rightPos, lookaheadPos] = [lookaheadPos, positionAfter(lookaheadPos)]; + state = state.ignoringRight(lookaheadPos); + } + // In ignoring the characters in the previous loop, we could + // have fallen off the end of the string, so end the loop + // prematurely if that happens! + if (state.right === WordBreakProperty.eot) { + boundaries.push(rightPos); + break; + } + // WB4 (continued): Lookahead must ALSO ignore these format, + // extend, ZWJ characters! + while (state.match(null, null, null, SET_WB4_IGNORE)) { + // Continue advancing in the string, as if these + // characters do not exist. DO NOT update left and right, + // however! + lookaheadPos = positionAfter(lookaheadPos); + state = state.ignoringLookahead(lookaheadPos); + } + + // See: https://unicode.org/reports/tr29/#WB_Rule_Macros + const SET_AHLETTER = [WordBreakProperty.ALetter, WordBreakProperty.Hebrew_Letter]; + const SET_MIDNUMLETQ = [WordBreakProperty.MidNumLet, WordBreakProperty.Single_Quote]; + + // Custom rules may override the base ruleset aside from the first few fundamental ones. + if(options?.rules) { + let customMatch: boolean = false; + for(const rule of options.rules) { + customMatch = rule.match(state); if(customMatch) { - continue; + if(rule.breakIfMatch) { + boundaries.push(rightPos); + } + break; // as customMatch == true here, this will trigger the `continue` that follows. } } - - // WB5: Do not break between most letters. - // if (isAHLetter(state.left) && isAHLetter(state.right)) - if(state.match(null, SET_AHLETTER, SET_AHLETTER, null)) { - continue; - } - // Do not break across certain punctuation - // WB6: (Don't break before apostrophes in contractions) - const SET_ALL_MIDLETTER = [WordBreakProperty.MidLetter, ...SET_MIDNUMLETQ]; - if(state.match(null, SET_AHLETTER, SET_ALL_MIDLETTER, SET_AHLETTER)) { - continue; - } - // WB7: (Don't break after apostrophes in contractions) - if(state.match(SET_AHLETTER, SET_ALL_MIDLETTER, SET_AHLETTER, null)) { + if(customMatch) { continue; } + } - // WB7a - if(state.match(null, [WordBreakProperty.Hebrew_Letter], [WordBreakProperty.Single_Quote], null)) { - continue; - } - // WB7b - if(state.match(null, - [WordBreakProperty.Hebrew_Letter], - [WordBreakProperty.Double_Quote], - [WordBreakProperty.Hebrew_Letter])) { - continue; - } - // WB7c - if(state.match([WordBreakProperty.Hebrew_Letter], - [WordBreakProperty.Double_Quote], - [WordBreakProperty.Hebrew_Letter], - null)) { - continue; - } - // Do not break within sequences of digits, or digits adjacent to letters. - // e.g., "3a" or "A3" - // WB8 - if(state.match(null, [WordBreakProperty.Numeric], [WordBreakProperty.Numeric], null)) { - continue; - } - // WB9 - if(state.match(null, SET_AHLETTER, [WordBreakProperty.Numeric], null)) { - continue; - } - // WB10 - if(state.match(null, [WordBreakProperty.Numeric], SET_AHLETTER, null)) { - continue; - } - // Do not break within sequences, such as 3.2, 3,456.789 - // WB11 - const SET_ALL_MIDNUM = [WordBreakProperty.MidNum, ...SET_MIDNUMLETQ]; - if(state.match([WordBreakProperty.Numeric], SET_ALL_MIDNUM, [WordBreakProperty.Numeric], null)) { - continue; - } - // WB12 - if(state.match(null, [WordBreakProperty.Numeric], SET_ALL_MIDNUM, [WordBreakProperty.Numeric])) { - continue; - } - // WB13: Do not break between Katakana - if(state.match(null, [WordBreakProperty.Katakana], [WordBreakProperty.Katakana], null)) { - continue; - } - // Do not break from extenders (e.g., U+202F NARROW NO-BREAK SPACE) - // WB13a - const SET_NUM_KAT_LET = [WordBreakProperty.Katakana, - WordBreakProperty.Numeric, - ...SET_AHLETTER]; - if(state.match(null, SET_NUM_KAT_LET, [WordBreakProperty.ExtendNumLet], null)) { - continue; - } - if(state.match(null, [WordBreakProperty.ExtendNumLet], [WordBreakProperty.ExtendNumLet], null)) { - continue; - } - // WB13b - if(state.match(null, [WordBreakProperty.ExtendNumLet], SET_NUM_KAT_LET, null)) { - continue; - } + // WB5: Do not break between most letters. + // if (isAHLetter(state.left) && isAHLetter(state.right)) + if(state.match(null, SET_AHLETTER, SET_AHLETTER, null)) { + continue; + } + // Do not break across certain punctuation + // WB6: (Don't break before apostrophes in contractions) + const SET_ALL_MIDLETTER = [WordBreakProperty.MidLetter, ...SET_MIDNUMLETQ]; + if(state.match(null, SET_AHLETTER, SET_ALL_MIDLETTER, SET_AHLETTER)) { + continue; + } + // WB7: (Don't break after apostrophes in contractions) + if(state.match(SET_AHLETTER, SET_ALL_MIDLETTER, SET_AHLETTER, null)) { + continue; + } - // WB15 & WB16: - // Do not break within emoji flag sequences. That is, do not break between - // regional indicator (RI) symbols if there is an odd number of RI - // characters before the break point. - if (state.right === WordBreakProperty.Regional_Indicator) { - // Emoji flags are actually composed of TWO scalar values, each being a - // "regional indicator". These indicators correspond to Latin letters. Put - // two of them together, and they spell out an ISO 3166-1-alpha-2 country - // code. Since these always come in pairs, NEVER split the pairs! So, if - // we happen to be inside the middle of an odd numbered of - // Regional_Indicators, DON'T SPLIT IT! - nConsecutiveRegionalIndicators += 1; - if ((nConsecutiveRegionalIndicators % 2) == 1) { - continue; - } - } else { - nConsecutiveRegionalIndicators = 0; - } - // WB999: Otherwise, break EVERYWHERE (including around ideographs) - boundaries.push(rightPos); - } while (rightPos < text.length); - - return boundaries; - - ///// Internal utility functions ///// - - /** - * Returns the position of the start of the next scalar value. This jumps - * over surrogate pairs. - * - * If asked for the character AFTER the end of the string, this always - * returns the length of the string. - */ - function positionAfter(pos: number): number { - if (pos >= text.length) { - return text.length; - } else if (isStartOfSurrogatePair(text[pos])) { - return pos + 2; + // WB7a + if(state.match(null, [WordBreakProperty.Hebrew_Letter], [WordBreakProperty.Single_Quote], null)) { + continue; + } + // WB7b + if(state.match(null, + [WordBreakProperty.Hebrew_Letter], + [WordBreakProperty.Double_Quote], + [WordBreakProperty.Hebrew_Letter])) { + continue; + } + // WB7c + if(state.match([WordBreakProperty.Hebrew_Letter], + [WordBreakProperty.Double_Quote], + [WordBreakProperty.Hebrew_Letter], + null)) { + continue; + } + // Do not break within sequences of digits, or digits adjacent to letters. + // e.g., "3a" or "A3" + // WB8 + if(state.match(null, [WordBreakProperty.Numeric], [WordBreakProperty.Numeric], null)) { + continue; + } + // WB9 + if(state.match(null, SET_AHLETTER, [WordBreakProperty.Numeric], null)) { + continue; + } + // WB10 + if(state.match(null, [WordBreakProperty.Numeric], SET_AHLETTER, null)) { + continue; + } + // Do not break within sequences, such as 3.2, 3,456.789 + // WB11 + const SET_ALL_MIDNUM = [WordBreakProperty.MidNum, ...SET_MIDNUMLETQ]; + if(state.match([WordBreakProperty.Numeric], SET_ALL_MIDNUM, [WordBreakProperty.Numeric], null)) { + continue; + } + // WB12 + if(state.match(null, [WordBreakProperty.Numeric], SET_ALL_MIDNUM, [WordBreakProperty.Numeric])) { + continue; + } + // WB13: Do not break between Katakana + if(state.match(null, [WordBreakProperty.Katakana], [WordBreakProperty.Katakana], null)) { + continue; + } + // Do not break from extenders (e.g., U+202F NARROW NO-BREAK SPACE) + // WB13a + const SET_NUM_KAT_LET = [WordBreakProperty.Katakana, + WordBreakProperty.Numeric, + ...SET_AHLETTER]; + if(state.match(null, SET_NUM_KAT_LET, [WordBreakProperty.ExtendNumLet], null)) { + continue; + } + if(state.match(null, [WordBreakProperty.ExtendNumLet], [WordBreakProperty.ExtendNumLet], null)) { + continue; + } + // WB13b + if(state.match(null, [WordBreakProperty.ExtendNumLet], SET_NUM_KAT_LET, null)) { + continue; + } + + // WB15 & WB16: + // Do not break within emoji flag sequences. That is, do not break between + // regional indicator (RI) symbols if there is an odd number of RI + // characters before the break point. + if (state.right === WordBreakProperty.Regional_Indicator) { + // Emoji flags are actually composed of TWO scalar values, each being a + // "regional indicator". These indicators correspond to Latin letters. Put + // two of them together, and they spell out an ISO 3166-1-alpha-2 country + // code. Since these always come in pairs, NEVER split the pairs! So, if + // we happen to be inside the middle of an odd numbered of + // Regional_Indicators, DON'T SPLIT IT! + nConsecutiveRegionalIndicators += 1; + if ((nConsecutiveRegionalIndicators % 2) == 1) { + continue; } - return pos + 1; + } else { + nConsecutiveRegionalIndicators = 0; } - } + // WB999: Otherwise, break EVERYWHERE (including around ideographs) + boundaries.push(rightPos); + } while (rightPos < text.length); - function isStartOfSurrogatePair(character: string) { - let codeUnit = character.charCodeAt(0); - return codeUnit >= 0xD800 && codeUnit <= 0xDBFF; - } + return boundaries; + + ///// Internal utility functions ///// /** - * Return the Word_Break property value for a character. - * Note that - * @param character a scalar value + * Returns the position of the start of the next scalar value. This jumps + * over surrogate pairs. + * + * If asked for the character AFTER the end of the string, this always + * returns the length of the string. */ - function property(character: string, options?: DefaultWordBreakerOptions): WordBreakProperty { - // If there is a customized mapping for the character, prioritize that. - if(options?.propertyMapping) { - let propName = options.propertyMapping(character); - if(propName) { - return propertyVal(propName, options); - } + function positionAfter(pos: number): number { + if (pos >= text.length) { + return text.length; + } else if (isStartOfSurrogatePair(text[pos])) { + return pos + 2; } + return pos + 1; + } +} - // This MUST be a scalar value. - // TODO: remove dependence on character.codepointAt()? - let codepoint = character.codePointAt(0) as number; +function isStartOfSurrogatePair(character: string) { + let codeUnit = character.charCodeAt(0); + return codeUnit >= 0xD800 && codeUnit <= 0xDBFF; +} - return searchForProperty(codepoint, 0, WORD_BREAK_PROPERTY.length - 1); +/** + * Return the Word_Break property value for a character. + * Note that + * @param character a scalar value + */ +function property(character: string, options?: DefaultWordBreakerOptions): WordBreakProperty { + // If there is a customized mapping for the character, prioritize that. + if(options?.propertyMapping) { + let propName = options.propertyMapping(character); + if(propName) { + return propertyVal(propName, options); + } } - function propertyVal(propName: string, options?: DefaultWordBreakerOptions) { - const matcher = (name: string) => name.toLowerCase() == propName.toLowerCase() + // This MUST be a scalar value. + // TODO: remove dependence on character.codepointAt()? + let codepoint = character.codePointAt(0) as number; - const customIndex = options?.customProperties?.findIndex(matcher) ?? -1; - return customIndex != -1 ? -customIndex - 1 : data.propertyMap.findIndex(matcher); - } + return searchForProperty(codepoint, 0, WORD_BREAK_PROPERTY.length - 1); +} - /** - * Binary search for the word break property of a given CODE POINT. - * - * The auto-generated data.ts master array defines a **character range** - * lookup table. If a character's codepoint is equal to or greater than - * the I.Start value for an entry and exclusively less than the next entry, - * it falls in the first entry's range bucket and is classified accordingly - * by this method. - */ - function searchForProperty(codePoint: number, left: number, right: number): WordBreakProperty { - // All items that are not found in the array are assigned the 'Other' property. - if (right < left) { - return WordBreakProperty.Other; - } +function propertyVal(propName: string, options?: DefaultWordBreakerOptions) { + const matcher = (name: string) => name.toLowerCase() == propName.toLowerCase() - let midpoint = left + ~~((right - left) / 2); - let candidate = WORD_BREAK_PROPERTY[midpoint]; + const customIndex = options?.customProperties?.findIndex(matcher) ?? -1; + return customIndex != -1 ? -customIndex - 1 : propertyMap.findIndex(matcher); +} - let nextRange = WORD_BREAK_PROPERTY[midpoint + 1]; - let startOfNextRange = nextRange ? nextRange[I.Start] : Infinity; +/** + * Binary search for the word break property of a given CODE POINT. + * + * The auto-generated data.ts master array defines a **character range** + * lookup table. If a character's codepoint is equal to or greater than + * the I.Start value for an entry and exclusively less than the next entry, + * it falls in the first entry's range bucket and is classified accordingly + * by this method. + */ +function searchForProperty(codePoint: number, left: number, right: number): WordBreakProperty { + // All items that are not found in the array are assigned the 'Other' property. + if (right < left) { + return WordBreakProperty.Other; + } - if (codePoint < candidate[I.Start]) { - return searchForProperty(codePoint, left, midpoint - 1); - } else if (codePoint >= startOfNextRange) { - return searchForProperty(codePoint, midpoint + 1, right); - } + let midpoint = left + ~~((right - left) / 2); + let candidate = WORD_BREAK_PROPERTY[midpoint]; - // We found it! - return candidate[I.Value]; + let nextRange = WORD_BREAK_PROPERTY[midpoint + 1]; + let startOfNextRange = nextRange ? nextRange[I.Start] : Infinity; + + if (codePoint < candidate[I.Start]) { + return searchForProperty(codePoint, left, midpoint - 1); + } else if (codePoint >= startOfNextRange) { + return searchForProperty(codePoint, midpoint + 1, right); } -} -// We cannot export a member whose name is a reserved word when -// implementing a namespace, BUT we can manually make the -// assignment and **declare** it as part of the namespace. -wordBreakers['default'] = wordBreakers.default_; -declare namespace wordBreakers { - export { default_ as default }; + // We found it! + return candidate[I.Value]; } diff --git a/common/models/wordbreakers/src/index.ts b/common/models/wordbreakers/src/index.ts index 2140ae6bf4..7f1682fca2 100644 --- a/common/models/wordbreakers/src/index.ts +++ b/common/models/wordbreakers/src/index.ts @@ -1,8 +1,5 @@ -/// -/// -/// +import placeholder from "./placeholder.js"; +import ascii from "./ascii.js"; +import default_ from "./default/index.js"; -// Let the word breakers be available both in the browser and in Node. -if (typeof module !== "undefined" && (module as any).exports) { - (module as any).exports['wordBreakers'] = wordBreakers; -} +export { placeholder, ascii, default_ as default, default_ as defaultWordbreaker }; \ No newline at end of file diff --git a/common/models/wordbreakers/src/placeholder.ts b/common/models/wordbreakers/src/placeholder.ts index 1105b89fb9..87832505ed 100644 --- a/common/models/wordbreakers/src/placeholder.ts +++ b/common/models/wordbreakers/src/placeholder.ts @@ -1,24 +1,21 @@ -namespace wordBreakers { - - /** - * A **VERY** dumb word breaker that simply splits at words. Do not use this - * word breaker! - * - * @param phrase The phrase in which to break words. - * @deprecated Use a word breaker tailored to your language instead! - */ - export function placeholder(phrase: string): Span[] { - let nextStart = 0; - return phrase.split(/\s+/).map(utterance => { - // XXX: The indices are NOT accurate to the original phrase! - let span = { - start: nextStart, - end: nextStart + utterance.length, - text: utterance, - length: utterance.length - }; - nextStart = span.end; - return span; - }); - } +/** + * A **VERY** dumb word breaker that simply splits at words. Do not use this + * word breaker! + * + * @param phrase The phrase in which to break words. + * @deprecated Use a word breaker tailored to your language instead! + */ +export default function placeholder(phrase: string): Span[] { + let nextStart = 0; + return phrase.split(/\s+/).map(utterance => { + // XXX: The indices are NOT accurate to the original phrase! + let span = { + start: nextStart, + end: nextStart + utterance.length, + text: utterance, + length: utterance.length + }; + nextStart = span.end; + return span; + }); } diff --git a/common/models/wordbreakers/test/test-ascii-word-breaker.js b/common/models/wordbreakers/test/test-ascii-word-breaker.js index 55b9c3b8ba..843df8c002 100644 --- a/common/models/wordbreakers/test/test-ascii-word-breaker.js +++ b/common/models/wordbreakers/test/test-ascii-word-breaker.js @@ -1,5 +1,5 @@ -var assert = require('chai').assert; -var breakASCIIWords = require('../build').wordBreakers['ascii']; +import { assert } from 'chai'; +import { ascii as breakASCIIWords } from '@keymanapp/models-wordbreakers'; describe('The ASCII word breaker', function () { it('should break simple English sentences', function () { diff --git a/common/models/wordbreakers/test/test-default-word-breaker.js b/common/models/wordbreakers/test/test-default-word-breaker.js index 85129b836c..17c03cc4b8 100644 --- a/common/models/wordbreakers/test/test-default-word-breaker.js +++ b/common/models/wordbreakers/test/test-default-word-breaker.js @@ -2,8 +2,8 @@ * Smoke-test the default */ -const assert = require('chai').assert; -const breakWords = require('../build').wordBreakers['default']; +import { assert } from 'chai'; +import { default as breakWords } from '@keymanapp/models-wordbreakers'; const SHY = '\u00AD'; // Other, Format. The "Soft HYphen" - usually invisible unless needed for word-wrapping. diff --git a/common/models/wordbreakers/test/test-placeholder-word-breaker.js b/common/models/wordbreakers/test/test-placeholder-word-breaker.js index dbc1aef34e..0991315cd2 100644 --- a/common/models/wordbreakers/test/test-placeholder-word-breaker.js +++ b/common/models/wordbreakers/test/test-placeholder-word-breaker.js @@ -1,6 +1,6 @@ -const assert = require('chai').assert; -const breakWords = require('../build').wordBreakers['placeholder']; +import { assert } from 'chai'; +import { placeholder as breakWords } from '@keymanapp/models-wordbreakers'; describe('The placeholder word breaker', function () { it('should break simple English sentences', function () { diff --git a/common/models/wordbreakers/tsconfig.json b/common/models/wordbreakers/tsconfig.json index 6575d9e7c5..6c029da40a 100644 --- a/common/models/wordbreakers/tsconfig.json +++ b/common/models/wordbreakers/tsconfig.json @@ -2,14 +2,20 @@ "extends": "../../../tsconfig-base.json", "compilerOptions": { - "module": "none", + "module": "es6", "lib": ["es6"], "target": "es5", + "moduleResolution": "Node16", "declaration": true, "sourceMap": true, - "outFile": "./build/index.js", + "inlineSources": true, + "sourceRoot": "/common/models/wordbreakers/src", "strict": true, "allowJs": false, + "baseUrl": "./", + "outDir": "build/obj", + "tsBuildInfoFile": "build/obj/tsconfig.tsbuildinfo", + "rootDir": "./src" }, "references": [ { "path": "../types" } diff --git a/common/predictive-text/.gitignore b/common/predictive-text/.gitignore index 924ca9595e..b4aef947b4 100644 --- a/common/predictive-text/.gitignore +++ b/common/predictive-text/.gitignore @@ -68,6 +68,7 @@ typings/ **/*.js # Intentional JavaScript files. +!build-bundler.js !testing/**/*.js !unit_tests/**/*.js !polyfills/**/*.js diff --git a/common/predictive-text/browser.tsconfig.json b/common/predictive-text/browser.tsconfig.json deleted file mode 100644 index 929924f9f1..0000000000 --- a/common/predictive-text/browser.tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "extends": "../../tsconfig-base.json", - - "compilerOptions": { - "allowJs": true, - "module": "none", - "outFile": "./build/index.js", - "inlineSources": true, - "sourceMap": true, - "target": "es5", - "lib": ["es6", "dom"] - }, - "include" : ["./*.ts"], - "references": [ - { "path": "../models/types" }, - { "path": "../models/wordbreakers" }, - { "path": "../models/templates" }, - { "path": "../web/lm-message-types" } - ], - "exclude" : [ - "node-defaultWorker.ts", - "virtualizedWorker.ts", - "index.d.ts", - "worker-interface.d.ts", - "node_modules", - "testing" - ], - "files": [ - "../web/lm-worker/build/index.wrapped-for-bundle.ts" - ] -} diff --git a/common/predictive-text/build-bundler.js b/common/predictive-text/build-bundler.js new file mode 100644 index 0000000000..eca5351cad --- /dev/null +++ b/common/predictive-text/build-bundler.js @@ -0,0 +1,57 @@ +/* + * Note: while this file is not meant to exist long-term, it provides a nice + * low-level proof-of-concept for esbuild bundling of the various Web submodules. + * + * Add some extra code at the end of src/index.ts and run it to verify successful bundling! + */ + +import esbuild from 'esbuild'; +import { spawn } from 'child_process'; + +await esbuild.build({ + bundle: true, + sourcemap: true, + format: "esm", + nodePaths: ['../../node_modules'], + entryPoints: { + 'index': 'build/obj/web/index.js', + }, + external: ['fs', 'vm'], + outdir: 'build/lib/web', + outExtension: { '.js': '.mjs' }, + tsconfig: 'src/web/tsconfig.json', + target: "es5" +}); + +// // Direct-use version +// esbuild.buildSync({ +// bundle: true, +// sourcemap: true, +// format: "iife", +// nodePaths: ['..'], +// entryPoints: { +// 'worker-main': 'build/obj/worker-main.js' +// }, +// outdir: 'build/lib', +// tsconfig: 'tsconfig.json', +// target: "es5" +// }); + +const dtsBundleCommand1 = spawn('npx dts-bundle-generator --project src/web/tsconfig.json -o build/lib/web/index.d.ts src/web/index.ts', { + shell: true +}); + +dtsBundleCommand1.stdout.on('data', data => console.log(data.toString())); +dtsBundleCommand1.stderr.on('data', data => console.error(data.toString())); + +// Forces synchronicity; done mostly so that the logs don't get jumbled up. +await new Promise((resolve, reject) => { + dtsBundleCommand1.on('exit', () => { + if(dtsBundleCommand1.exitCode != 0) { + reject(); + process.exit(dtsBundleCommand1.exitCode); + } + resolve(); + }); +}); + diff --git a/common/predictive-text/build.sh b/common/predictive-text/build.sh index 617eaa379b..56680781f8 100755 --- a/common/predictive-text/build.sh +++ b/common/predictive-text/build.sh @@ -22,10 +22,7 @@ cd "$(dirname "$THIS_SCRIPT")" ################################ Main script ################################ -# TODO: once these modules are builder-based, reference here too: -# "@../models/templates" \ -# "@../models/types" \ -# "@../models/wordbreakers" +# "@../models/types" \ # is just a .d.ts, so there's nothing to actually BUILD. builder_describe "Builds the lm-layer module" \ "@/common/web/keyman-version" \ @@ -34,16 +31,11 @@ builder_describe "Builds the lm-layer module" \ "configure" \ "build" \ "test" \ - ":headless A headless, Node-oriented version of the module useful for unit tests" \ - ":browser The standard version of the module for in-browser use" \ "--ci Sets $(builder_term test) action to use CI-based test configurations & reporting" builder_describe_outputs \ - configure /node_modules \ - configure:headless /node_modules \ - configure:browser /node_modules \ - build:headless /common/predictive-text/build/headless.js \ - build:browser /common/predictive-text/build/index.js + configure /node_modules \ + build /common/predictive-text/build/lib/web/index.mjs # is built by the final step. builder_parse "$@" @@ -63,18 +55,14 @@ fi ### BUILD ACTIONS -# Builds the top-level JavaScript file for use in browsers -if builder_start_action build:browser; then - npm run tsc -- -b ./browser.tsconfig.json - - builder_finish_action success build:browser -fi - # Builds the top-level JavaScript file for use on Node -if builder_start_action build:headless; then - npm run tsc -- -b ./tsconfig.json +if builder_start_action build; then + npm run tsc -- -b ./tsconfig.all.json + + # esbuild-bundled products at this level are not intended to be used for anything but testing. + node build-bundler.js - builder_finish_action success build:headless + builder_finish_action success build fi ### TEST ACTIONS @@ -86,16 +74,10 @@ if builder_has_option --ci; then TEST_OPTIONS=--ci fi -if builder_start_action test:headless; then +if builder_start_action test; then # We'll test the included libraries here for now, at least until we have # converted their builds to builder scripts - ./unit_tests/test.sh test:libraries test:headless $TEST_OPTIONS + ./unit_tests/test.sh test:libraries test:headless test:browser $TEST_OPTIONS - builder_finish_action success test:headless -fi - -if builder_start_action test:browser; then - ./unit_tests/test.sh test:browser $TEST_OPTIONS - - builder_finish_action success test:browser -fi + builder_finish_action success test +fi \ No newline at end of file diff --git a/common/predictive-text/index.d.ts b/common/predictive-text/index.d.ts deleted file mode 100644 index 3dd8c4b1f4..0000000000 --- a/common/predictive-text/index.d.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Defines TS method typing for the LMLayer's public API. - * - * Unfortunately, it is not possible to do this programmatically at this time, as TS won't output declarations - * if any JS files are allowed in compilation. We can't convert the embedded worker's intermediate file to TS - * safely due to DedicatedWorkerGlobalScope and a few other typing issues. - */ - -/// -/// - -declare namespace com.keyman.text.prediction { - class LMLayer { - - /** - * Construct the top-level LMLayer interface. This also starts the underlying Worker. - * Make sure to call .load() when using the default Worker. - * - * @param uri URI of the underlying LMLayer worker code. This will usually be a blob: - * or file: URI. If uri is not provided, this will start the default Worker. - */ - constructor(capabilities: Capabilities, worker?: Worker); - - /** - * Initializes the LMLayer worker with the keyboard/platform's capabilities, - * as well as a description of the model required. - */ - loadModel(model: string): Promise; - - /** - * Prepares the LMLayer for reinitialization with a different model/capability set. - */ - unloadModel(); - - predict(transform: Transform, context: Context): Promise; - - // TODO: asynchronous close() method. - // Worker code must recognize message and call self.close(). - - private onMessage(event: MessageEvent): void; - - /** - * Given a function, this utility returns the source code within it, as a string. - * This is intended to unwrap the "wrapped" source code created in the LMLayerWorker - * build process. - * - * @param fn The function whose body will be returned. - */ - static unwrap(fn: Function): string; - - /** - * Converts the INSIDE of a function into a blob URI that can - * be passed as a valid URI for a Worker. - * @param fn Function whose body will be referenced by a URI. - * - * This function makes the following possible: - * - * let worker = new Worker(LMLayer.asBlobURI(function myWorkerCode () { - * postMessage('inside Web Worker') - * function onmessage(event) { - * // handle message inside Web Worker. - * } - * })); - */ - static asBlobURI(fn: Function): string; - - /** - * Clears out any computational resources in use by the LMLayer, including shutting - * down any internal WebWorkers. - */ - public shutdown(): void; - } -} \ No newline at end of file diff --git a/common/predictive-text/index.ts b/common/predictive-text/index.ts deleted file mode 100644 index 887f71c2ce..0000000000 --- a/common/predictive-text/index.ts +++ /dev/null @@ -1,254 +0,0 @@ -/* - * Copyright (c) 2018 National Research Council Canada (author: Eddie A. Santos) - * Copyright (c) 2018 SIL International - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -/// -/// -/// - -/** - * Top-level interface to the Language Modelling layer, or "LMLayer" for short. - * - * The Language Modelling layer provides a way for keyboards to offer prediction and - * correction functionalities. The LMLayer proper runs within a Web Worker, however, - * this class is intended to run in the main thread, and automatically spawn a Web - * Worker, capable of offering predictions. - * - * Since the Worker runs in a different thread, the public methods of this class are - * asynchronous. Methods of note include: - * - * - #loadModel() -- loads a specified model file - * - #predict() -- ask the LMLayer to offer suggestions (predictions or corrections) for - * the input event - * - #unloadModel() -- unloads the LMLayer's currently loaded model, preparing it to - * receive (load) a new model - * - * The top-level LMLayer will automatically starts up its own Web Worker. - */ - -namespace com.keyman.text.prediction { - export class LMLayer { - /** - * The underlying worker instance. By default, this is the LMLayerWorker. - */ - private _worker: Worker; - /** Call this when the LMLayer has sent us the 'ready' message! */ - private _declareLMLayerReady: (conf: Configuration) => void; - private _predictPromises: PromiseStore; - private _wordbreakPromises: PromiseStore; - private _acceptPromises: PromiseStore; - private _revertPromises: PromiseStore; - private _nextToken: number; - private capabilities: Capabilities; - - /** - * Construct the top-level LMLayer interface. This also starts the underlying Worker. - * - * @param uri URI of the underlying LMLayer worker code. This will usually be a blob: - * or file: URI. If uri is not provided, this will start the default Worker. - */ - constructor(capabilities: Capabilities, worker?: Worker, testMode?: boolean) { - // Either use the given worker, or instantiate the default worker. - this._worker = worker || DefaultWorker.constructInstance(); - this._worker.onmessage = this.onMessage.bind(this) - this._declareLMLayerReady = null; - this._predictPromises = new PromiseStore(); - this._wordbreakPromises = new PromiseStore(); - this._acceptPromises = new PromiseStore(); - this._revertPromises = new PromiseStore(); - this._nextToken = Number.MIN_SAFE_INTEGER; - - this.sendConfig(capabilities, !!testMode); - } - - /** - * Initializes the LMLayer worker with the host platform's capability set. - * - * @param capabilities The host platform's capability spec - a model cannot assume access to more context - * than specified by this parameter. - */ - private sendConfig(capabilities: Capabilities, testMode: boolean) { - this._worker.postMessage({ - message: 'config', - capabilities: capabilities, - testMode: testMode - }); - } - - /** - * Initializes the LMLayer worker with a path to the desired model file. - */ - loadModel(modelSource: string, loadType: 'file' | 'raw' = 'file'): Promise { - return new Promise((resolve, _reject) => { - // Sets up so the promise is resolved in the onMessage() callback, when it receives - // the 'ready' message. - this._declareLMLayerReady = resolve; - - let modelSourceSpec: any = { - type: loadType - }; - - if(loadType == 'file') { - modelSourceSpec.file = modelSource; - } else { - modelSourceSpec.code = modelSource; - } - - this._worker.postMessage({ - message: 'load', - source: modelSourceSpec - }); - }); - } - - /** - * Unloads the previously-active model from memory, resetting the LMLayer to prep - * for transition to use of a new model. - */ - public unloadModel() { - this._worker.postMessage({ - message: 'unload' - }); - } - - predict(transform: Transform | Distribution, context: Context): Promise { - let token = this._nextToken++; - return new Promise((resolve, reject) => { - this._predictPromises.make(token, resolve, reject); - this._worker.postMessage({ - message: 'predict', - token: token, - transform: transform, - context: context, - }); - }); - } - - wordbreak(context: Context): Promise { - let token = this._nextToken++; - return new Promise((resolve, reject) => { - this._wordbreakPromises.make(token, resolve, reject); - this._worker.postMessage({ - message: 'wordbreak', - token: token, - context: context - }) - }); - } - - acceptSuggestion(suggestion: Suggestion, context: Context, postTransform: Transform): Promise { - let token = this._nextToken++; - return new Promise((resolve, reject) => { - this._acceptPromises.make(token, resolve, reject); - this._worker.postMessage({ - message: 'accept', - token: token, - suggestion: suggestion, - context: context, - postTransform: postTransform - }); - }); - } - - revertSuggestion(reversion: Reversion, context: Context): Promise { - let token = this._nextToken++; - return new Promise((resolve, reject) => { - this._revertPromises.make(token, resolve, reject); - this._worker.postMessage({ - message: 'revert', - token: token, - reversion: reversion, - context: context - }) - }); - } - - resetContext(context: Context) { - this._worker.postMessage({ - message: 'reset-context', - context: context - }); - } - - // TODO: asynchronous close() method. - // Worker code must recognize message and call self.close(). - - private onMessage(event: MessageEvent): void { - let payload: OutgoingMessage = event.data; - if (payload.message === 'error') { - console.error(payload.log); - if(payload.error) { - console.error(payload.error); - } - } - else if (payload.message === 'ready') { - this._declareLMLayerReady(event.data.configuration); - } else if (payload.message === 'suggestions') { - this._predictPromises.keep(payload.token, payload.suggestions); - } else if (payload.message === 'currentword') { - this._wordbreakPromises.keep(payload.token, payload.word); - } else if (payload.message === 'postaccept') { - this._acceptPromises.keep(payload.token, payload.reversion); - } else if (payload.message === 'postrevert') { - this._revertPromises.keep(payload.token, payload.suggestions); - } else { - // This branch should never execute, but just in case... - //@ts-ignore - throw new Error(`Message not implemented: ${payload.message}`); - } - } - - /** - * Clears out any computational resources in use by the LMLayer, including shutting - * down any internal WebWorkers. - */ - public shutdown() { - this._worker.terminate(); - } - - /** - * Given a function, this utility returns the source code within it, as a string. - * This is intended to unwrap the "wrapped" source code created in the LMLayerWorker - * build process. - * - * @param fn The function whose body will be returned. - */ - static unwrap(fn: Function): string { - let wrapper = fn.toString(); - let match = wrapper.match(/function[^{]+{((?:.|\r|\n)+)}[^}]*$/); - return match[1]; - } - } -} - -(function () { - let ns = com.keyman.text.prediction; - - // Let LMLayer be available both in the browser and in Node. - if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { - module.exports = ns.LMLayer; - //@ts-ignore - ns.LMLayer.PromiseStore = ns.PromiseStore; - } else { - //@ts-ignore - window.LMLayer = ns.LMLayer; - } -}()); \ No newline at end of file diff --git a/common/predictive-text/node-defaultWorker.ts b/common/predictive-text/node-defaultWorker.ts deleted file mode 100644 index 3cf24dcf53..0000000000 --- a/common/predictive-text/node-defaultWorker.ts +++ /dev/null @@ -1,12 +0,0 @@ -/// - -namespace com.keyman.text.prediction { - export class DefaultWorker { - static constructInstance(): Worker { - let scriptStr = LMLayer.unwrap(LMLayerWorkerCode); - let worker = new VirtualizedWorker(scriptStr); - - return worker; - } - } -} \ No newline at end of file diff --git a/common/predictive-text/package.json b/common/predictive-text/package.json index beb7c46615..14c624c1c4 100644 --- a/common/predictive-text/package.json +++ b/common/predictive-text/package.json @@ -1,7 +1,29 @@ { "name": "@keymanapp/lexical-model-layer", "description": "Keyman/Predictive Text integration layer", - "main": "build/index.js", + "main": "build/obj/node/index.js", + "exports": { + ".": { + "node": { + "types": "./build/obj/node/index.d.ts", + "import": "./build/obj/index.js" + }, + "default": { + "types": "./build/obj/web/index.d.ts", + "import": "./build/obj/web/index.js" + } + }, + "./node": "./build/obj/node/index.js", + "./web": "./build/obj/web/index.js", + "./build/obj/node/index.js": "./build/obj/node/index.js", + "./build/obj/web/index.js": "./build/obj/web/index.js" + }, + "imports": { + "#./*.js": "./build/obj/*.js", + "#./node/*.js": "./build/obj/node/*.js", + "#./web/*.js": "./build/obj/web/*.js" + }, + "type": "module", "scripts": { "karma": "karma", "mocha": "mocha", diff --git a/common/predictive-text/promise-store.ts b/common/predictive-text/promise-store.ts deleted file mode 100644 index 4ef29ad7c0..0000000000 --- a/common/predictive-text/promise-store.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2018 National Research Council Canada (author: Eddie A. Santos) - * Copyright (c) 2018 SIL International - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - - -type Resolve = (value?: T | PromiseLike) => void; -type Reject = (reason?: any) => void; -interface PromiseCallbacks { - resolve: Resolve; - reject: Reject; -} - -namespace com.keyman.text.prediction { - /** - * Associate tokens with promises. - * - * First, .make() a promise -- associate a token with resolve/reject callbacks. - * - * You can either .keep() a promise -- resolve() and forget it; - * Or you may also .break() a promise -- reject() and forget it. - * - * is the type of resolved value (value yielded successfully by promise). - */ - export class PromiseStore { - // IE11 offers partial support for new Map(). - // Assume only .get(), .set(), .has(), .delete(), and .size work. - // See: http://kangax.github.io/compat-table/es6/#test-Map - private _promises: Map>; - constructor() { - this._promises = new Map(); - } - /** - * How many promises are currently being tracked? - */ - get length(): number { - return this._promises.size; - } - /** - * Associate a token with its respective resolve and reject callbacks. - */ - make(token: Token, resolve: Resolve, reject: Reject): void { - if (this._promises.has(token)) { - return reject(`Existing request with token ${token}`); - } - this._promises.set(token, { reject, resolve }); - } - /** - * Resolve the promise associated with a token (with a value!). - * Once the promise is resolved, the token is removed.. - */ - keep(token: Token, value: T) { - let callbacks = this._promises.get(token); - if (!callbacks) { - throw new Error(`No promise associated with token: ${token}`); - } - let accept = callbacks.resolve; - this._promises.delete(token); - return accept(value); - } - /** - * Instantly reject and forget a promise associated with the token. - */ - break(token: Token, reason?: any): void { - let callbacks = this._promises.get(token); - if (!callbacks) { - throw new Error(`No promise associated with token: ${token}`); - } - this._promises.delete(token); - callbacks.reject(reason); - } - } -} \ No newline at end of file diff --git a/common/predictive-text/src/index.ts b/common/predictive-text/src/index.ts new file mode 100644 index 0000000000..e087e6957d --- /dev/null +++ b/common/predictive-text/src/index.ts @@ -0,0 +1,3 @@ +export { default as LMLayer } from './lmlayer.js'; +export { default as NodeWorker } from './node/worker.js'; +export { default as WebWorker } from './web/worker.js'; \ No newline at end of file diff --git a/common/predictive-text/src/lmlayer.ts b/common/predictive-text/src/lmlayer.ts new file mode 100644 index 0000000000..f331273e65 --- /dev/null +++ b/common/predictive-text/src/lmlayer.ts @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2018 National Research Council Canada (author: Eddie A. Santos) + * Copyright (c) 2018 SIL International + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import PromiseStore from "./promise-store.js"; + +/// +/// + +/** + * Top-level interface to the Language Modelling layer, or "LMLayer" for short. + * + * The Language Modelling layer provides a way for keyboards to offer prediction and + * correction functionalities. The LMLayer proper runs within a Web Worker, however, + * this class is intended to run in the main thread, and automatically spawn a Web + * Worker, capable of offering predictions. + * + * Since the Worker runs in a different thread, the public methods of this class are + * asynchronous. Methods of note include: + * + * - #loadModel() -- loads a specified model file + * - #predict() -- ask the LMLayer to offer suggestions (predictions or corrections) for + * the input event + * - #unloadModel() -- unloads the LMLayer's currently loaded model, preparing it to + * receive (load) a new model + * + * The top-level LMLayer will automatically starts up its own Web Worker. + */ + +export default class LMLayer { + /** + * The underlying worker instance. By default, this is the LMLayerWorker. + */ + private _worker: Worker; + /** Call this when the LMLayer has sent us the 'ready' message! */ + private _declareLMLayerReady: (conf: Configuration) => void; + private _predictPromises: PromiseStore; + private _wordbreakPromises: PromiseStore; + private _acceptPromises: PromiseStore; + private _revertPromises: PromiseStore; + private _nextToken: number; + private capabilities: Capabilities; + + /** + * Construct the top-level LMLayer interface. This also starts the underlying Worker. + * + * @param uri URI of the underlying LMLayer worker code. This will usually be a blob: + * or file: URI. If uri is not provided, this will start the default Worker. + */ + constructor(capabilities: Capabilities, worker: Worker, testMode?: boolean) { + // Either use the given worker, or instantiate the default worker. + this._worker = worker; + this._worker.onmessage = this.onMessage.bind(this) + this._declareLMLayerReady = null; + this._predictPromises = new PromiseStore(); + this._wordbreakPromises = new PromiseStore(); + this._acceptPromises = new PromiseStore(); + this._revertPromises = new PromiseStore(); + this._nextToken = Number.MIN_SAFE_INTEGER; + + this.sendConfig(capabilities, !!testMode); + } + + /** + * Initializes the LMLayer worker with the host platform's capability set. + * + * @param capabilities The host platform's capability spec - a model cannot assume access to more context + * than specified by this parameter. + */ + private sendConfig(capabilities: Capabilities, testMode: boolean) { + this._worker.postMessage({ + message: 'config', + capabilities: capabilities, + testMode: testMode + }); + } + + /** + * Initializes the LMLayer worker with a path to the desired model file. + */ + loadModel(modelSource: string, loadType: 'file' | 'raw' = 'file'): Promise { + return new Promise((resolve, _reject) => { + // Sets up so the promise is resolved in the onMessage() callback, when it receives + // the 'ready' message. + this._declareLMLayerReady = resolve; + + let modelSourceSpec: any = { + type: loadType + }; + + if(loadType == 'file') { + modelSourceSpec.file = modelSource; + } else { + modelSourceSpec.code = modelSource; + } + + this._worker.postMessage({ + message: 'load', + source: modelSourceSpec + }); + }); + } + + /** + * Unloads the previously-active model from memory, resetting the LMLayer to prep + * for transition to use of a new model. + */ + public unloadModel() { + this._worker.postMessage({ + message: 'unload' + }); + } + + predict(transform: Transform | Distribution, context: Context): Promise { + let token = this._nextToken++; + return new Promise((resolve, reject) => { + this._predictPromises.make(token, resolve, reject); + this._worker.postMessage({ + message: 'predict', + token: token, + transform: transform, + context: context, + }); + }); + } + + wordbreak(context: Context): Promise { + let token = this._nextToken++; + return new Promise((resolve, reject) => { + this._wordbreakPromises.make(token, resolve, reject); + this._worker.postMessage({ + message: 'wordbreak', + token: token, + context: context + }) + }); + } + + acceptSuggestion(suggestion: Suggestion, context: Context, postTransform: Transform): Promise { + let token = this._nextToken++; + return new Promise((resolve, reject) => { + this._acceptPromises.make(token, resolve, reject); + this._worker.postMessage({ + message: 'accept', + token: token, + suggestion: suggestion, + context: context, + postTransform: postTransform + }); + }); + } + + revertSuggestion(reversion: Reversion, context: Context): Promise { + let token = this._nextToken++; + return new Promise((resolve, reject) => { + this._revertPromises.make(token, resolve, reject); + this._worker.postMessage({ + message: 'revert', + token: token, + reversion: reversion, + context: context + }) + }); + } + + resetContext(context: Context) { + this._worker.postMessage({ + message: 'reset-context', + context: context + }); + } + + // TODO: asynchronous close() method. + // Worker code must recognize message and call self.close(). + + private onMessage(event: MessageEvent): void { + let payload: OutgoingMessage = event.data; + if (payload.message === 'error') { + console.error(payload.log); + if(payload.error) { + console.error(payload.error); + } + } + else if (payload.message === 'ready') { + this._declareLMLayerReady(event.data.configuration); + } else if (payload.message === 'suggestions') { + this._predictPromises.keep(payload.token, payload.suggestions); + } else if (payload.message === 'currentword') { + this._wordbreakPromises.keep(payload.token, payload.word); + } else if (payload.message === 'postaccept') { + this._acceptPromises.keep(payload.token, payload.reversion); + } else if (payload.message === 'postrevert') { + this._revertPromises.keep(payload.token, payload.suggestions); + } else { + // This branch should never execute, but just in case... + //@ts-ignore + throw new Error(`Message not implemented: ${payload.message}`); + } + } + + /** + * Clears out any computational resources in use by the LMLayer, including shutting + * down any internal WebWorkers. + */ + public shutdown() { + this._worker.terminate(); + } +} diff --git a/common/predictive-text/src/node/index.ts b/common/predictive-text/src/node/index.ts new file mode 100644 index 0000000000..1a70a48bf3 --- /dev/null +++ b/common/predictive-text/src/node/index.ts @@ -0,0 +1,3 @@ +export { default as LMLayer } from '../lmlayer.js'; +export { default as Worker } from './worker.js'; +export { default as SourcemappedWorker } from './sourcemappedWorker.js'; \ No newline at end of file diff --git a/common/predictive-text/src/node/sourcemappedWorker.ts b/common/predictive-text/src/node/sourcemappedWorker.ts new file mode 100644 index 0000000000..5fd8bb9ec4 --- /dev/null +++ b/common/predictive-text/src/node/sourcemappedWorker.ts @@ -0,0 +1,19 @@ +import VirtualizedWorker from "./virtualizedWorker.js"; +import unwrap from '../unwrap.js'; + +import { LMLayerWorkerCode, LMLayerWorkerSourcemapComment } from "@keymanapp/lm-worker/worker-main.wrapped.js"; + +export default class SourcemappedWorker { + static constructInstance(): Worker { + let scriptStr = unwrap(LMLayerWorkerCode); + + // If this is definitively set to either true or false, tree-shaking can take effect. + // An imported const variable doesn't seem to do it, though. + // if(false) { + scriptStr += '\n' + LMLayerWorkerSourcemapComment; + // } + let worker = new VirtualizedWorker(scriptStr); + + return worker as any as Worker; + } +} \ No newline at end of file diff --git a/common/predictive-text/src/node/tsconfig.json b/common/predictive-text/src/node/tsconfig.json new file mode 100644 index 0000000000..b9567ad113 --- /dev/null +++ b/common/predictive-text/src/node/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../../tsconfig-base.json", + "compilerOptions": { + "allowJs": false, + "declaration": true, + "module": "es6", + "moduleResolution": "Node16", + "inlineSources": true, + "sourceMap": true, + "sourceRoot": "keyman", + "target": "es5", + "lib": ["es6"], + "types": ["node"], + "baseUrl": "../", + "outDir": "../../build/obj", + "tsBuildInfoFile": "../../build/obj/node/tsconfig.tsbuildinfo", + "rootDir": "../" + }, + "include": [ + "*.ts" + ], + "references": [ + { "path": "../tsconfig.json" }, + { "path": "../../../web/lm-worker" } + ] +} \ No newline at end of file diff --git a/common/predictive-text/src/node/virtualizedWorker.ts b/common/predictive-text/src/node/virtualizedWorker.ts new file mode 100644 index 0000000000..f32d265c99 --- /dev/null +++ b/common/predictive-text/src/node/virtualizedWorker.ts @@ -0,0 +1,84 @@ +// We use a subset of the Worker interface here; compiling directly against the true +// WebWorker type definitions would require us to implement more methods than we do. +/// + +import * as fs from 'fs'; +import * as vm from 'vm'; + +class VirtualizedWorkerContext { + // The LMLayerWorker installs itself to 'self', the expected Worker global, so we provide an alias. + self: VirtualizedWorkerContext; + + constructor() { + this.self = this; + } + + postMessage: (message: any) => void; + + importScripts(...scriptNames: string[]) { + /* Use of vm.createContext and script.runInContext allow us to avoid + * polluting the global scope with imports. When we throw away the + * context object, imported scripts will be automatically GC'd. + */ + for(let script of scriptNames) { + this.__importScriptString(fs.readFileSync(script, "UTF-8")); + } + } + + __importScriptString(scriptStr: string) { + let context = vm.createContext(this); + var script = new vm.Script(scriptStr); + script.runInContext(context); + } +} + +/** + * Note: this does not create an actual Worker, separate process, or thread. Everything will + * be executed in-line on a virtualized context. + * + * In the future, it might be nice to use Node's Worker Threads implementation. + */ +export default class VirtualizedWorker implements Worker { + private _workerContext: VirtualizedWorkerContext; + + constructor(scriptStr: string) { + this._workerContext = new VirtualizedWorkerContext(); + // Needs to exist before setting up the worker; must exist by `.install()`. + this._workerContext.postMessage = this.workerPostMessage.bind(this); + + // Initialize the "worker". + this._workerContext.__importScriptString(scriptStr); + } + + // Sends the worker's postMessage messages to the appropriate `onmessage` handler. + private workerPostMessage(message: unknown) { + if(this.onmessage) { + this.onmessage({data: message} as any as MessageEvent); + } + } + + /** + * Accepts a callback function that will receive messages sent from the `VirtualizedWorker`'s `postMessage` function, + * much like the standard `Worker.onmessage`. + */ + onmessage: (this: Worker, ev: MessageEvent) => any; + + postMessage(message: any) { + let msgObj = {data: message}; + let msgJSON = JSON.stringify(msgObj); + + /* + * Execute the command within the virtualized worker's scope. The worker's returned + * `postMessage` calls will still reach outside, as they have a reference to `this` via + * `postMessage` (which we've set to a bound `this.workerPostMessage`). + * + * Among other things, this will allow the worker to use its internal namespaces without issue. + */ + let msgCommand = "onmessage(" + msgJSON + ")"; + this._workerContext.__importScriptString(msgCommand); + } + + terminate(): void { + this._workerContext = null; + } +} \ No newline at end of file diff --git a/common/predictive-text/src/node/worker.ts b/common/predictive-text/src/node/worker.ts new file mode 100644 index 0000000000..68f14b587b --- /dev/null +++ b/common/predictive-text/src/node/worker.ts @@ -0,0 +1,15 @@ +import VirtualizedWorker from "./virtualizedWorker.js"; +import unwrap from '../unwrap.js'; + +import { LMLayerWorkerCode, LMLayerWorkerSourcemapComment } from "@keymanapp/lm-worker/worker-main.wrapped.min.js"; + +export default class Worker { + static constructInstance(): Worker { + let scriptStr = unwrap(LMLayerWorkerCode); + + scriptStr += '\n' + LMLayerWorkerSourcemapComment; + let worker = new VirtualizedWorker(scriptStr); + + return worker as any as Worker; + } +} \ No newline at end of file diff --git a/common/predictive-text/src/promise-store.ts b/common/predictive-text/src/promise-store.ts new file mode 100644 index 0000000000..890ba59239 --- /dev/null +++ b/common/predictive-text/src/promise-store.ts @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2018 National Research Council Canada (author: Eddie A. Santos) + * Copyright (c) 2018 SIL International + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/// + +type Resolve = (value?: T | PromiseLike) => void; +type Reject = (reason?: any) => void; +interface PromiseCallbacks { + resolve: Resolve; + reject: Reject; +} + + +/** + * Associate tokens with promises. + * + * First, .make() a promise -- associate a token with resolve/reject callbacks. + * + * You can either .keep() a promise -- resolve() and forget it; + * Or you may also .break() a promise -- reject() and forget it. + * + * is the type of resolved value (value yielded successfully by promise). + */ +export default class PromiseStore { + // IE11 offers partial support for new Map(). + // Assume only .get(), .set(), .has(), .delete(), and .size work. + // See: http://kangax.github.io/compat-table/es6/#test-Map + private _promises: Map>; + constructor() { + this._promises = new Map(); + } + /** + * How many promises are currently being tracked? + */ + get length(): number { + return this._promises.size; + } + /** + * Associate a token with its respective resolve and reject callbacks. + */ + make(token: Token, resolve: Resolve, reject: Reject): void { + if (this._promises.has(token)) { + return reject(`Existing request with token ${token}`); + } + this._promises.set(token, { reject, resolve }); + } + /** + * Resolve the promise associated with a token (with a value!). + * Once the promise is resolved, the token is removed.. + */ + keep(token: Token, value: T) { + let callbacks = this._promises.get(token); + if (!callbacks) { + throw new Error(`No promise associated with token: ${token}`); + } + let accept = callbacks.resolve; + this._promises.delete(token); + return accept(value); + } + /** + * Instantly reject and forget a promise associated with the token. + */ + break(token: Token, reason?: any): void { + let callbacks = this._promises.get(token); + if (!callbacks) { + throw new Error(`No promise associated with token: ${token}`); + } + this._promises.delete(token); + callbacks.reject(reason); + } +} \ No newline at end of file diff --git a/common/predictive-text/src/tsconfig.json b/common/predictive-text/src/tsconfig.json new file mode 100644 index 0000000000..40fb9f45cd --- /dev/null +++ b/common/predictive-text/src/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig-base.json", + + "compilerOptions": { + "allowJs": false, + "declaration": true, + "module": "es6", + "moduleResolution": "Node16", + "inlineSources": true, + "sourceMap": true, + "sourceRoot": "keyman", + "target": "es5", + "lib": ["es6"], + "types": ["node"], + "baseUrl": "./", + "outDir": "../build/obj", + "tsBuildInfoFile": "../build/obj/tsconfig.tsbuildinfo", + "rootDir": "./" + }, + "references": [ + { "path": "../../web/utils" }, + { "path": "../../models/types"}, + { "path": "../../models/wordbreakers"}, + { "path": "../../web/lm-message-types"}, + { "path": "../../models/templates"} + ], + "include" : [ "./*.ts" ], + "exclude" : [ + "node", + "web", + "index.ts" // It's handled by the final layer. + ] +} diff --git a/common/predictive-text/src/unwrap.ts b/common/predictive-text/src/unwrap.ts new file mode 100644 index 0000000000..410c9db718 --- /dev/null +++ b/common/predictive-text/src/unwrap.ts @@ -0,0 +1,11 @@ +/** + * Given a function, this utility returns the source code within it, as a string. + * This is intended to unwrap the "wrapped" source code created in the LMLayerWorker + * build process. + * + * @param fn The function whose body will be returned. + */ +export default function unwrap(encodedSrc: string): string { + let wrapper = decodeURIComponent(encodedSrc); + return wrapper; +} \ No newline at end of file diff --git a/common/predictive-text/src/web/index.ts b/common/predictive-text/src/web/index.ts new file mode 100644 index 0000000000..1a70a48bf3 --- /dev/null +++ b/common/predictive-text/src/web/index.ts @@ -0,0 +1,3 @@ +export { default as LMLayer } from '../lmlayer.js'; +export { default as Worker } from './worker.js'; +export { default as SourcemappedWorker } from './sourcemappedWorker.js'; \ No newline at end of file diff --git a/common/predictive-text/src/web/sourcemappedWorker.ts b/common/predictive-text/src/web/sourcemappedWorker.ts new file mode 100644 index 0000000000..da7ed77eb9 --- /dev/null +++ b/common/predictive-text/src/web/sourcemappedWorker.ts @@ -0,0 +1,34 @@ +import unwrap from '../unwrap.js'; +import { LMLayerWorkerCode, LMLayerWorkerSourcemapComment } from "@keymanapp/lm-worker/worker-main.wrapped.js"; + +export default class SourcemappedWorker { + static constructInstance(): Worker { + return new Worker(this.asBlobURI(LMLayerWorkerCode)); + } + + /** + * Converts the INSIDE of a function into a blob URI that can + * be passed as a valid URI for a Worker. + * @param fn Function whose body will be referenced by a URI. + * + * This function makes the following possible: + * + * let worker = new Worker(LMLayer.asBlobURI(function myWorkerCode () { + * postMessage('inside Web Worker') + * function onmessage(event) { + * // handle message inside Web Worker. + * } + * })); + */ + static asBlobURI(encodedSrc: string): string { + let code = unwrap(encodedSrc); + + // If this is definitively set to either true or false, tree-shaking can take effect. + // An imported const variable doesn't seem to do it, though. + // if(false) { + code += '\n' + LMLayerWorkerSourcemapComment; + // } + let blob = new Blob([code], { type: 'text/javascript' }); + return URL.createObjectURL(blob); + } +} \ No newline at end of file diff --git a/common/predictive-text/src/web/tsconfig.json b/common/predictive-text/src/web/tsconfig.json new file mode 100644 index 0000000000..9e1f43978e --- /dev/null +++ b/common/predictive-text/src/web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../../tsconfig-base.json", + "compilerOptions": { + "allowJs": false, + "declaration": true, + "module": "es6", + "moduleResolution": "Node16", + "inlineSources": true, + "sourceMap": true, + "sourceRoot": "keyman", + "target": "es5", + "lib": ["es6", "DOM"], + "types": ["node"], + "baseUrl": "../", + "outDir": "../../build/obj", + "tsBuildInfoFile": "../../build/obj/web/tsconfig.tsbuildinfo", + "rootDir": "../" + }, + "include": [ + "*.ts" + ], + "references": [ + { "path": "../tsconfig.json" } + ] +} \ No newline at end of file diff --git a/common/predictive-text/src/web/worker.ts b/common/predictive-text/src/web/worker.ts new file mode 100644 index 0000000000..372887fcdd --- /dev/null +++ b/common/predictive-text/src/web/worker.ts @@ -0,0 +1,34 @@ +import unwrap from '../unwrap.js'; +import { LMLayerWorkerCode, LMLayerWorkerSourcemapComment } from "@keymanapp/lm-worker/worker-main.wrapped.min.js"; + +export default class DefaultWorker { + static constructInstance(): Worker { + return new Worker(this.asBlobURI(LMLayerWorkerCode)); + } + + /** + * Converts the INSIDE of a function into a blob URI that can + * be passed as a valid URI for a Worker. + * @param fn Function whose body will be referenced by a URI. + * + * This function makes the following possible: + * + * let worker = new Worker(LMLayer.asBlobURI(function myWorkerCode () { + * postMessage('inside Web Worker') + * function onmessage(event) { + * // handle message inside Web Worker. + * } + * })); + */ + static asBlobURI(encodedSrc: string): string { + let code = unwrap(encodedSrc); + + // If this is definitively set to either true or false, tree-shaking can take effect. + // An imported const variable doesn't seem to do it, though. + // if(false) { + code += '\n' + LMLayerWorkerSourcemapComment; + // } + let blob = new Blob([code], { type: 'text/javascript' }); + return URL.createObjectURL(blob); + } +} \ No newline at end of file diff --git a/common/predictive-text/worker-interface.d.ts b/common/predictive-text/src/worker-interface.d.ts similarity index 100% rename from common/predictive-text/worker-interface.d.ts rename to common/predictive-text/src/worker-interface.d.ts diff --git a/common/predictive-text/tsconfig.all.json b/common/predictive-text/tsconfig.all.json new file mode 100644 index 0000000000..33fe749e21 --- /dev/null +++ b/common/predictive-text/tsconfig.all.json @@ -0,0 +1,34 @@ +{ + // Due to the heterogenous target mix supported by the LMLayer, the two different "worker" types + // require separate sub-tsconfigs, both of which require common references. The multi-config + // setup in this project and its subfolders ensure all modules can compile cleanly and in the + // necessary compilation order. + "extends": "../../tsconfig-base.json", + + "compilerOptions": { + "allowJs": false, + "declaration": true, + "module": "es6", + "moduleResolution": "Node16", + "inlineSources": true, + "sourceMap": true, + "sourceRoot": "keyman", + "target": "es5", + "lib": ["es6"], + "types": ["node"], + "baseUrl": "./", + "outDir": "build/obj", + "tsBuildInfoFile": "build/obj/tsconfig.all.tsbuildinfo", + "rootDir": "./src" + }, + "files": [], + "references": [ + { "path": "../web/utils" }, + { "path": "../models/types"}, + { "path": "../models/wordbreakers"}, + { "path": "../web/lm-message-types"}, + { "path": "../models/templates"}, + { "path": "src/node" }, + { "path": "src/web" } + ], +} diff --git a/common/predictive-text/tsconfig.json b/common/predictive-text/tsconfig.json deleted file mode 100644 index c364f699a1..0000000000 --- a/common/predictive-text/tsconfig.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "extends": "../../tsconfig-base.json", - - "compilerOptions": { - "allowJs": true, - "outFile": "build/headless.js", - "module": "none", - "inlineSources": true, - "sourceMap": true, - "target": "es5", - "lib": ["es6"] - }, - "references": [ - { "path": "../web/utils" }, - { "path": "../models/types"}, - { "path": "../models/wordbreakers"}, - { "path": "../web/lm-message-types"}, - { "path": "../models/templates"}, - ], - "include" : [ "./*.ts" ], - "exclude" : [ - "web-defaultWorker.ts", - "node_modules", - "testing" - ], - "files": [ - // By renaming the output JS as TS, and including the @ts-nocheck directive, we can - // bypass wrapped-worker typing issues while still bundling the file as part of the - // tsc build! - "../web/lm-worker/build/index.wrapped-for-bundle.ts" - ] -} diff --git a/common/predictive-text/unit_tests/headless/promise-store.js b/common/predictive-text/unit_tests/headless/promise-store.js index f14b9c8555..0809b215ce 100644 --- a/common/predictive-text/unit_tests/headless/promise-store.js +++ b/common/predictive-text/unit_tests/headless/promise-store.js @@ -1,7 +1,8 @@ -var assert = require('chai').assert; -var sinon = require('sinon'); +import { assert } from 'chai'; +import sinon from 'sinon'; -let PromiseStore = require('../../build/headless').PromiseStore; +import PromiseStore from '#./promise-store.js'; +import { randomToken } from '@keymanapp/common-test-resources/model-helpers.mjs'; describe('PromiseStore', function () { describe('.make()', function () { diff --git a/common/predictive-text/unit_tests/headless/top-level-lmlayer.js b/common/predictive-text/unit_tests/headless/top-level-lmlayer.js index f4b891c8e0..78b071e3c4 100644 --- a/common/predictive-text/unit_tests/headless/top-level-lmlayer.js +++ b/common/predictive-text/unit_tests/headless/top-level-lmlayer.js @@ -1,11 +1,29 @@ -var assert = require('chai').assert; -var sinon = require('sinon'); +import { assert } from 'chai'; +import sinon from 'sinon'; -let LMLayer = require('../../build/headless'); +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { LMLayer } from '#./node/index.js'; +import { capabilities } from '@keymanapp/common-test-resources/model-helpers.mjs'; // Test the top-level LMLayer interface. // Note: these tests can only be run after BOTH stages of compilation are completed. describe('LMLayer', function() { + /** + * Returns an object implementing *enough* of the Worker + * interface to fool the LMLayer into thinking it's + * communicating with a bona fide Web Worker. + * + * @returns {Worker} an object with sinon.fake() instances. + */ + function createFakeWorker(postMessage) { + return { + postMessage: postMessage ? sinon.fake(postMessage) : sinon.fake(), + onmessage: null + }; + } + describe('[[constructor]]', function () { it('should accept a Worker to instantiate', function () { new LMLayer(capabilities(), createFakeWorker()); @@ -29,7 +47,7 @@ describe('LMLayer', function() { let fakeWorker = createFakeWorker(); let lmLayer = new LMLayer(capabilities(), fakeWorker); - lmLayer.loadModel("./unit_tests/in_browser/resources/models/simple-dummy.js"); + lmLayer.loadModel(require.resolve("@keymanapp/common-test-resources/models/simple-dummy.js")); assert.isFunction(fakeWorker.onmessage, 'LMLayer failed to set a callback!'); }); @@ -37,7 +55,7 @@ describe('LMLayer', function() { it('should send the `load` message to the LMLayer', async function () { let fakeWorker = createFakeWorker(fakePostMessage); let lmLayer = new LMLayer(capabilities(), fakeWorker); - let configuration = await lmLayer.loadModel("./unit_tests/in_browser/resources/models/simple-dummy.js"); + let configuration = await lmLayer.loadModel(require.resolve("@keymanapp/common-test-resources/models/simple-dummy.js")); assert.propertyVal(fakeWorker.postMessage, 'callCount', 2); // In the "Worker", assert the message looks right and @@ -95,34 +113,6 @@ describe('LMLayer', function() { }) }); - // Since the Blob API is limited to browsers, look for those - // tests for .asBlobURI() in the in_browser tests. - describe('.unwrap', function () { - it('should return the inner code of a function', function () { - // Create a multi-line function body we can match in a RegExp. - let text = LMLayer.unwrap(function hello() { - var hello; - var world; - }); - // Unwrap should give us back ONLY the body. Whitespace isn't really important. - assert.match(text, /^\s*var\s+hello;\s*var\s+world;\s*$/); - }); - }); - - /** - * Returns an object implementing *enough* of the Worker - * interface to fool the LMLayer into thinking it's - * communicating with a bona fide Web Worker. - * - * @returns {Worker} an object with sinon.fake() instances. - */ - function createFakeWorker(postMessage) { - return { - postMessage: postMessage ? sinon.fake(postMessage) : sinon.fake(), - onmessage: null - }; - } - /** * Call a function in the future, i.e., later in the event loop. * The call does NOT block the current execution. diff --git a/common/predictive-text/unit_tests/headless/worker-dummy-integration.js b/common/predictive-text/unit_tests/headless/worker-dummy-integration.js index f1d8c375d4..6e6adb426f 100644 --- a/common/predictive-text/unit_tests/headless/worker-dummy-integration.js +++ b/common/predictive-text/unit_tests/headless/worker-dummy-integration.js @@ -1,9 +1,11 @@ -var assert = require('chai').assert; -var fs = require('fs'); -let LMLayer = require('../../build/headless'); +import { assert } from 'chai'; +import fs from 'fs'; -// Load the LMLayerWorkerCode function into this context -require('node:vm').runInThisContext(fs.readFileSync(__dirname + '/../../../web/lm-worker/build/index.js', 'utf-8')); +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { LMLayer, SourcemappedWorker as Worker } from '#./node/index.js'; +import { capabilities, iGotDistractedByHazel } from '@keymanapp/common-test-resources/model-helpers.mjs'; /* * Shows off the LMLayer API, using the full prediction interface. @@ -16,7 +18,7 @@ require('node:vm').runInThisContext(fs.readFileSync(__dirname + '/../../../web/l describe('LMLayer using dummy model', function () { describe('Prediction', function () { it('will predict future suggestions (loaded from file)', function () { - var lmLayer = new LMLayer(capabilities()); + var lmLayer = new LMLayer(capabilities(), Worker.constructInstance()); var stripIDs = function(suggestions) { suggestions.forEach(function(suggestion) { @@ -29,7 +31,7 @@ describe('LMLayer using dummy model', function () { // Not done yet, as this test case is a slightly-edited copy of the in-browser version. return lmLayer.loadModel( // We're running headlessly, so the path can be relative to the npm root directory. - "unit_tests/in_browser/resources/models/simple-dummy.js" + require.resolve("@keymanapp/common-test-resources/models/simple-dummy.js") ).then(function (actualConfiguration) { return Promise.resolve(); }).then(function () { @@ -55,7 +57,7 @@ describe('LMLayer using dummy model', function () { }); it('will predict future suggestions (loaded from raw source)', function () { - var lmLayer = new LMLayer(capabilities()); + var lmLayer = new LMLayer(capabilities(), Worker.constructInstance()); var stripIDs = function(suggestions) { suggestions.forEach(function(suggestion) { @@ -64,7 +66,7 @@ describe('LMLayer using dummy model', function () { }; // We're running headlessly, so the path can be relative to the npm root directory. - let modelCode = fs.readFileSync("./unit_tests/in_browser/resources/models/simple-dummy.js").toString(); + let modelCode = fs.readFileSync(require.resolve("@keymanapp/common-test-resources/models/simple-dummy.js")).toString(); // We're testing many as asynchronous messages in a row. // this would be cleaner using async/await syntax. @@ -98,14 +100,14 @@ describe('LMLayer using dummy model', function () { describe('Wordbreaking', function () { it('will perform (default) wordbreaking and return word at caret', function () { - var lmLayer = new LMLayer(capabilities()); + var lmLayer = new LMLayer(capabilities(), Worker.constructInstance()); // We're testing many as asynchronous messages in a row. // this would be cleaner using async/await syntax. // Not done yet, as this test case is a slightly-edited copy of the in-browser version. return lmLayer.loadModel( // We're running headlessly, so the path can be relative to the npm root directory. - "unit_tests/in_browser/resources/models/simple-dummy.js" + require.resolve("@keymanapp/common-test-resources/models/simple-dummy.js") ).then(function (actualConfiguration) { return Promise.resolve(); }).then(function () { diff --git a/common/predictive-text/unit_tests/headless/worker-trie-integration.js b/common/predictive-text/unit_tests/headless/worker-trie-integration.js index ca543301a2..88ae133097 100644 --- a/common/predictive-text/unit_tests/headless/worker-trie-integration.js +++ b/common/predictive-text/unit_tests/headless/worker-trie-integration.js @@ -1,5 +1,10 @@ -var assert = require('chai').assert; -let LMLayer = require('../../build/headless'); +import { assert } from 'chai'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { LMLayer, SourcemappedWorker as Worker } from '#./node/index.js'; +import { capabilities } from '@keymanapp/common-test-resources/model-helpers.mjs'; /* * How to run the worlist @@ -8,14 +13,14 @@ describe('LMLayer using the trie model', function () { describe('Prediction', function () { var EXPECTED_SUGGESTIONS = 3; it('will predict an empty buffer', function () { - var lmLayer = new LMLayer(capabilities()); + var lmLayer = new LMLayer(capabilities(), Worker.constructInstance()); // We're testing many as asynchronous messages in a row. // this would be cleaner using async/await syntax. // Not done yet, as this test case is a slightly-edited copy of the in-browser version. return lmLayer.loadModel( // We're running headlessly, so the path can be relative to the npm root directory. - "unit_tests/in_browser/resources/models/simple-trie.js" + require.resolve("@keymanapp/common-test-resources/models/simple-trie.js") ).then(function (_actualConfiguration) { return Promise.resolve(); }).then(function () { @@ -50,11 +55,11 @@ describe('LMLayer using the trie model', function () { // // https://community.software.sil.org/t/search-term-to-key-in-lexical-model-not-working-both-ways-by-default/3133 it('should use the default searchTermToKey()', function () { - var lmLayer = new LMLayer(capabilities()); + var lmLayer = new LMLayer(capabilities(), Worker.constructInstance()); return lmLayer.loadModel( // We're running headlessly, so the path can be relative to the npm root directory. - "unit_tests/in_browser/resources/models/naive-trie.js" + require.resolve("@keymanapp/common-test-resources/models/naive-trie.js") ).then(function (_actualConfiguration) { return Promise.resolve(); }).then(function () { diff --git a/common/predictive-text/unit_tests/helpers.js b/common/predictive-text/unit_tests/helpers.js deleted file mode 100644 index 1ece33428b..0000000000 --- a/common/predictive-text/unit_tests/helpers.js +++ /dev/null @@ -1,139 +0,0 @@ -/** - * @file helpers - * - * Globally-defined helper functions for use in in Mocha tests. - */ - -var fs = require("fs"); -var vm = require("vm"); - -// Choose the appropriate global object. Either `global` in -// Node, or `window` in browsers. -var _ = global || window; - -/** - * Creates a MessageEvent (for inter-worker communication), with the given data payload. - * - * @param {*} data - */ -_.createMessageEventWithData = function createMessageEventWithData(data) { - return { data }; -} - -/** - * Creates a simple, default capabilities object for standard-case LMLayer init. - */ -_.capabilities = function capabilities() { - return { - maxLeftContextCodePoints: 64 - } -} - -/** - * Mimics a message from the outer LMLayer shell with a simple, default config object. - * Used for Worker tests. - */ -_.configWorker = function configWorker(worker) { - worker.onMessage(createMessageEventWithData({ - message: 'config', - capabilities: _.capabilities() - })); -} - -/** - * A valid model that suggests exactly what you want it to suggest. - * - * @returns {ModelDescription} - */ -_.dummyModel = function dummyModel(futureSuggestions) { - return { - type: 'dummy', - futureSuggestions: futureSuggestions || [] - }; -} -/** - * Capabilities of a keyboard that will ONLY send left-sided capabilities. - * The keyboard does not support deleting to the right. - * - * @returns {Capabilities} - */ -_.defaultCapabilities = function defaultCapabilities() { - return { - maxLeftContextCodeUnits: 64 - }; -} - -/** - * Returns the Context of an empty buffer; no text, at both the start and - * end of the buffer. - * - * @returns {Context} - */ -_.emptyContext = function emptyContext() { - return { - left: '', - startOfBuffer: true, - endOfBuffer: true - }; -} - -/** - * Returns a Transform that, when applied, makes no changes to the buffer. - * - * @returns {Transform} - */ -_.zeroTransform = function zeroTransform() { - return { - insert: '', - deleteLeft: 0, - }; -} - -/** - * Returns a random token. NOT guaranteed to be unique. - * - * @returns {Token} - */ -_.randomToken = function randomToken() { - var range = Number.MAX_SAFE_INTEGER - Number.MIN_SAFE_INTEGER; - return Math.random() * range + Number.MIN_SAFE_INTEGER; -} - -// Use fixtures used in browser tests IN NODE! -if (typeof require === 'function') { - _.iGotDistractedByHazel = function () { - return jsonFixture('future_suggestions/i_got_distracted_by_hazel'); - } - - /** - * Return a JSON fixture - */ - _.jsonFixture = function (name) { - // Assuming this file structure: - // . - // ├── helpers.js - // └── in_browser - // └── json - // ├── future_suggestions - // │ └── ... - // └── wordlists - // └── ... - return require('./in_browser/json/' + name); - } - - // This worker-global function does not exist by default in Node! - _.importScriptsWith = function(context) { - return function() { // the constructed context's importScripts method. - - /* Use of vm.createContext and script.runInContext allow us to avoid - * polluting the global scope with imports. When we throw away the - * context object, imported scripts will be automatically GC'd. - */ - for(var i=0; i < arguments.length; i++) { - context = vm.createContext(context); - var script = new vm.Script(fs.readFileSync(arguments[i])); - script.runInContext(context); - } - } - } -} diff --git a/common/predictive-text/unit_tests/in_browser/CI.conf.cjs b/common/predictive-text/unit_tests/in_browser/CI.conf.cjs new file mode 100644 index 0000000000..eaedb4a1ca --- /dev/null +++ b/common/predictive-text/unit_tests/in_browser/CI.conf.cjs @@ -0,0 +1,4 @@ +var BASE_CONFIG = require("./base.conf.cjs"); +var ci_config_adapter = require("../../../test/resources/karma-browserstack-config.cjs"); + +module.exports = ci_config_adapter(BASE_CONFIG, "full"); \ No newline at end of file diff --git a/common/predictive-text/unit_tests/in_browser/CI.conf.js b/common/predictive-text/unit_tests/in_browser/CI.conf.js deleted file mode 100644 index d3d2b02e40..0000000000 --- a/common/predictive-text/unit_tests/in_browser/CI.conf.js +++ /dev/null @@ -1,4 +0,0 @@ -var BASE_CONFIG = require("./base.conf.js"); -var ci_config_adapter = require("../../../../common/test/resources/karma-browserstack-config.js"); - -module.exports = ci_config_adapter(BASE_CONFIG, "full"); \ No newline at end of file diff --git a/common/predictive-text/unit_tests/in_browser/base.conf.js b/common/predictive-text/unit_tests/in_browser/base.conf.cjs similarity index 55% rename from common/predictive-text/unit_tests/in_browser/base.conf.js rename to common/predictive-text/unit_tests/in_browser/base.conf.cjs index 2c62af1854..6fa9de9f22 100644 --- a/common/predictive-text/unit_tests/in_browser/base.conf.js +++ b/common/predictive-text/unit_tests/in_browser/base.conf.cjs @@ -4,7 +4,7 @@ module.exports = { // base path that will be used to resolve all patterns (eg. files, exclude) - basePath: './', + basePath: '../../../../', // Root directory of the repository(!) client: { /* `client.args` here is passed to the test runner page as `__karma__.config.args`. @@ -31,16 +31,27 @@ module.exports = { // list of files / patterns to load in the browser files: [ - // Include the generated worker code. Make sure it's linked before any of the test cases. - '../../../web/lm-worker/build/index.js', - '../../build/index.js', - '../../../../common/test/resources/timeout-adapter.js', - 'helpers.js', // Provides utility helpers and objects for tests. - 'cases/**/*.js', // Where the tests actually reside. + // { pattern: 'node_modules/**/*.js', served: true, watched: false, included: false }, + // { pattern: 'node_modules/**/*.mjs', served: true, watched: false, included: false, type: "module" }, + // { pattern: 'node_modules/**/*.cjs', served: true, watched: false, included: false }, + + // Provides utility helpers and objects for tests. + { pattern: 'common/predictive-text/unit_tests/in_browser/helpers.mjs', served: true, watched: true, included: false}, + + // Where the tests actually reside. + { pattern: 'common/predictive-text/unit_tests/in_browser/cases/**/*.js', type: 'module' }, + + 'common/test/resources/*.js', + 'common/test/resources/json/models/**/*.json', + { pattern: 'common/web/lm-worker/build/lib/*.js', watched: true, served: true, included: false}, + { pattern: 'common/web/lm-worker/build/lib/*.js.map', watched: true, served: true, included: false}, + { pattern: 'common/predictive-text/build/obj/**/*.*', watched: true, served: true, included: false }, + { pattern: 'common/predictive-text/build/obj/**/*.js.map', watched: true, served: true, included: false }, + { pattern: 'common/predictive-text/build/lib/**/*.*', watched: true, served: true, included: false }, + { pattern: 'common/predictive-text/build/lib/**/*.js.map', watched: true, served: true, included: false }, // We don't have anything in these locations... yet. But they'll be useful for test resources. - 'json/**/*.json', // Where pre-loaded JSON resides. - {pattern: 'resources/**/*.*', watched: true, served: true, included: false}, // General testing resources. + {pattern: 'common/test/resources/**/*.*', watched: true, served: true, included: false}, // General testing resources. ], // list of files / patterns to exclude @@ -50,17 +61,19 @@ module.exports = { // preprocess matching files before serving them to the browser // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor preprocessors: { - 'json/**/*.json' : ['json_fixtures'] + 'common/test/resources/json/models/**/*.json' : ['json_fixtures'] }, // Settings to properly configure how JSON fixtures are automatically loaded by Karma. jsonFixturesPreprocessor: { - stripPrefix: 'json/', + stripPrefix: 'common/test/resources/json/', variableName: '__json__' }, proxies: { - "/resources/": "/base/resources/" + "/resources/": "/base/common/test/resources/", + // "/node_modules/": "/base/node_modules/", + // "/@keymanapp/lm-worker/": "/base/node_modules/@keymanapp/lm-worker/" }, // web server port diff --git a/common/predictive-text/unit_tests/in_browser/cases/top-level-lmlayer.js b/common/predictive-text/unit_tests/in_browser/cases/top-level-lmlayer.js index 12c0c2aff1..d2481e522c 100644 --- a/common/predictive-text/unit_tests/in_browser/cases/top-level-lmlayer.js +++ b/common/predictive-text/unit_tests/in_browser/cases/top-level-lmlayer.js @@ -1,22 +1,23 @@ var assert = chai.assert; -var LMLayer = com.keyman.text.prediction.LMLayer; -var DefaultWorker = com.keyman.text.prediction.DefaultWorker; + +import { LMLayer, Worker as WorkerBuilder } from "../../../build/lib/web/index.mjs"; +import * as helpers from "../helpers.mjs"; describe('LMLayer', function () { this.timeout(testconfig.timeouts.standard); describe('[[constructor]]', function () { it('should construct with a single argument', function () { - let lmLayer = new LMLayer(helpers.defaultCapabilities, null, true); + let lmLayer = new LMLayer(helpers.defaultCapabilities, WorkerBuilder.constructInstance(), true); assert.instanceOf(lmLayer, LMLayer); lmLayer.shutdown(); }); }); - describe('#asBlobURI()', function () { + describe.skip('#asBlobURI()', function () { // #asBlobURI() requires browser APIs, hence why it cannot be tested headless in Node. it('should take a function and convert it into a blob function', function (done) { - let uri = DefaultWorker.asBlobURI(function dummyHandler() { + let uri = WorkerBuilder.asBlobURI(function dummyHandler() { // Post something weird, so we can be reasonably certain the Web Worker is... // well, working. // WARNING: Do NOT refactor this string as a variable. It **MUST** remain a string diff --git a/common/predictive-text/unit_tests/in_browser/cases/worker-dummy-integration.js b/common/predictive-text/unit_tests/in_browser/cases/worker-dummy-integration.js index 993975dbfe..b3c8ec3e9c 100644 --- a/common/predictive-text/unit_tests/in_browser/cases/worker-dummy-integration.js +++ b/common/predictive-text/unit_tests/in_browser/cases/worker-dummy-integration.js @@ -1,5 +1,7 @@ var assert = chai.assert; -var LMLayer = com.keyman.text.prediction.LMLayer; + +import { LMLayer, Worker } from "../../../build/lib/web/index.mjs"; +import * as helpers from "../helpers.mjs"; /* * Shows off the LMLayer API, using the full prediction interface. @@ -17,7 +19,7 @@ describe('LMLayer using dummy model', function () { this.timeout(testconfig.timeouts.standard * 3); // This one makes multiple subsequent calls across // the WebWorker boundary, so we should be generous here. - var lmLayer = new LMLayer(helpers.defaultCapabilities, null, true); + var lmLayer = new LMLayer(helpers.defaultCapabilities, Worker.constructInstance(), true); var stripIDs = function(suggestions) { suggestions.forEach(function(suggestion) { @@ -60,7 +62,7 @@ describe('LMLayer using dummy model', function () { it('will perform (default) wordbreaking and return word at caret', function () { this.timeout(testconfig.timeouts.standard * 3); // This one makes multiple subsequent calls across // the WebWorker boundary, so we should be generous here. - var lmLayer = new LMLayer(helpers.defaultCapabilities); + var lmLayer = new LMLayer(helpers.defaultCapabilities, Worker.constructInstance()); // We're testing many as asynchronous messages in a row. // this would be cleaner using async/await syntax, but @@ -94,6 +96,6 @@ describe('LMLayer using dummy model', function () { } function iGotDistractedByHazel() { - return __json__['future_suggestions/i_got_distracted_by_hazel']; + return __json__['models/future_suggestions/i_got_distracted_by_hazel']; } }); diff --git a/common/predictive-text/unit_tests/in_browser/cases/worker-trie-integration.js b/common/predictive-text/unit_tests/in_browser/cases/worker-trie-integration.js index 833cda2aea..c068daf8f1 100644 --- a/common/predictive-text/unit_tests/in_browser/cases/worker-trie-integration.js +++ b/common/predictive-text/unit_tests/in_browser/cases/worker-trie-integration.js @@ -1,5 +1,7 @@ var assert = chai.assert; -var LMLayer = com.keyman.text.prediction.LMLayer; + +import { LMLayer, Worker } from "../../../build/lib/web/index.mjs"; +import * as helpers from "../helpers.mjs"; /* * How to run the worlist @@ -16,7 +18,7 @@ describe('LMLayer using the trie model', function () { // Parameter 3 = true: enables 'test mode', disables correction-search timeout. // This helps prevent the correction-search timeout from flaking out periodically during unit tests in // CI, since remote servers / devices are involved. - var lmLayer = new LMLayer(helpers.defaultCapabilities, null, true); + var lmLayer = new LMLayer(helpers.defaultCapabilities, Worker.constructInstance(), true); // We're testing many as asynchronous messages in a row. // this would be cleaner using async/await syntax, but @@ -58,7 +60,7 @@ describe('LMLayer using the trie model', function () { // // https://community.software.sil.org/t/search-term-to-key-in-lexical-model-not-working-both-ways-by-default/3133 it('should use the default searchTermToKey()', function () { - var lmLayer = new LMLayer(helpers.defaultCapabilities); + var lmLayer = new LMLayer(helpers.defaultCapabilities, Worker.constructInstance()); return lmLayer.loadModel( // We need to provide an absolute path since the worker is based within a blob. diff --git a/common/predictive-text/unit_tests/in_browser/cases/worker.js b/common/predictive-text/unit_tests/in_browser/cases/worker.js index fdf85dfcf1..4bcb5bdc1f 100644 --- a/common/predictive-text/unit_tests/in_browser/cases/worker.js +++ b/common/predictive-text/unit_tests/in_browser/cases/worker.js @@ -1,6 +1,8 @@ var assert = chai.assert; -var LMLayer = com.keyman.text.prediction.LMLayer; -var DefaultWorker = com.keyman.text.prediction.DefaultWorker; + +import { Worker as WorkerBuilder } from "../../../build/lib/web/index.mjs"; +import { LMLayerWorkerCode } from "/base/common/web/lm-worker/build/lib/worker-main.wrapped.min.js"; +import * as helpers from "../helpers.mjs"; describe('LMLayerWorker', function () { // This one makes multiple subsequent calls across the WebWorker boundary, so we should be generous here. @@ -8,15 +10,16 @@ describe('LMLayerWorker', function () { describe('LMLayerWorkerCode', function() { it('should exist!', function() { - assert.isFunction(LMLayerWorkerCode, - 'Could not find LMLayerWorkerCode! Does embedded_worker.js exist?' - ); + // assert.isFunction(LMLayerWorkerCode, + // 'Could not find LMLayerWorkerCode! Does embedded_worker.js exist?' + // ); + assert.isString(LMLayerWorkerCode); }); }); describe('Usage within a Web Worker', function () { it('should install itself in the worker context', function (done) { - let uri = DefaultWorker.asBlobURI(LMLayerWorkerCode); + let uri = WorkerBuilder.asBlobURI(LMLayerWorkerCode); let worker = new Worker(uri); worker.onmessage = function thisShouldBeCalled(message) { done(); diff --git a/common/predictive-text/unit_tests/in_browser/helpers.js b/common/predictive-text/unit_tests/in_browser/helpers.js deleted file mode 100644 index a2d8ab127e..0000000000 --- a/common/predictive-text/unit_tests/in_browser/helpers.js +++ /dev/null @@ -1,8 +0,0 @@ -var helpers; - -// Establishes the equivalent of a TS namespace. -(function(helpers){ - helpers.defaultCapabilities = { - maxLeftContextCodeUnits: 64 - }; -})(helpers || (helpers = {})); \ No newline at end of file diff --git a/common/predictive-text/unit_tests/in_browser/helpers.mjs b/common/predictive-text/unit_tests/in_browser/helpers.mjs new file mode 100644 index 0000000000..385379bde6 --- /dev/null +++ b/common/predictive-text/unit_tests/in_browser/helpers.mjs @@ -0,0 +1,3 @@ +export let defaultCapabilities = { + maxLeftContextCodeUnits: 64 +}; \ No newline at end of file diff --git a/common/predictive-text/unit_tests/in_browser/manual.conf.js b/common/predictive-text/unit_tests/in_browser/manual.conf.cjs similarity index 95% rename from common/predictive-text/unit_tests/in_browser/manual.conf.js rename to common/predictive-text/unit_tests/in_browser/manual.conf.cjs index f1e3ed4865..9f94634473 100644 --- a/common/predictive-text/unit_tests/in_browser/manual.conf.js +++ b/common/predictive-text/unit_tests/in_browser/manual.conf.cjs @@ -1,5 +1,5 @@ module.exports = function(config) { - var base = require("./base.conf.js"); + var base = require("./base.conf.cjs"); var specifics = { // test results reporter to use diff --git a/common/predictive-text/unit_tests/test.sh b/common/predictive-text/unit_tests/test.sh index a0554b6325..74d29cb8ca 100755 --- a/common/predictive-text/unit_tests/test.sh +++ b/common/predictive-text/unit_tests/test.sh @@ -15,7 +15,7 @@ cd "$THIS_SCRIPT_PATH" ################################ Main script ################################ # Defaults -FLAGS="--require ./unit_tests/helpers" +FLAGS="" builder_describe "Runs all tests for the language-modeling / predictive-text layer module" \ "configure" \ @@ -74,6 +74,12 @@ if builder_start_action test:libraries; then npm run test popd + pushd "$KEYMAN_ROOT/common/web/lm-worker" + echo + echo "### Running ${BUILDER_TERM_START}common/web/lm-worker${BUILDER_TERM_END} tests" + ./build.sh test + popd + builder_finish_action success test:libraries fi @@ -122,13 +128,13 @@ if builder_start_action test:browser; then if builder_has_option --ci; then KARMA_FLAGS="$KARMA_FLAGS --reporters teamcity,BrowserStack" - KARMA_CONFIG="CI.conf.js" + KARMA_CONFIG="CI.conf.cjs" KARMA_INFO_LEVEL="--log-level=debug" else - KARMA_CONFIG="manual.conf.js" + KARMA_CONFIG="manual.conf.cjs" if builder_is_debug_build; then KARMA_FLAGS="$KARMA_FLAGS --no-single-run" - KARMA_CONFIG="manual.conf.js" + KARMA_CONFIG="manual.conf.cjs" KARMA_INFO_LEVEL="--log-level=debug" echo @@ -137,7 +143,7 @@ if builder_start_action test:browser; then fi fi - if [[ KARMA_CONFIG == "manual.conf.js" ]]; then + if [[ KARMA_CONFIG == "manual.conf.cjs" ]]; then get_browser_set_for_OS else BROWSERS= diff --git a/common/predictive-text/virtualizedWorker.ts b/common/predictive-text/virtualizedWorker.ts deleted file mode 100644 index ea2233d1ab..0000000000 --- a/common/predictive-text/virtualizedWorker.ts +++ /dev/null @@ -1,88 +0,0 @@ -/// -/// - -namespace com.keyman.text.prediction { - // Unfortunately, importing like this loses all type information for the modules. - // It's that, or massively overhaul lm-layer to be module-based instead of - // namespace-based. - var fs = require("fs"); - var vm = require("vm"); - - class VirtualizedWorkerContext { - // The LMLayerWorker installs itself to 'self', the expected Worker global, so we provide an alias. - self: VirtualizedWorkerContext; - - constructor() { - this.self = this; - } - - postMessage: (message: any) => void; - - importScripts(...scriptNames: string[]) { - /* Use of vm.createContext and script.runInContext allow us to avoid - * polluting the global scope with imports. When we throw away the - * context object, imported scripts will be automatically GC'd. - */ - for(let script of scriptNames) { - this.__importScriptString(fs.readFileSync(script, "UTF-8")); - } - } - - __importScriptString(scriptStr: string) { - let context = vm.createContext(this); - var script = new vm.Script(scriptStr); - script.runInContext(context); - } - } - - /** - * Note: this does not create an actual Worker, separate process, or thread. Everything will - * be executed in-line on a virtualized context. - * - * In the future, it might be nice to use Node's Worker Threads implementation. - */ - export class VirtualizedWorker implements Worker { - private _workerContext: VirtualizedWorkerContext; - - constructor(scriptStr: string) { - this._workerContext = new VirtualizedWorkerContext(); - // Needs to exist before setting up the worker; must exist by `.install()`. - this._workerContext.postMessage = this.workerPostMessage.bind(this); - - // Initialize the "worker". - this._workerContext.__importScriptString(scriptStr); - } - - // Sends the worker's postMessage messages to the appropriate `onmessage` handler. - private workerPostMessage(message: unknown) { - if(this.onmessage) { - this.onmessage({data: message} as any as MessageEvent); - } - } - - /** - * Accepts a callback function that will receive messages sent from the `VirtualizedWorker`'s `postMessage` function, - * much like the standard `Worker.onmessage`. - */ - onmessage: (this: Worker, ev: MessageEvent) => any; - - postMessage(message: any) { - let msgObj = {data: message}; - let msgJSON = JSON.stringify(msgObj); - - /* - * Execute the command within the virtualized worker's scope. The worker's returned - * `postMessage` calls will still reach outside, as they have a reference to `this` via - * `postMessage` (which we've set to a bound `this.workerPostMessage`). - * - * Among other things, this will allow the worker to use its internal namespaces without issue. - */ - let msgCommand = "onmessage(" + msgJSON + ")"; - this._workerContext.__importScriptString(msgCommand); - } - - terminate(): void { - this._workerContext = null; - } - } -} \ No newline at end of file diff --git a/common/predictive-text/web-defaultWorker.ts b/common/predictive-text/web-defaultWorker.ts deleted file mode 100644 index 6a4f527c13..0000000000 --- a/common/predictive-text/web-defaultWorker.ts +++ /dev/null @@ -1,27 +0,0 @@ -namespace com.keyman.text.prediction { - export class DefaultWorker { - static constructInstance(): Worker { - return new Worker(this.asBlobURI(LMLayerWorkerCode)); - } - - /** - * Converts the INSIDE of a function into a blob URI that can - * be passed as a valid URI for a Worker. - * @param fn Function whose body will be referenced by a URI. - * - * This function makes the following possible: - * - * let worker = new Worker(LMLayer.asBlobURI(function myWorkerCode () { - * postMessage('inside Web Worker') - * function onmessage(event) { - * // handle message inside Web Worker. - * } - * })); - */ - static asBlobURI(fn: Function): string { - let code = LMLayer.unwrap(fn); - let blob = new Blob([code], { type: 'text/javascript' }); - return URL.createObjectURL(blob); - } - } -} \ No newline at end of file diff --git a/common/test/resources/index.mjs b/common/test/resources/index.mjs new file mode 100644 index 0000000000..3152cd4584 --- /dev/null +++ b/common/test/resources/index.mjs @@ -0,0 +1,3 @@ +// Exists to facilitate relative path resolution via Node's require.resolve function. + +export { }; \ No newline at end of file diff --git a/common/predictive-text/unit_tests/in_browser/json/future_suggestions/i_got_distracted_by_hazel.json b/common/test/resources/json/models/future_suggestions/i_got_distracted_by_hazel.json similarity index 100% rename from common/predictive-text/unit_tests/in_browser/json/future_suggestions/i_got_distracted_by_hazel.json rename to common/test/resources/json/models/future_suggestions/i_got_distracted_by_hazel.json diff --git a/common/predictive-text/unit_tests/in_browser/json/tries/accented.json b/common/test/resources/json/models/tries/accented.json similarity index 100% rename from common/predictive-text/unit_tests/in_browser/json/tries/accented.json rename to common/test/resources/json/models/tries/accented.json diff --git a/common/predictive-text/unit_tests/in_browser/json/tries/english-1000.json b/common/test/resources/json/models/tries/english-1000.json similarity index 100% rename from common/predictive-text/unit_tests/in_browser/json/tries/english-1000.json rename to common/test/resources/json/models/tries/english-1000.json diff --git a/common/test/resources/karma-browserstack-config.js b/common/test/resources/karma-browserstack-config.cjs similarity index 100% rename from common/test/resources/karma-browserstack-config.js rename to common/test/resources/karma-browserstack-config.cjs diff --git a/common/test/resources/keyboards/khmer_angkor.js b/common/test/resources/keyboards/khmer_angkor.js index 7b797e7e69..93cdec4df5 100644 --- a/common/test/resources/keyboards/khmer_angkor.js +++ b/common/test/resources/keyboards/khmer_angkor.js @@ -6,8 +6,8 @@ KeymanWeb.KR(new Keyboard_khmer_angkor()); } function Keyboard_khmer_angkor() { - var modCodes = com.keyman.text.Codes.modifierCodes; - var keyCodes = com.keyman.text.Codes.keyCodes; + var modCodes = keyman.osk.modifierCodes; + var keyCodes = keyman.osk.keyCodes; this.KI="Keyboard_khmer_angkor"; this.KN="Khmer Angkor"; @@ -2804,7 +2804,7 @@ function Keyboard_khmer_angkor() k.KO(-1,t,"»"); } if(m) { - + k.KDC(-1,t); r=this.g_normalise(t,e); } diff --git a/common/test/resources/keyboards/test_deadkeys.js b/common/test/resources/keyboards/test_deadkeys.js index c55165b8f6..697fb65b54 100644 --- a/common/test/resources/keyboards/test_deadkeys.js +++ b/common/test/resources/keyboards/test_deadkeys.js @@ -6,8 +6,8 @@ KeymanWeb.KR(new Keyboard_test_deadkeys()); } function Keyboard_test_deadkeys() { - var modCodes = com.keyman.text.Codes.modifierCodes; - var keyCodes = com.keyman.text.Codes.keyCodes; + var modCodes = keyman.osk.modifierCodes; + var keyCodes = keyman.osk.keyCodes; this.KI="Keyboard_test_deadkeys"; this.KN="Keyman Deadkey Stress-Tester"; @@ -325,7 +325,7 @@ function Keyboard_test_deadkeys() k.KDO(-1,t,17); } if(m) { - + k.KDC(-1,t); r=this.g_dead_reorder(t,e); } diff --git a/common/test/resources/model-helpers.mjs b/common/test/resources/model-helpers.mjs new file mode 100644 index 0000000000..1de10bdb15 --- /dev/null +++ b/common/test/resources/model-helpers.mjs @@ -0,0 +1,160 @@ +import path from 'path'; +import { assert } from 'chai'; +import fs from 'fs'; +import vm from 'vm'; + +/** + * @file model-helpers.mjs + * + * Defines a common set of helper functions for use with predictive-text-related + * unit tests. + */ + +// Ensure that we can successfully load the module & apply kmwLength, as it's +// needed for some of the unit tests. + +// // Verify that the KMW string extensions are loaded via side-effect. + +import { extendString } from '@keymanapp/web-utils'; + +import { createRequire } from "module"; +import { fileURLToPath } from 'url'; + +// Ensure that our KMW string-extensions activate. +extendString(); +assert.ok('💩'.kmwLength); + +/** + * Creates a MessageEvent (for inter-worker communication), with the given data payload. + * + * @param {*} data + */ +export function createMessageEventWithData(data) { + return { data }; +} + +/** + * Creates a simple, default capabilities object for standard-case LMLayer init. + */ +export function capabilities() { + return { + maxLeftContextCodePoints: 64 + } +} + +/** + * Mimics a message from the outer LMLayer shell with a simple, default config object. + * Used for Worker tests. + */ +export function configWorker(worker) { + worker.onMessage(createMessageEventWithData({ + message: 'config', + capabilities: capabilities() + })); +} + +/** + * A valid model that suggests exactly what you want it to suggest. + * + * @returns {ModelDescription} + */ +export function dummyModel(futureSuggestions) { + return { + type: 'dummy', + futureSuggestions: futureSuggestions || [] + }; +} +/** + * Capabilities of a keyboard that will ONLY send left-sided capabilities. + * The keyboard does not support deleting to the right. + * + * @returns {Capabilities} + */ +export function defaultCapabilities() { + return { + maxLeftContextCodeUnits: 64 + }; +} + +/** + * Returns the Context of an empty buffer; no text, at both the start and + * end of the buffer. + * + * @returns {Context} + */ +export function emptyContext() { + return { + left: '', + startOfBuffer: true, + endOfBuffer: true + }; +} + +/** + * Returns a Transform that, when applied, makes no changes to the buffer. + * + * @returns {Transform} + */ +export function zeroTransform() { + return { + insert: '', + deleteLeft: 0, + }; +} + +/** + * Returns a random token. NOT guaranteed to be unique. + * + * @returns {Token} + */ +export function randomToken() { + var range = Number.MAX_SAFE_INTEGER - Number.MIN_SAFE_INTEGER; + return Math.random() * range + Number.MIN_SAFE_INTEGER; +} + +export function iGotDistractedByHazel() { + return jsonFixture('models/future_suggestions/i_got_distracted_by_hazel'); +} + +export function jsonFixture(name, root, import_root) { + // Assuming this file structure: + // root + // └── json + // ├── future_suggestions + // │ └── ... + // └── wordlists + // └── ... + + // Default root: this folder. + if(!root) { + root = 'json'; + } + + if(!import_root) { + import_root = import.meta.url; + } + + // The most straight-forward way... is to use CommonJS-style require to load JSON. + // Fortunately, Node provides the tools needed to recreate it. + const require = createRequire(import_root); + + // ES-module mode also leaves out `__dirname`, so we rebuild that too. + const __filename = fileURLToPath(import_root); + const __dirname = path.dirname(__filename); + return require(path.join(__dirname, root, `${name}.json`)); +} + +export function importScriptsWith(context) { + return function() { // the constructed context's importScripts method. + + /* Use of vm.createContext and script.runInContext allow us to avoid + * polluting the global scope with imports. When we throw away the + * context object, imported scripts will be automatically GC'd. + */ + for(var i=0; i < arguments.length; i++) { + context = vm.createContext(context); + var script = new vm.Script(fs.readFileSync(arguments[i])); + script.runInContext(context); + } + } +} diff --git a/common/predictive-text/unit_tests/in_browser/resources/models/naive-trie.js b/common/test/resources/models/naive-trie.js similarity index 100% rename from common/predictive-text/unit_tests/in_browser/resources/models/naive-trie.js rename to common/test/resources/models/naive-trie.js diff --git a/common/predictive-text/unit_tests/in_browser/resources/models/simple-dummy.js b/common/test/resources/models/simple-dummy.js similarity index 100% rename from common/predictive-text/unit_tests/in_browser/resources/models/simple-dummy.js rename to common/test/resources/models/simple-dummy.js diff --git a/common/predictive-text/unit_tests/in_browser/resources/models/simple-trie.js b/common/test/resources/models/simple-trie.js similarity index 100% rename from common/predictive-text/unit_tests/in_browser/resources/models/simple-trie.js rename to common/test/resources/models/simple-trie.js diff --git a/common/test/resources/package.json b/common/test/resources/package.json new file mode 100644 index 0000000000..03f8de3cb4 --- /dev/null +++ b/common/test/resources/package.json @@ -0,0 +1,12 @@ +{ + "name": "@keymanapp/common-test-resources", + "description": "Common test resources for Keyman's JS & TS-based components & products", + "type": "module", + "license": "MIT", + "devDependencies": { + "@keymanapp/resources-gosh": "*", + "@types/node": "^10.17.21", + "chai": "^4.3.4", + "typescript": "^4.9.5" + } +} diff --git a/common/test/resources/timeout-adapter.js b/common/test/resources/timeout-adapter.js index b8141a6a41..0e93ec94a9 100644 --- a/common/test/resources/timeout-adapter.js +++ b/common/test/resources/timeout-adapter.js @@ -1,6 +1,6 @@ // Preprocessing of the Karma configuration's client.args parameter. -com = com || {}; +var com = com || {}; com.keyman = com.keyman || {}; com.keyman.karma = com.keyman.karma || {}; diff --git a/common/tools/sourcemap-path-remapper/build.sh b/common/tools/sourcemap-path-remapper/build.sh index 16c5c76a67..d917bca89b 100755 --- a/common/tools/sourcemap-path-remapper/build.sh +++ b/common/tools/sourcemap-path-remapper/build.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash # -# Compile our sourcemap-path remapping module for use by Web builds, releases, etc. +# Compiles the JS sourcemap remapper tool used by some of our TS projects when complications +# arise with sourcemap handling. # + +# Exit on command failure and when using unset variables: set -eu ## START STANDARD BUILD SCRIPT INCLUDE @@ -17,9 +20,6 @@ cd "$THIS_SCRIPT_PATH" ################################ Main script ################################ -# TODO: for predictive-text, we only need :headless, perhaps we should be splitting modules? -# TODO: remove :tools once kmlmc is a dependency for test:module - builder_describe "Builds a sourcemap manipulation ES module for use in Web-related builds" \ "clean" \ "configure" \ @@ -31,13 +31,6 @@ builder_describe_outputs \ builder_parse "$@" -### CONFIGURE ACTIONS - -if builder_start_action configure; then - verify_npm_setup - builder_finish_action success configure -fi - ### CLEAN ACTIONS if builder_start_action clean; then @@ -45,6 +38,13 @@ if builder_start_action clean; then builder_finish_action success clean fi +### CONFIGURE ACTIONS + +if builder_start_action configure; then + verify_npm_setup + builder_finish_action success configure +fi + ### BUILD ACTIONS if builder_start_action build; then diff --git a/common/tools/sourcemap-path-remapper/package.json b/common/tools/sourcemap-path-remapper/package.json index 552d60989b..2a46897739 100644 --- a/common/tools/sourcemap-path-remapper/package.json +++ b/common/tools/sourcemap-path-remapper/package.json @@ -10,7 +10,7 @@ "type": "git", "url": "git+https://github.com/keymanapp/keyman.git" }, - "author": "Joshua A. Horton ", + "author": "Joshua A. Horton ", "license": "MIT", "bugs": { "url": "https://github.com/keymanapp/keyman/issues" diff --git a/common/tools/sourcemap-path-remapper/src/index.ts b/common/tools/sourcemap-path-remapper/src/index.ts index de54a3499b..7d26e3c8a7 100644 --- a/common/tools/sourcemap-path-remapper/src/index.ts +++ b/common/tools/sourcemap-path-remapper/src/index.ts @@ -1,5 +1,5 @@ import * as fs from 'fs'; -import convertSourcemap from 'convert-source-map'; // Transforms sourcemaps among various common formats. +import convertSourceMap from 'convert-source-map'; // Transforms sourcemaps among various common formats. // Base64, stringified-JSON, end-of-file comment... // Because we're compiling the main project based on its TS build outputs, and our cross-module references @@ -16,33 +16,33 @@ export interface Mapping { */ class RemappingState { constructor(srcmap: Object) { - this.sourcemap = srcmap; + this.sourceMap = srcmap; this.resetChangedSourcepathTracking(); } - public readonly sourcemap: any; - private _unchangedSourcepaths: string[] = []; + public readonly sourceMap: any; + private _unchangedSourcePaths: string[] = []; public get unchangedSourcepaths() { - return this._unchangedSourcepaths; + return this._unchangedSourcePaths; } public resetChangedSourcepathTracking() { - this._unchangedSourcepaths = [...this.sourcemap.sources]; + this._unchangedSourcePaths = [...this.sourceMap.sources]; } /** * The sourcemap's `sourceRoot` property. */ public get sourceRoot() { - return this.sourcemap.sourceRoot; + return this.sourceMap.sourceRoot; } /** * The sourcemap's `sourceRoot` property. */ public set sourceRoot(path: string) { - this.sourcemap.sourceRoot = path; + this.sourceMap.sourceRoot = path; } /** @@ -59,7 +59,7 @@ class RemappingState { const unusedTransforms = [...transforms]; - const sourcePaths = this.sourcemap.sources; + const sourcePaths = this.sourceMap.sources; for(let i = 0; i < sourcePaths.length; i++) { for(const transform of transforms) { if(sourcePaths[i].match(transform.from)) { @@ -101,7 +101,7 @@ class RemappingState { * @param file */ public toFile(file: fs.PathLike) { - fs.writeFileSync(file, convertSourcemap.fromObject(this.sourcemap).toJSON()); + fs.writeFileSync(file, convertSourceMap.fromObject(this.sourceMap).toJSON()); } } @@ -132,6 +132,6 @@ export default class SourcemapRemapper { * @returns */ public static fromBuffer(buffer: Buffer) { - return this.fromObject(convertSourcemap.fromJSON(buffer).toObject()); + return this.fromObject(convertSourceMap.fromJSON(buffer).toObject()); } } \ No newline at end of file diff --git a/common/web/input-processor/build-bundler.js b/common/web/input-processor/build-bundler.js new file mode 100644 index 0000000000..34614fa680 --- /dev/null +++ b/common/web/input-processor/build-bundler.js @@ -0,0 +1,45 @@ +/* + * Note: while this file is not meant to exist long-term, it provides a nice + * low-level proof-of-concept for esbuild bundling of the various Web submodules. + * + * Add some extra code at the end of src/index.ts and run it to verify successful bundling! + */ + +import esbuild from 'esbuild'; +import { spawn } from 'child_process'; + +// Bundled ES module version +esbuild.buildSync({ + entryPoints: ['build/obj/index.js'], + bundle: true, + sourcemap: true, + external: ['fs', 'vm'], + format: "esm", + // Sets 'common/web' as a root folder for module resolution; + // this allows the keyman-version and utils imports to resolve. + // + // We also need to point it at the nested build output folder to resolve in-project + // imports when compiled - esbuild doesn't seem to pick up on the shifted base. + nodePaths: ['..', "build/obj"], + outfile: "build/lib/index.mjs", + tsconfig: 'tsconfig.json', + target: "es5" +}); + +// Bundled CommonJS (classic Node) module version +esbuild.buildSync({ + entryPoints: ['build/obj/index.js'], + bundle: true, + sourcemap: true, + external: ['fs', 'vm'], + format: "cjs", + // Sets 'common/web' as a root folder for module resolution; + // this allows the keyman-version and utils imports to resolve. + // + // We also need to point it at the nested build output folder to resolve in-project + // imports when compiled - esbuild doesn't seem to pick up on the shifted base. + nodePaths: ['..', "build/obj"], + outfile: "build/lib/index.cjs", + tsconfig: 'tsconfig.json', + target: "es5" +}); \ No newline at end of file diff --git a/common/web/input-processor/build.sh b/common/web/input-processor/build.sh index c7995e5d42..a22048df52 100755 --- a/common/web/input-processor/build.sh +++ b/common/web/input-processor/build.sh @@ -16,13 +16,11 @@ cd "$(dirname "$THIS_SCRIPT")" ################################ Main script ################################ -# TODO: for predictive-text, we only need :headless, perhaps we should be splitting modules? -# TODO: remove :tools once kmlmc is a dependency for test:module - builder_describe "Builds the standalone, headless form of Keyman Engine for Web's input-processor module" \ "@/common/web/keyman-version" \ "@/common/web/keyboard-processor" \ "@/common/predictive-text" \ + "@/developer/src/kmc-model test" \ "clean" \ "configure" \ "build" \ @@ -33,10 +31,7 @@ builder_describe "Builds the standalone, headless form of Keyman Engine for Web' builder_describe_outputs \ configure /node_modules \ - configure:module /node_modules \ - configure:tools /node_modules \ - build:module /common/web/input-processor/build/index.js \ - build:tools /developer/src/kmc/build/src/kmlmc.js # TODO: remove this once kmlmc is a dependency + build /common/web/input-processor/build/lib/index.mjs \ builder_parse "$@" @@ -56,36 +51,25 @@ fi ### BUILD ACTIONS -if builder_start_action build:tools; then - # Used by test:module - # TODO: convert to a dependency once we have updated kmlmc to use builder script - pushd "$KEYMAN_ROOT/developer/src/kmc-model" - ./build.sh - popd - pushd "$KEYMAN_ROOT/developer/src/kmc" - ./build.sh - popd - - builder_finish_action success build:tools -fi +if builder_start_action build; then + tsc -b ./tsconfig.json + node build-bundler.js -if builder_start_action build:module; then - tsc -b src/tsconfig.json - builder_finish_action success build:module + # Declaration bundling. + tsc --emitDeclarationOnly --outFile ./build/lib/index.d.ts + + builder_finish_action success build fi # TEST ACTIONS -if builder_start_action test:module; then +if builder_start_action test; then FLAGS= if builder_has_option --ci; then FLAGS="--reporter mocha-teamcity-reporter" fi - # Build the leaf-style, bundled version of input-processor for use in testing. - tsc -b src/tsconfig.bundled.json - mocha --recursive $FLAGS ./tests/cases/ - builder_finish_action success test:module + builder_finish_action success test fi \ No newline at end of file diff --git a/common/web/input-processor/package.json b/common/web/input-processor/package.json index 6675574334..a4d86d5be4 100644 --- a/common/web/input-processor/package.json +++ b/common/web/input-processor/package.json @@ -38,5 +38,20 @@ "@keymanapp/keyman-version": "*", "@keymanapp/web-utils": "*", "eventemitter3": "^4.0.0" + }, + "type": "module", + "imports": { + "#./*.js": "./build/obj/*.js" + }, + "main": "./build/obj/index.js", + "types": "./build/obj/index.d.ts", + "exports": { + ".": "./build/obj/index.js", + "./lib": { + "types": "./build/lib/index.d.ts", + "import": "./build/lib/index.mjs", + "require": "./build/lib/index.cjs" + }, + "./obj/*.js": "./build/obj/*.js" } } diff --git a/common/web/input-processor/src/includes/events.ts b/common/web/input-processor/src/includes/events.ts deleted file mode 100644 index 2893c0ccee..0000000000 --- a/common/web/input-processor/src/includes/events.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Implements Node's EventEmitter class and related module components in a near -// browser-compatible way. (Just requires a blank 'module' object on the window.) -/// - -// Unfortunately, I can't get it to recognize type information properly -// because we can't use require statements. So, a small-scale manual definition. -declare class EventEmitter { - /** Add a listener for a given event */ - on(event: string, func: (...args: any[]) => boolean, context?: any); - /** Add a one-time listener for a given event */ - once(event: string, func: (...args: any[]) => boolean, context?: any); - removeListener(event: string, func: (...args: any[]) => boolean, context?: any, once?: boolean); - - // Defines their alternately-themed aliases. - addListener: typeof EventEmitter.prototype.on; - off: typeof EventEmitter.prototype.removeListener; - - // Defines the actual event-raising function. - emit(eventName: string, ...args: any[]); -} \ No newline at end of file diff --git a/common/web/input-processor/src/index.ts b/common/web/input-processor/src/index.ts new file mode 100644 index 0000000000..360a660108 --- /dev/null +++ b/common/web/input-processor/src/index.ts @@ -0,0 +1,7 @@ +/// + +export { default as InputProcessor } from './text/inputProcessor.js'; +export { default as ContextWindow } from './text/contextWindow.js'; +export { default as ModelSpec } from './text/prediction/modelSpec.js'; +export { default as LanguageProcessor, StateChangeEnum } from './text/prediction/languageProcessor.js'; +export { default as PredictionContext } from './text/prediction/predictionContext.js'; \ No newline at end of file diff --git a/common/web/input-processor/src/text/contextWindow.ts b/common/web/input-processor/src/text/contextWindow.ts index 69b83a58db..2adb4f0b6a 100644 --- a/common/web/input-processor/src/text/contextWindow.ts +++ b/common/web/input-processor/src/text/contextWindow.ts @@ -1,44 +1,44 @@ -namespace com.keyman.text { - export class ContextWindow implements Context { - // Used to limit the range of context replicated for use of keyboard rules within - // the engine, as used for fat-finger prep / `Alternate` generation. - public static readonly ENGINE_RULE_WINDOW: Configuration = { - leftContextCodePoints: 64, - rightContextCodePoints: 32 - }; - - left: string; - right?: string; - - startOfBuffer: boolean; - endOfBuffer: boolean; - - casingForm?: CasingForm; - - constructor(mock: Mock, config: Configuration, layerId: string) { - this.left = mock.getTextBeforeCaret(); - this.startOfBuffer = this.left._kmwLength() <= config.leftContextCodePoints; - if(!this.startOfBuffer) { - // Our custom substring version will return the last n characters if param #1 is given -n. - this.left = this.left._kmwSubstr(-config.leftContextCodePoints); - } - - this.right = mock.getTextAfterCaret(); - this.endOfBuffer = this.right._kmwLength() <= config.rightContextCodePoints; - if(!this.endOfBuffer) { - this.right = this.right._kmwSubstr(0, config.rightContextCodePoints); - } - - this.casingForm = - layerId == 'shift' ? 'initial' : - layerId == 'caps' ? 'upper' : - null; +import { Mock } from "@keymanapp/keyboard-processor"; + +export default class ContextWindow implements Context { + // Used to limit the range of context replicated for use of keyboard rules within + // the engine, as used for fat-finger prep / `Alternate` generation. + public static readonly ENGINE_RULE_WINDOW: Configuration = { + leftContextCodePoints: 64, + rightContextCodePoints: 32 + }; + + left: string; + right?: string; + + startOfBuffer: boolean; + endOfBuffer: boolean; + + casingForm?: CasingForm; + + constructor(mock: Mock, config: Configuration, layerId: string) { + this.left = mock.getTextBeforeCaret(); + this.startOfBuffer = this.left._kmwLength() <= config.leftContextCodePoints; + if(!this.startOfBuffer) { + // Our custom substring version will return the last n characters if param #1 is given -n. + this.left = this.left._kmwSubstr(-config.leftContextCodePoints); } - public toMock(): Mock { - let caretPos = this.left._kmwLength(); - - return new Mock(this.left + (this.right || ""), caretPos); + this.right = mock.getTextAfterCaret(); + this.endOfBuffer = this.right._kmwLength() <= config.rightContextCodePoints; + if(!this.endOfBuffer) { + this.right = this.right._kmwSubstr(0, config.rightContextCodePoints); } + + this.casingForm = + layerId == 'shift' ? 'initial' : + layerId == 'caps' ? 'upper' : + null; + } + + public toMock(): Mock { + let caretPos = this.left._kmwLength(); + + return new Mock(this.left + (this.right || ""), caretPos); } } \ No newline at end of file diff --git a/common/web/input-processor/src/text/inputProcessor.ts b/common/web/input-processor/src/text/inputProcessor.ts index c2cf2aeed8..a4719b7909 100644 --- a/common/web/input-processor/src/text/inputProcessor.ts +++ b/common/web/input-processor/src/text/inputProcessor.ts @@ -1,373 +1,372 @@ // Defines a 'polyfill' of sorts for NPM's events module -/// -/// -/// + /// -namespace com.keyman.text { - export class InputProcessor { - public static readonly DEFAULT_OPTIONS: ProcessorInitOptions = { - baseLayout: 'us' +import ContextWindow from "./contextWindow.js"; +import LanguageProcessor from "./prediction/languageProcessor.js"; +import type ModelSpec from "./prediction/modelSpec.js"; +import { globalObject, DeviceSpec } from "@keymanapp/web-utils"; + +import { + type Alternate, + Codes, + type Keyboard, + KeyboardInterface, + KeyboardProcessor, + type KeyEvent, + Mock, + type OutputTarget, + type ProcessorInitOptions, + RuleBehavior, + SystemStoreIDs, + type TextTransform +} from "@keymanapp/keyboard-processor"; + +export default class InputProcessor { + public static readonly DEFAULT_OPTIONS: ProcessorInitOptions = { + baseLayout: 'us' + } + + /** + * Indicates the device (platform) to be used for non-keystroke events, + * such as those sent to `begin postkeystroke` and `begin newcontext` + * entry points. + */ + private contextDevice: DeviceSpec; + private kbdProcessor: KeyboardProcessor; + private lngProcessor: LanguageProcessor; + + constructor(device: DeviceSpec, predictiveTextWorker: Worker, options?: ProcessorInitOptions) { + if(!device) { + throw new Error('device must be defined'); } - /** - * Indicates the device (platform) to be used for non-keystroke events, - * such as those sent to `begin postkeystroke` and `begin newcontext` - * entry points. - */ - private contextDevice: utils.DeviceSpec; - private kbdProcessor: KeyboardProcessor; - private lngProcessor: prediction.LanguageProcessor; - - constructor(device: utils.DeviceSpec, options?: ProcessorInitOptions) { - if(!device) { - throw new Error('device must be defined'); - } + if(!options) { + options = InputProcessor.DEFAULT_OPTIONS; + } - if(!options) { - options = InputProcessor.DEFAULT_OPTIONS; - } + this.contextDevice = device; + this.kbdProcessor = new KeyboardProcessor(device, options); + this.lngProcessor = new LanguageProcessor(predictiveTextWorker); + } - this.contextDevice = device; - this.kbdProcessor = new KeyboardProcessor(device, options); - this.lngProcessor = new prediction.LanguageProcessor(); - } + public get languageProcessor(): LanguageProcessor { + return this.lngProcessor; + } - public get languageProcessor(): prediction.LanguageProcessor { - return this.lngProcessor; - } + public get keyboardProcessor(): KeyboardProcessor { + return this.kbdProcessor; + } - public get keyboardProcessor(): KeyboardProcessor { - return this.kbdProcessor; - } + public get keyboardInterface(): KeyboardInterface { + return this.keyboardProcessor.keyboardInterface; + } - public get keyboardInterface(): text.KeyboardInterface { - return this.keyboardProcessor.keyboardInterface; - } + public get activeKeyboard(): Keyboard { + return this.keyboardInterface.activeKeyboard; + } - public get activeKeyboard(): keyboards.Keyboard { - return this.keyboardInterface.activeKeyboard; - } + public set activeKeyboard(keyboard: Keyboard) { + this.keyboardInterface.activeKeyboard = keyboard; - public set activeKeyboard(keyboard: keyboards.Keyboard) { - this.keyboardInterface.activeKeyboard = keyboard; + // All old deadkeys and keyboard-specific cache should immediately be invalidated + // on a keyboard change. + this.resetContext(); + } - // All old deadkeys and keyboard-specific cache should immediately be invalidated - // on a keyboard change. - this.resetContext(); - } + public get activeModel(): ModelSpec { + return this.languageProcessor.activeModel; + } - public get activeModel(): prediction.ModelSpec { - return this.languageProcessor.activeModel; + /** + * Tell the currently active keyboard that a new context has been selected, + * e.g. by focus change, selection change, keyboard change, etc. + * + * @param {Object} outputTarget The OutputTarget that has focus + * @returns {Object} A RuleBehavior object describing the cumulative effects of + * all matched keyboard rules + */ + processNewContextEvent(outputTarget: OutputTarget): RuleBehavior { + const ruleBehavior = this.keyboardProcessor.processNewContextEvent(this.contextDevice, outputTarget); + + if(ruleBehavior) { + ruleBehavior.finalize(this.keyboardProcessor, outputTarget, true); } + return ruleBehavior; + } - /** - * Tell the currently active keyboard that a new context has been selected, - * e.g. by focus change, selection change, keyboard change, etc. - * - * @param {Object} outputTarget The OutputTarget that has focus - * @returns {Object} A RuleBehavior object describing the cumulative effects of - * all matched keyboard rules - */ - processNewContextEvent(outputTarget: OutputTarget): RuleBehavior { - const ruleBehavior = this.keyboardProcessor.processNewContextEvent(this.contextDevice, outputTarget); - - if(ruleBehavior) { - ruleBehavior.finalize(this.keyboardProcessor, outputTarget, true); + /** + * Simulate a keystroke according to the touched keyboard button element + * + * Handles default output and keyboard processing for both OSK and physical keystrokes. + * + * @param {Object} keyEvent The abstracted KeyEvent to use for keystroke processing + * @param {Object} outputTarget The OutputTarget receiving the KeyEvent + * @returns {Object} A RuleBehavior object describing the cumulative effects of + * all matched keyboard rules. + */ + processKeyEvent(keyEvent: KeyEvent, outputTarget: OutputTarget): RuleBehavior { + const kbdMismatch = keyEvent.srcKeyboard && this.activeKeyboard != keyEvent.srcKeyboard; + const trueActiveKeyboard = this.activeKeyboard; + + try { + if(kbdMismatch) { + // Avoid force-reset of context per our setter above. + this.keyboardInterface.activeKeyboard = keyEvent.srcKeyboard; } - return ruleBehavior; - } - /** - * Simulate a keystroke according to the touched keyboard button element - * - * Handles default output and keyboard processing for both OSK and physical keystrokes. - * - * @param {Object} keyEvent The abstracted KeyEvent to use for keystroke processing - * @param {Object} outputTarget The OutputTarget receiving the KeyEvent - * @returns {Object} A RuleBehavior object describing the cumulative effects of - * all matched keyboard rules. - */ - processKeyEvent(keyEvent: KeyEvent, outputTarget: OutputTarget): RuleBehavior { - const kbdMismatch = keyEvent.srcKeyboard && this.activeKeyboard != keyEvent.srcKeyboard; - const trueActiveKeyboard = this.activeKeyboard; - - try { - if(kbdMismatch) { - // Avoid force-reset of context per our setter above. - this.keyboardInterface.activeKeyboard = keyEvent.srcKeyboard; - } - - return this._processKeyEvent(keyEvent, outputTarget); - } finally { - if(kbdMismatch) { - // Restore our "current" activeKeyboard to its setting before the mismatching KeyEvent. - this.keyboardInterface.activeKeyboard = trueActiveKeyboard; - } + return this._processKeyEvent(keyEvent, outputTarget); + } finally { + if(kbdMismatch) { + // Restore our "current" activeKeyboard to its setting before the mismatching KeyEvent. + this.keyboardInterface.activeKeyboard = trueActiveKeyboard; } } + } - /** - * Acts as the core of `processKeyEvent` once we're comfortable asserting that the incoming - * keystroke matches the current `activeKeyboard`. - * @param keyEvent - * @param outputTarget - * @returns - */ - private _processKeyEvent(keyEvent: KeyEvent, outputTarget: OutputTarget): RuleBehavior { - let formFactor = keyEvent.device.formFactor; - let fromOSK = keyEvent.isSynthetic; - - // The default OSK layout for desktop devices does not include nextlayer info, relying on modifier detection here. - // It's the OSK equivalent to doModifierPress on 'desktop' form factors. - if((formFactor == utils.FormFactor.Desktop || !this.activeKeyboard || this.activeKeyboard.usesDesktopLayoutOnDevice(keyEvent.device)) && fromOSK) { - // If it's a desktop OSK style and this triggers a layer change, - // a modifier key was clicked. No output expected, so it's safe to instantly exit. - if(this.keyboardProcessor.selectLayer(keyEvent)) { - return new RuleBehavior(); - } + /** + * Acts as the core of `processKeyEvent` once we're comfortable asserting that the incoming + * keystroke matches the current `activeKeyboard`. + * @param keyEvent + * @param outputTarget + * @returns + */ + private _processKeyEvent(keyEvent: KeyEvent, outputTarget: OutputTarget): RuleBehavior { + let formFactor = keyEvent.device.formFactor; + let fromOSK = keyEvent.isSynthetic; + + // The default OSK layout for desktop devices does not include nextlayer info, relying on modifier detection here. + // It's the OSK equivalent to doModifierPress on 'desktop' form factors. + if((formFactor == DeviceSpec.FormFactor.Desktop || !this.activeKeyboard || this.activeKeyboard.usesDesktopLayoutOnDevice(keyEvent.device)) && fromOSK) { + // If it's a desktop OSK style and this triggers a layer change, + // a modifier key was clicked. No output expected, so it's safe to instantly exit. + if(this.keyboardProcessor.selectLayer(keyEvent)) { + return new RuleBehavior(); } + } - // Will handle keystroke-based non-layer change modifier & state keys, mapping them through the physical keyboard's version - // of state management. `doModifierPress` must always run. - if(this.keyboardProcessor.doModifierPress(keyEvent, outputTarget, !fromOSK)) { - // If run on a desktop platform, we know that modifier & state key presses may not - // produce output, so we may make an immediate return safely. - if(!fromOSK) { - return new RuleBehavior(); - } + // Will handle keystroke-based non-layer change modifier & state keys, mapping them through the physical keyboard's version + // of state management. `doModifierPress` must always run. + if(this.keyboardProcessor.doModifierPress(keyEvent, outputTarget, !fromOSK)) { + // If run on a desktop platform, we know that modifier & state key presses may not + // produce output, so we may make an immediate return safely. + if(!fromOSK) { + return new RuleBehavior(); } + } - // If suggestions exist AND space is pressed, accept the suggestion and do not process the keystroke. - // If a suggestion was just accepted AND backspace is pressed, revert the change and do not process the backspace. - // We check the first condition here, while the prediction UI handles the second through the try__() methods below. - if(this.languageProcessor.isActive) { - // The following code relies on JS's logical operator "short-circuit" properties to prevent unwanted triggering of the second condition. - - // Can the suggestion UI revert a recent suggestion? If so, do that and swallow the backspace. - if((keyEvent.kName == "K_BKSP" || keyEvent.Lcode == Codes.keyCodes["K_BKSP"]) && this.languageProcessor.tryRevertSuggestion()) { - return new RuleBehavior(); - // Can the suggestion UI accept an existing suggestion? If so, do that and swallow the space character. - } else if((keyEvent.kName == "K_SPACE" || keyEvent.Lcode == Codes.keyCodes["K_SPACE"]) && this.languageProcessor.tryAcceptSuggestion('space')) { - return new RuleBehavior(); - } + // If suggestions exist AND space is pressed, accept the suggestion and do not process the keystroke. + // If a suggestion was just accepted AND backspace is pressed, revert the change and do not process the backspace. + // We check the first condition here, while the prediction UI handles the second through the try__() methods below. + if(this.languageProcessor.isActive) { + // The following code relies on JS's logical operator "short-circuit" properties to prevent unwanted triggering of the second condition. + + // Can the suggestion UI revert a recent suggestion? If so, do that and swallow the backspace. + if((keyEvent.kName == "K_BKSP" || keyEvent.Lcode == Codes.keyCodes["K_BKSP"]) && this.languageProcessor.tryRevertSuggestion()) { + return new RuleBehavior(); + // Can the suggestion UI accept an existing suggestion? If so, do that and swallow the space character. + } else if((keyEvent.kName == "K_SPACE" || keyEvent.Lcode == Codes.keyCodes["K_SPACE"]) && this.languageProcessor.tryAcceptSuggestion('space')) { + return new RuleBehavior(); } + } - // // ...end I3363 (Build 301) + // // ...end I3363 (Build 301) - // Create a "mock" backup of the current outputTarget in its pre-input state. - // Current, long-existing assumption - it's DOM-backed. - let preInputMock = Mock.from(outputTarget, true); + // Create a "mock" backup of the current outputTarget in its pre-input state. + // Current, long-existing assumption - it's DOM-backed. + let preInputMock = Mock.from(outputTarget, true); - const startingLayerId = this.keyboardProcessor.layerId; + const startingLayerId = this.keyboardProcessor.layerId; - // We presently need the true keystroke to run on the FULL context. That index is still - // needed for some indexing operations when comparing two different output targets. - let ruleBehavior = this.keyboardProcessor.processKeystroke(keyEvent, outputTarget); + // We presently need the true keystroke to run on the FULL context. That index is still + // needed for some indexing operations when comparing two different output targets. + let ruleBehavior = this.keyboardProcessor.processKeystroke(keyEvent, outputTarget); - // Swap layer as appropriate. - if(keyEvent.kNextLayer) { - this.keyboardProcessor.selectLayer(keyEvent); - } + // Swap layer as appropriate. + if(keyEvent.kNextLayer) { + this.keyboardProcessor.selectLayer(keyEvent); + } - // If it's a key that we 'optimize out' of our fat-finger correction algorithm, - // we MUST NOT trigger it for this keystroke. - let isOnlyLayerSwitchKey = text.Codes.isKnownOSKModifierKey(keyEvent.kName); - - // Best-guess stopgap for possible custom modifier keys. - // If a key (1) does not affect the context and (2) shifts the active layer, - // we assume it's a modifier key. (Touch keyboards may define custom modifier keys.) - // - // Note: this will mean we won't generate alternates in the niche scenario where: - // 1. Keypress does not alter the actual context - // 2. It DOES emit a deadkey with an earlier processing rule. - // 3. The FINAL processing rule does not match. - // 4. The key ALSO signals a layer shift. - // If any of the four above conditions aren't met - no problem! - // So it's a pretty niche scenario. - if((ruleBehavior?.transcription?.transform as TextTransform)?.isNoOp() && keyEvent.kNextLayer) { - isOnlyLayerSwitchKey = true; - } + // If it's a key that we 'optimize out' of our fat-finger correction algorithm, + // we MUST NOT trigger it for this keystroke. + let isOnlyLayerSwitchKey = Codes.isKnownOSKModifierKey(keyEvent.kName); + + // Best-guess stopgap for possible custom modifier keys. + // If a key (1) does not affect the context and (2) shifts the active layer, + // we assume it's a modifier key. (Touch keyboards may define custom modifier keys.) + // + // Note: this will mean we won't generate alternates in the niche scenario where: + // 1. Keypress does not alter the actual context + // 2. It DOES emit a deadkey with an earlier processing rule. + // 3. The FINAL processing rule does not match. + // 4. The key ALSO signals a layer shift. + // If any of the four above conditions aren't met - no problem! + // So it's a pretty niche scenario. + if((ruleBehavior?.transcription?.transform as TextTransform)?.isNoOp() && keyEvent.kNextLayer) { + isOnlyLayerSwitchKey = true; + } - const keepRuleBehavior = ruleBehavior != null; - // Should we swallow any further processing of keystroke events for this keydown-keypress sequence? - if(keepRuleBehavior) { - // alternates are our fat-finger alternate outputs. We don't build these for keys we detect as - // layer switch keys - let alternates = isOnlyLayerSwitchKey ? null : this.buildAlternates(ruleBehavior, keyEvent, preInputMock); + const keepRuleBehavior = ruleBehavior != null; + // Should we swallow any further processing of keystroke events for this keydown-keypress sequence? + if(keepRuleBehavior) { + // alternates are our fat-finger alternate outputs. We don't build these for keys we detect as + // layer switch keys + let alternates = isOnlyLayerSwitchKey ? null : this.buildAlternates(ruleBehavior, keyEvent, preInputMock); - // Now that we've done all the keystroke processing needed, ensure any extra effects triggered - // by the actual keystroke occur. - ruleBehavior.finalize(this.keyboardProcessor, outputTarget, false); + // Now that we've done all the keystroke processing needed, ensure any extra effects triggered + // by the actual keystroke occur. + ruleBehavior.finalize(this.keyboardProcessor, outputTarget, false); - // -- All keystroke (and 'alternate') processing is now complete. Time to finalize everything! -- + // -- All keystroke (and 'alternate') processing is now complete. Time to finalize everything! -- - // Notify the ModelManager of new input - it's predictive text time! - if(alternates && alternates.length > 0) { - ruleBehavior.transcription.alternates = alternates; - } - } else { - // We need a dummy RuleBehavior for keys which have no output (e.g. Shift) - ruleBehavior = new RuleBehavior(); - ruleBehavior.transcription = outputTarget.buildTranscriptionFrom(outputTarget, null, false); - ruleBehavior.triggersDefaultCommand = true; + // Notify the ModelManager of new input - it's predictive text time! + if(alternates && alternates.length > 0) { + ruleBehavior.transcription.alternates = alternates; } + } else { + // We need a dummy RuleBehavior for keys which have no output (e.g. Shift) + ruleBehavior = new RuleBehavior(); + ruleBehavior.transcription = outputTarget.buildTranscriptionFrom(outputTarget, null, false); + ruleBehavior.triggersDefaultCommand = true; + } - // The keyboard may want to take an action after all other keystroke processing is - // finished, for example to switch layers. This action may not have any output - // but may change system store or variable store values. Given this, we don't need to - // save anything about the post behavior, after finalizing it - - // We need to tell the keyboard if the layer has been changed, either by a keyboard rule itself, - // or by the touch layout 'nextlayer' control. - const hasLayerChanged = ruleBehavior.setStore[KeyboardInterface.TSS_LAYER] || keyEvent.kNextLayer; - this.keyboardProcessor.newLayerStore.set(hasLayerChanged ? this.keyboardProcessor.layerId : ''); - this.keyboardProcessor.oldLayerStore.set(hasLayerChanged ? startingLayerId : ''); + // The keyboard may want to take an action after all other keystroke processing is + // finished, for example to switch layers. This action may not have any output + // but may change system store or variable store values. Given this, we don't need to + // save anything about the post behavior, after finalizing it - let postRuleBehavior = this.keyboardProcessor.processPostKeystroke(this.contextDevice, outputTarget); - if(postRuleBehavior) { - postRuleBehavior.finalize(this.keyboardProcessor, outputTarget, true); - } + // We need to tell the keyboard if the layer has been changed, either by a keyboard rule itself, + // or by the touch layout 'nextlayer' control. + const hasLayerChanged = ruleBehavior.setStore[SystemStoreIDs.TSS_LAYER] || keyEvent.kNextLayer; + this.keyboardProcessor.newLayerStore.set(hasLayerChanged ? this.keyboardProcessor.layerId : ''); + this.keyboardProcessor.oldLayerStore.set(hasLayerChanged ? startingLayerId : ''); - // Yes, even for ruleBehavior.triggersDefaultCommand. Those tend to change the context. - ruleBehavior.predictionPromise = this.languageProcessor.predict(ruleBehavior.transcription, this.keyboardProcessor.layerId); + let postRuleBehavior = this.keyboardProcessor.processPostKeystroke(this.contextDevice, outputTarget); + if(postRuleBehavior) { + postRuleBehavior.finalize(this.keyboardProcessor, outputTarget, true); + } - // Text did not change (thus, no text "input") if we tabbed or merely moved the caret. - if(!ruleBehavior.triggersDefaultCommand) { - // For DOM-aware targets, this will trigger a DOM event page designers may listen for. - outputTarget.doInputEvent(); - } + // Yes, even for ruleBehavior.triggersDefaultCommand. Those tend to change the context. + ruleBehavior.predictionPromise = this.languageProcessor.predict(ruleBehavior.transcription, this.keyboardProcessor.layerId); - return keepRuleBehavior ? ruleBehavior : null; + // Text did not change (thus, no text "input") if we tabbed or merely moved the caret. + if(!ruleBehavior.triggersDefaultCommand) { + // For DOM-aware targets, this will trigger a DOM event page designers may listen for. + outputTarget.doInputEvent(); } - private buildAlternates(ruleBehavior: RuleBehavior, keyEvent: KeyEvent, preInputMock: Mock): Alternate[] { - let alternates: Alternate[]; - - // If we're performing a 'default command', it's not a standard 'typing' event - don't do fat-finger stuff. - // Also, don't do fat-finger stuff if predictive text isn't enabled. - if(this.languageProcessor.isActive && !ruleBehavior.triggersDefaultCommand) { - let keyDistribution = keyEvent.keyDistribution; - - // We don't need to track absolute indexing during alternate-generation; - // only position-relative, so it's better to use a sliding window for context - // when making alternates. (Slightly worse for short text, matters greatly - // for long text.) - let contextWindow = new ContextWindow(preInputMock, ContextWindow.ENGINE_RULE_WINDOW, this.keyboardProcessor.layerId); - let windowedMock = contextWindow.toMock(); - - // Note - we don't yet do fat-fingering with longpress keys. - if(keyDistribution && keyEvent.kbdLayer) { - // Tracks a 'deadline' for fat-finger ops, just in case both context is long enough - // and device is slow enough that the calculation takes too long. - // - // Consider use of https://developer.mozilla.org/en-US/docs/Web/API/Performance/now instead? - // Would allow finer-tuned control. - let TIMEOUT_THRESHOLD: number = Number.MAX_VALUE; - let _globalThis = com.keyman.utils.getGlobalObject(); - let timer: () => number; - - // Available by default on `window` in browsers, but _not_ on `global` in Node, - // surprisingly. Since we can't use code dependent on `require` statements - // at present, we have to condition upon it actually existing. - if(_globalThis['performance'] && _globalThis['performance']['now']) { - timer = function() { - return _globalThis['performance']['now'](); - }; - - TIMEOUT_THRESHOLD = timer() + 16; // + 16ms. - } // else { - // We _could_ just use Date.now() as a backup... but that (probably) only matters - // when unit testing. So... we actually don't _need_ time thresholding when in - // a Node environment. - // } - - // Tracks a minimum probability for keystroke probability. Anything less will not be - // included in alternate calculations. - // - // Seek to match SearchSpace.EDIT_DISTANCE_COST_SCALE from the predictive-text engine. - // Reasoning for the selected value may be seen there. Short version - keystrokes - // that _appear_ very precise may otherwise not even consider directly-neighboring keys. - let KEYSTROKE_EPSILON = Math.exp(-5); - - // Sort the distribution into probability-descending order. - keyDistribution.sort((a, b) => b.p - a.p); - - let activeLayout = this.activeKeyboard.layout(keyEvent.device.formFactor); - alternates = []; - - let totalMass = 0; // Tracks sum of non-error probabilities. - for(let pair of keyDistribution) { - if(pair.p < KEYSTROKE_EPSILON) { - totalMass += pair.p; - break; - } else if(timer && timer() >= TIMEOUT_THRESHOLD) { - // Note: it's always possible that the thread _executing_ our JS - // got paused by the OS, even if JS itself is single-threaded. - // - // The case where `alternates` is initialized (line 167) but empty - // (because of net-zero loop iterations) MUST be handled. - break; - } - - let mock = Mock.from(windowedMock, false); - - let altKey = activeLayout.getLayer(keyEvent.kbdLayer).getKey(pair.keyId); - if(!altKey) { - console.warn("Potential fat-finger key could not be found in layer!"); - continue; - } - - let altEvent = altKey.constructKeyEvent(this.keyboardProcessor, keyEvent.device); - let alternateBehavior = this.keyboardProcessor.processKeystroke(altEvent, mock); - - // If alternateBehavior.beep == true, ignore it. It's a disallowed key sequence, - // so we expect users to never intend their use. + return keepRuleBehavior ? ruleBehavior : null; + } + + private buildAlternates(ruleBehavior: RuleBehavior, keyEvent: KeyEvent, preInputMock: Mock): Alternate[] { + let alternates: Alternate[]; + + // If we're performing a 'default command', it's not a standard 'typing' event - don't do fat-finger stuff. + // Also, don't do fat-finger stuff if predictive text isn't enabled. + if(this.languageProcessor.isActive && !ruleBehavior.triggersDefaultCommand) { + let keyDistribution = keyEvent.keyDistribution; + + // We don't need to track absolute indexing during alternate-generation; + // only position-relative, so it's better to use a sliding window for context + // when making alternates. (Slightly worse for short text, matters greatly + // for long text.) + let contextWindow = new ContextWindow(preInputMock, ContextWindow.ENGINE_RULE_WINDOW, this.keyboardProcessor.layerId); + let windowedMock = contextWindow.toMock(); + + // Note - we don't yet do fat-fingering with longpress keys. + if(this.languageProcessor.isActive && keyDistribution && keyEvent.kbdLayer) { + // Tracks a 'deadline' for fat-finger ops, just in case both context is long enough + // and device is slow enough that the calculation takes too long. + // + // Consider use of https://developer.mozilla.org/en-US/docs/Web/API/Performance/now instead? + // Would allow finer-tuned control. + let TIMEOUT_THRESHOLD: number = Number.MAX_VALUE; + let _globalThis = globalObject(); + let timer: () => number; + + // Available by default on `window` in browsers, but _not_ on `global` in Node, + // surprisingly. Since we can't use code dependent on `require` statements + // at present, we have to condition upon it actually existing. + if(_globalThis['performance'] && _globalThis['performance']['now']) { + timer = function() { + return _globalThis['performance']['now'](); + }; + + TIMEOUT_THRESHOLD = timer() + 16; // + 16ms. + } // else { + // We _could_ just use Date.now() as a backup... but that (probably) only matters + // when unit testing. So... we actually don't _need_ time thresholding when in + // a Node environment. + // } + + // Tracks a minimum probability for keystroke probability. Anything less will not be + // included in alternate calculations. + // + // Seek to match SearchSpace.EDIT_DISTANCE_COST_SCALE from the predictive-text engine. + // Reasoning for the selected value may be seen there. Short version - keystrokes + // that _appear_ very precise may otherwise not even consider directly-neighboring keys. + let KEYSTROKE_EPSILON = Math.exp(-5); + + // Sort the distribution into probability-descending order. + keyDistribution.sort((a, b) => b.p - a.p); + + let activeLayout = this.activeKeyboard.layout(keyEvent.device.formFactor); + alternates = []; + + let totalMass = 0; // Tracks sum of non-error probabilities. + for(let pair of keyDistribution) { + if(pair.p < KEYSTROKE_EPSILON) { + totalMass += pair.p; + break; + } else if(timer && timer() >= TIMEOUT_THRESHOLD) { + // Note: it's always possible that the thread _executing_ our JS + // got paused by the OS, even if JS itself is single-threaded. // - // Also possible that this set of conditions fail for all evaluated alternates. - if(alternateBehavior && !alternateBehavior.beep && pair.p > 0) { - let transform: Transform = alternateBehavior.transcription.transform; - - // Ensure that the alternate's token id matches that of the current keystroke, as we only - // record the matched rule's context (since they match) - transform.id = ruleBehavior.transcription.token; - alternates.push({sample: transform, 'p': pair.p}); - totalMass += pair.p; - } + // The case where `alternates` is initialized (line 167) but empty + // (because of net-zero loop iterations) MUST be handled. + break; } - // Renormalizes the distribution, as any error (beep) results - // will result in a distribution that doesn't sum to 1 otherwise. - // All `.p` values are strictly positive, so totalMass is - // guaranteed to be > 0 if the array has entries. - alternates.forEach(function(alt) { - alt.p /= totalMass; - }); - } - } - return alternates; - } + let mock = Mock.from(windowedMock, false); - public resetContext(outputTarget?: OutputTarget) { - this.keyboardProcessor.resetContext(); - this.languageProcessor.invalidateContext(outputTarget, this.keyboardProcessor.layerId); + let altKey = activeLayout.getLayer(keyEvent.kbdLayer).getKey(pair.keyId); + if(!altKey) { + console.warn("Potential fat-finger key could not be found in layer!"); + continue; + } - // Let the keyboard do its initial group processing - //console.log('processNewContextEvent called from resetContext'); - if(outputTarget) { - this.processNewContextEvent(outputTarget); + let altEvent = this.keyboardProcessor.activeKeyboard.constructKeyEvent(altKey, keyEvent.device, this.keyboardProcessor.stateKeys); + let alternateBehavior = this.keyboardProcessor.processKeystroke(altEvent, mock); + + // If alternateBehavior.beep == true, ignore it. It's a disallowed key sequence, + // so we expect users to never intend their use. + // + // Also possible that this set of conditions fail for all evaluated alternates. + if(alternateBehavior && !alternateBehavior.beep && pair.p > 0) { + let transform: Transform = alternateBehavior.transcription.transform; + + // Ensure that the alternate's token id matches that of the current keystroke, as we only + // record the matched rule's context (since they match) + transform.id = ruleBehavior.transcription.token; + alternates.push({sample: transform, 'p': pair.p}); + totalMass += pair.p; + } + } + + // Renormalizes the distribution, as any error (beep) results + // will result in a distribution that doesn't sum to 1 otherwise. + // All `.p` values are strictly positive, so totalMass is + // guaranteed to be > 0 if the array has entries. + alternates.forEach(function(alt) { + alt.p /= totalMass; + }); } } + return alternates; } -} - -(function () { - let ns = com.keyman.text; - // Let the InputProcessor be available both in the browser and in Node. - if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { - module.exports = ns.InputProcessor; - //@ts-ignore - ns.InputProcessor.com = com; // Export the root namespace so that all InputProcessor classes are accessible by unit tests. + public resetContext(outputTarget?: OutputTarget) { + this.keyboardProcessor.resetContext(outputTarget); + this.languageProcessor.invalidateContext(outputTarget, this.keyboardProcessor.layerId); } -}()); \ No newline at end of file +} \ No newline at end of file diff --git a/common/web/input-processor/src/text/prediction/languageProcessor.ts b/common/web/input-processor/src/text/prediction/languageProcessor.ts index 12511cfb9f..e7405c5dfd 100644 --- a/common/web/input-processor/src/text/prediction/languageProcessor.ts +++ b/common/web/input-processor/src/text/prediction/languageProcessor.ts @@ -1,288 +1,226 @@ -/// -/// - -namespace com.keyman.text.prediction { - export interface ModelSpec { - /** - * The model's unique identifier. - */ - id: string; - - /** - * The list of supported BCP-47 language codes. Only one language should be supported, - * although multiple variants based on region code (at min) may be specified. - */ - languages: string[]; - - /** - * The path/URL to the file that defines the model. If both `path` and `raw` are specified, - * `path` takes precedence. - */ - path: string; - - /** - * The raw JS script defining the model. Only used if `path` is not specified. - */ - code: string; +import EventEmitter from "eventemitter3"; +import { LMLayer } from "@keymanapp/lexical-model-layer/web"; +import { OutputTarget, Transcription, Mock } from "@keymanapp/keyboard-processor"; +import ContextWindow from "../contextWindow.js"; +import ModelSpec from "./modelSpec.js" + +/** + * Corresponds to the 'suggestionsready' LanguageProcessor event. + */ +export type ReadySuggestionsHandler = (prediction: ReadySuggestions) => boolean; + +export type StateChangeEnum = 'active'|'configured'|'inactive'; +/** + * Corresponds to the 'statechange' LanguageProcessor event. + */ +export type StateChangeHandler = (state: StateChangeEnum) => boolean; + +/** + * Covers 'tryaccept' events. + */ +export type TryUIHandler = (source: string) => boolean; + +export type InvalidateSourceEnum = 'new'|'context'; + +/** + * Corresponds to the 'invalidatesuggestions' LanguageProcessor event. + */ +export type InvalidateSuggestionsHandler = (source: InvalidateSourceEnum) => boolean; + +export class ReadySuggestions { + suggestions: Suggestion[]; + transcriptionID: number; + + constructor(suggestions: Suggestion[], id: number) { + this.suggestions = suggestions; + this.transcriptionID = id; } +} - /** - * Corresponds to the 'suggestionsready' LanguageProcessor event. - */ - export type ReadySuggestionsHandler = (prediction: ReadySuggestions) => boolean; +interface LanguageProcessorEventMap { + 'suggestionsready': ReadySuggestionsHandler, + 'invalidatesuggestions': InvalidateSuggestionsHandler, + 'statechange': StateChangeHandler, + 'tryaccept': TryUIHandler, + 'tryrevert': () => void, - export type StateChangeEnum = 'active'|'configured'|'inactive'; /** - * Corresponds to the 'statechange' LanguageProcessor event. + * Is called synchronously once suggestion application is successful and the context has been updated. + * + * @param outputTarget The `OutputTarget` representation of the context the suggestion was applied to. + * @returns */ - export type StateChangeHandler = (state: StateChangeEnum) => boolean; + 'suggestionapplied': (outputTarget: OutputTarget) => boolean +} - /** - * Covers both 'tryaccept' and 'tryrevert' events. - */ - export type TryUIHandler = (source: string) => boolean; +/* Is more like the model configuration engine */ +export default class LanguageProcessor extends EventEmitter { + private lmEngine: LMLayer; + private currentModel?: ModelSpec; + private configuration?: Configuration; + private currentPromise?: Promise; - export type InvalidateSourceEnum = 'new'|'context'; + private recentTranscriptions: Transcription[] = []; - /** - * Corresponds to the 'invalidatesuggestions' LanguageProcessor event. - */ - export type InvalidateSuggestionsHandler = (source: InvalidateSourceEnum) => boolean; + private _mayPredict: boolean = true; + private _mayCorrect: boolean = true; - export class ReadySuggestions { - suggestions: Suggestion[]; - transcriptionID: number; + private _state: StateChangeEnum = 'inactive'; - constructor(suggestions: Suggestion[], id: number) { - this.suggestions = suggestions; - this.transcriptionID = id; - } - } + private static readonly TRANSCRIPTION_BUFFER: 10 = 10; - type SupportedEventNames = "suggestionsready" | "invalidatesuggestions" | "statechange" | "tryaccept" | "tryrevert"; - type SupportedEventHandler = InvalidateSuggestionsHandler | ReadySuggestionsHandler | StateChangeHandler | TryUIHandler; + public constructor(predictiveTextWorker: Worker, supportsRightDeletions: boolean = false) { + super(); - export class LanguageProcessor extends EventEmitter { - private lmEngine: LMLayer; - private currentModel?: ModelSpec; - private configuration?: Configuration; - private currentPromise?: Promise; + // Establishes KMW's platform 'capabilities', which limit the range of context a LMLayer + // model may expect. + let capabilities: Capabilities = { + maxLeftContextCodePoints: 64, + // Since the apps don't yet support right-deletions. + maxRightContextCodePoints: supportsRightDeletions ? 0 : 64 + } - private recentTranscriptions: Transcription[] = []; + if(!this.canEnable() || !predictiveTextWorker) { + return; + } - private _mayPredict: boolean = true; - private _mayCorrect: boolean = true; + // TODO: this needs to be supplied separately - runs on Node need to refer to a different type! + this.lmEngine = new LMLayer(capabilities, predictiveTextWorker); + } - private static readonly TRANSCRIPTION_BUFFER: 10 = 10; + public get activeModel(): ModelSpec { + return this.currentModel; + } - public init(supportsRightDeletions: boolean = false) { - // Establishes KMW's platform 'capabilities', which limit the range of context a LMLayer - // model may expect. - let capabilities: Capabilities = { - maxLeftContextCodePoints: 64, - // Since the apps don't yet support right-deletions. - maxRightContextCodePoints: supportsRightDeletions ? 0 : 64 - } + public get isConfigured(): boolean { + return !!this.configuration; + } - if(!this.canEnable()) { - return; - } + public get state(): StateChangeEnum { + return this._state; + } - this.lmEngine = new LMLayer(capabilities); - } + public unloadModel() { + this.lmEngine.unloadModel(); + delete this.currentModel; + delete this.configuration; - public get activeModel(): ModelSpec { - return this.currentModel; + this._state = 'inactive'; + this.emit('statechange', 'inactive'); + } + + loadModel(model: ModelSpec): Promise { + if(!model) { + throw new Error("Null reference not allowed."); } - public unloadModel() { - this.lmEngine.unloadModel(); - delete this.currentModel; - delete this.configuration; + let specType: 'file'|'raw' = model.path ? 'file' : 'raw'; + let source = specType == 'file' ? model.path : model.code; - this.emit('statechange', 'inactive'); + // We pre-emptively emit so that the banner's DOM elements may update synchronously. + // Prevents an ugly "flash of unstyled content" layout issue during keyboard load + // on our mobile platforms when embedded. + this.currentModel = model; + if(this.mayPredict) { + this._state = 'active'; + this.emit('statechange', 'active'); } - loadModel(model: ModelSpec): Promise { - if(!model) { - throw new Error("Null reference not allowed."); + return this.lmEngine.loadModel(source, specType).then((config: Configuration) => { + this.configuration = config; + this._state = 'configured'; + this.emit('statechange', 'configured'); + }).catch((error) => { + // Does this provide enough logging information? + let message: string; + if(error instanceof Error) { + message = error.message; + } else { + message = String(error); } + console.error("Could not load model '" + model.id + "': " + message); - let specType: 'file'|'raw' = model.path ? 'file' : 'raw'; - let source = specType == 'file' ? model.path : model.code; - let lp = this; - - // We pre-emptively emit so that the banner's DOM elements may update synchronously. - // Prevents an ugly "flash of unstyled content" layout issue during keyboard load - // on our mobile platforms when embedded. - lp.currentModel = model; - if(this.mayPredict) { - lp.emit('statechange', 'active'); - } + // Since the model couldn't load, immediately deactivate. Visually, it'll look + // like the banner crashed shortly after load. + this.currentModel = null; + this._state = 'inactive'; + this.emit('statechange', 'inactive'); + }); + } - return this.lmEngine.loadModel(source, specType).then(function(config: Configuration) { - lp.configuration = config; - lp.emit('statechange', 'configured'); - }).catch(function(error) { - // Does this provide enough logging information? - let message: string; - if(error instanceof Error) { - message = error.message; - } else { - message = String(error); - } - console.error("Could not load model '" + model.id + "': " + message); + public invalidateContext(outputTarget: OutputTarget, layerId: string): Promise { + // Signal to any predictive text UI that the context has changed, invalidating recent predictions. + this.emit('invalidatesuggestions', 'context'); - // Since the model couldn't load, immediately deactivate. Visually, it'll look - // like the banner crashed shortly after load. - lp.currentModel = null; - lp.emit('statechange', 'inactive'); - }); + // If there's no active model, there can be no predictions. + // We'll also be missing important data needed to even properly REQUEST the predictions. + if(!this.currentModel || !this.configuration) { + return Promise.resolve([]); } - public invalidateContext(outputTarget: OutputTarget, layerId: string) { - // Signal to any predictive text UI that the context has changed, invalidating recent predictions. - this.emit('invalidatesuggestions', 'context'); - - // If there's no active model, there can be no predictions. - // We'll also be missing important data needed to even properly REQUEST the predictions. - if(!this.currentModel || !this.configuration) { - return; - } - - // Don't attempt predictions when disabled! - // invalidateContext otherwise bypasses .predict()'s check against this. - if(!this.isActive) { - return; - } else if(outputTarget) { - let transcription = outputTarget.buildTranscriptionFrom(outputTarget, null, false); - this.predict_internal(transcription, true, layerId); - } + // Don't attempt predictions when disabled! + // invalidateContext otherwise bypasses .predict()'s check against this. + if(!this.isActive) { + return Promise.resolve([]); + } else if(outputTarget) { + let transcription = outputTarget.buildTranscriptionFrom(outputTarget, null, false); + return this.predict_internal(transcription, true, layerId); } + } - public wordbreak(target: OutputTarget, layerId: string): Promise { - if(!this.isActive) { - return null; - } - - let context = new ContextWindow(Mock.from(target, false), this.configuration, layerId); - return this.lmEngine.wordbreak(context); + public wordbreak(target: OutputTarget, layerId: string): Promise { + if(!this.isActive) { + return null; } - public predict(transcription: Transcription, layerId: string): Promise { - if(!this.isActive) { - return null; - } - - // If there's no active model, there can be no predictions. - // We'll also be missing important data needed to even properly REQUEST the predictions. - if(!this.currentModel || !this.configuration) { - return null; - } - - // We've already invalidated any suggestions resulting from any previously-existing Promise - - // may as well officially invalidate them via event. - this.emit("invalidatesuggestions", 'new'); + let context = new ContextWindow(Mock.from(target, false), this.configuration, layerId); + return this.lmEngine.wordbreak(context); + } - return this.predict_internal(transcription, false, layerId); + public predict(transcription: Transcription, layerId: string): Promise { + if(!this.isActive) { + return null; } - /** - * - * @param suggestion - * @param outputTarget - * @param getLayerId a function that returns the current layerId, - * required because layerid can be changed by PostKeystroke - * @returns - */ - public applySuggestion(suggestion: Suggestion, outputTarget: OutputTarget, getLayerId: ()=>string): Promise { - if(!outputTarget) { - throw "Accepting suggestions requires a destination OutputTarget instance." - } - - // Find the state of the context at the time the suggestion was generated. - // This may refer to the context before an input keystroke or before application - // of a predictive suggestion. - let original = this.getPredictionState(suggestion.transformId); - if(!original) { - console.warn("Could not apply the Suggestion!"); - return null; - } else { - // Apply the Suggestion! - - // Step 1: determine the final output text - let final = text.Mock.from(original.preInput, false); - final.apply(suggestion.transform); - - // Step 2: build a final, master Transform that will produce the desired results from the CURRENT state. - // In embedded mode, both Android and iOS are best served by calculating this transform and applying its - // values as needed for use with their IME interfaces. - let transform = final.buildTransformFrom(outputTarget); - outputTarget.apply(transform); - - // Tell the banner that a suggestion was applied, so it can call the - // keyboard's PostKeystroke entry point as needed - this.emit('suggestionapplied', outputTarget); - - // Build a 'reversion' Transcription that can be used to undo this apply() if needed, - // replacing the suggestion transform with the original input text. - let preApply = text.Mock.from(original.preInput, false); - preApply.apply(original.transform); - - // Builds the reversion option according to the loaded lexical model's known - // syntactic properties. - let suggestionContext = new ContextWindow(original.preInput, this.configuration, getLayerId()); - - // We must accept the Suggestion from its original context, which was before - // `original.transform` was applied. - let reversionPromise: Promise = this.lmEngine.acceptSuggestion(suggestion, suggestionContext, original.transform); - - // Also, request new prediction set based on the resulting context. - let lp = this; - reversionPromise = reversionPromise.then(function(reversion) { - let mappedReversion: Reversion = { - // By mapping back to the original Transcription that generated the Suggestion, - // the input will be automatically rewound to the preInput state. - transform: original.transform, - // The ID part is critical; the reversion can't be applied without it. - transformId: -original.token, // reversions use the additive inverse. - displayAs: reversion.displayAs, // The real reason we needed to call the LMLayer. - id: reversion.id, - tag: reversion.tag - } - // // If using the version from lm-layer: - // let mappedReversion = reversion; - // mappedReversion.transformId = reversionTranscription.token; - lp.predictFromTarget(outputTarget, getLayerId()); - return mappedReversion; - }); - - return reversionPromise; - } + // If there's no active model, there can be no predictions. + // We'll also be missing important data needed to even properly REQUEST the predictions. + if(!this.currentModel || !this.configuration) { + return null; } - public applyReversion(reversion: Reversion, outputTarget: OutputTarget) { - if(!outputTarget) { - throw "Accepting suggestions requires a destination OutputTarget instance." - } + // We've already invalidated any suggestions resulting from any previously-existing Promise - + // may as well officially invalidate them via event. + this.emit("invalidatesuggestions", 'new'); - // Find the state of the context at the time the suggestion was generated. - // This may refer to the context before an input keystroke or before application - // of a predictive suggestion. - // - // Reversions use the additive inverse of the id token of the Transcription being - // reverted to. - let original = this.getPredictionState(-reversion.transformId); - if(!original) { - console.warn("Could not apply the Suggestion!"); - return; - } + return this.predict_internal(transcription, false, layerId); + } + + /** + * + * @param suggestion + * @param outputTarget + * @param getLayerId a function that returns the current layerId, + * required because layerid can be changed by PostKeystroke + * @returns + */ + public applySuggestion(suggestion: Suggestion, outputTarget: OutputTarget, getLayerId: ()=>string): Promise { + if(!outputTarget) { + throw "Accepting suggestions requires a destination OutputTarget instance." + } - // Apply the Reversion! + // Find the state of the context at the time the suggestion was generated. + // This may refer to the context before an input keystroke or before application + // of a predictive suggestion. + let original = this.getPredictionState(suggestion.transformId); + if(!original) { + console.warn("Could not apply the Suggestion!"); + return null; + } else { + // Apply the Suggestion! // Step 1: determine the final output text - let final = text.Mock.from(original.preInput, false); - final.apply(reversion.transform); // Should match original.transform, actually. (See applySuggestion) + let final = Mock.from(original.preInput, false); + final.apply(suggestion.transform); // Step 2: build a final, master Transform that will produce the desired results from the CURRENT state. // In embedded mode, both Android and iOS are best served by calculating this transform and applying its @@ -290,155 +228,227 @@ namespace com.keyman.text.prediction { let transform = final.buildTransformFrom(outputTarget); outputTarget.apply(transform); - // The reason we need to preserve the additive-inverse 'transformId' property on Reversions. - let promise = this.lmEngine.revertSuggestion(reversion, new ContextWindow(original.preInput, this.configuration, null)) - - let lp = this; - return promise.then(function(suggestions: Suggestion[]) { - let result = new ReadySuggestions(suggestions, transform.id); - lp.emit("suggestionsready", result); - lp.currentPromise = null; - - return suggestions; + // Tell the banner that a suggestion was applied, so it can call the + // keyboard's PostKeystroke entry point as needed + this.emit('suggestionapplied', outputTarget); + + // Build a 'reversion' Transcription that can be used to undo this apply() if needed, + // replacing the suggestion transform with the original input text. + let preApply = Mock.from(original.preInput, false); + preApply.apply(original.transform); + + // Builds the reversion option according to the loaded lexical model's known + // syntactic properties. + let suggestionContext = new ContextWindow(original.preInput, this.configuration, getLayerId()); + + // We must accept the Suggestion from its original context, which was before + // `original.transform` was applied. + let reversionPromise: Promise = this.lmEngine.acceptSuggestion(suggestion, suggestionContext, original.transform); + + // Also, request new prediction set based on the resulting context. + reversionPromise = reversionPromise.then((reversion) => { + let mappedReversion: Reversion = { + // By mapping back to the original Transcription that generated the Suggestion, + // the input will be automatically rewound to the preInput state. + transform: original.transform, + // The ID part is critical; the reversion can't be applied without it. + transformId: -original.token, // reversions use the additive inverse. + displayAs: reversion.displayAs, // The real reason we needed to call the LMLayer. + id: reversion.id, + tag: reversion.tag + } + // // If using the version from lm-layer: + // let mappedReversion = reversion; + // mappedReversion.transformId = reversionTranscription.token; + this.predictFromTarget(outputTarget, getLayerId()); + return mappedReversion; }); + + return reversionPromise; } + } - public predictFromTarget(outputTarget: OutputTarget, layerId: string): Promise { - if(!outputTarget) { - return null; - } + public applyReversion(reversion: Reversion, outputTarget: OutputTarget) { + if(!outputTarget) { + throw "Accepting suggestions requires a destination OutputTarget instance." + } - let transcription = outputTarget.buildTranscriptionFrom(outputTarget, null, false); - return this.predict(transcription, layerId); + // Find the state of the context at the time the suggestion was generated. + // This may refer to the context before an input keystroke or before application + // of a predictive suggestion. + // + // Reversions use the additive inverse of the id token of the Transcription being + // reverted to. + let original = this.getPredictionState(-reversion.transformId); + if(!original) { + console.warn("Could not apply the Suggestion!"); + return; } - /** - * Called internally to do actual predictions after any relevant "invalidatesuggestions" events - * have been raised. - * @param transcription The triggering transcription (if it exists) - */ - private predict_internal(transcription: Transcription, resetContext: boolean, layerId: string): Promise { - if(!transcription) { - return null; - } + // Apply the Reversion! - let context = new ContextWindow(transcription.preInput, this.configuration, layerId); - this.recordTranscription(transcription); + // Step 1: determine the final output text + let final = Mock.from(original.preInput, false); + final.apply(reversion.transform); // Should match original.transform, actually. (See applySuggestion) - if(resetContext) { - this.lmEngine.resetContext(context); - } + // Step 2: build a final, master Transform that will produce the desired results from the CURRENT state. + // In embedded mode, both Android and iOS are best served by calculating this transform and applying its + // values as needed for use with their IME interfaces. + let transform = final.buildTransformFrom(outputTarget); + outputTarget.apply(transform); - let alternates = transcription.alternates; - if(!alternates || alternates.length == 0) { - alternates = [{ - sample: transcription.transform, - p: 1.0 - }]; - } + // The reason we need to preserve the additive-inverse 'transformId' property on Reversions. + let promise = this.lmEngine.revertSuggestion(reversion, new ContextWindow(original.preInput, this.configuration, null)) - let transform = transcription.transform; - var promise = this.currentPromise = this.lmEngine.predict(alternates, context); + return promise.then((suggestions: Suggestion[]) => { + let result = new ReadySuggestions(suggestions, transform.id); + this.emit("suggestionsready", result); + this.currentPromise = null; - let lp = this; - return promise.then(function(suggestions: Suggestion[]) { - if(promise == lp.currentPromise) { - let result = new ReadySuggestions(suggestions, transform.id); - lp.emit("suggestionsready", result); - lp.currentPromise = null; - } + return suggestions; + }); + } - return suggestions; - }); + public predictFromTarget(outputTarget: OutputTarget, layerId: string): Promise { + if(!outputTarget) { + return null; } - private recordTranscription(transcription: Transcription) { - this.recentTranscriptions.push(transcription); + let transcription = outputTarget.buildTranscriptionFrom(outputTarget, null, false); + return this.predict(transcription, layerId); + } - if(this.recentTranscriptions.length > LanguageProcessor.TRANSCRIPTION_BUFFER) { - this.recentTranscriptions.splice(0, 1); - } + /** + * Called internally to do actual predictions after any relevant "invalidatesuggestions" events + * have been raised. + * @param transcription The triggering transcription (if it exists) + */ + private predict_internal(transcription: Transcription, resetContext: boolean, layerId: string): Promise { + if(!transcription) { + return null; } - /** - * Retrieves the context and output state of KMW immediately before the prediction with - * token `id` was generated. Must correspond to a 'recent' one, as only so many are stored - * in `ModelManager`'s history buffer. - * @param id A unique identifier corresponding to a recent `Transcription`. - * @returns The matching `Transcription`, or `null` none is found. - */ - public getPredictionState(id: number): Transcription { - let match = this.recentTranscriptions.filter(function(t: Transcription) { - return t.token == id; - }) - - return match.length == 0 ? null : match[0]; + let context = new ContextWindow(transcription.preInput, this.configuration, layerId); + this.recordTranscription(transcription); + + if(resetContext) { + this.lmEngine.resetContext(context); } - public shutdown() { - this.lmEngine.shutdown(); + let alternates = transcription.alternates; + if(!alternates || alternates.length == 0) { + alternates = [{ + sample: transcription.transform, + p: 1.0 + }]; } - public get isActive(): boolean { - if(!this.canEnable()) { - this._mayPredict = false; - return false; + let transform = transcription.transform; + var promise = this.currentPromise = this.lmEngine.predict(alternates, context); + + return promise.then((suggestions: Suggestion[]) => { + if(promise == this.currentPromise) { + let result = new ReadySuggestions(suggestions, transform.id); + this.emit("suggestionsready", result); + this.currentPromise = null; } - return (this.activeModel || false) && this._mayPredict; - } - public canEnable(): boolean { - // Is overridden for dom-aware KMW in case of old IE versions. - return true; - } + return suggestions; + }); + } + + private recordTranscription(transcription: Transcription) { + this.recentTranscriptions.push(transcription); - public get mayPredict() { - return this._mayPredict; + if(this.recentTranscriptions.length > LanguageProcessor.TRANSCRIPTION_BUFFER) { + this.recentTranscriptions.splice(0, 1); } + } - public set mayPredict(flag: boolean) { - if(!this.canEnable()) { - return; - } + /** + * Retrieves the context and output state of KMW immediately before the prediction with + * token `id` was generated. Must correspond to a 'recent' one, as only so many are stored + * in `ModelManager`'s history buffer. + * @param id A unique identifier corresponding to a recent `Transcription`. + * @returns The matching `Transcription`, or `null` none is found. + */ + public getPredictionState(id: number): Transcription { + let match = this.recentTranscriptions.filter((t: Transcription) => { + return t.token == id; + }) - let oldVal = this._mayPredict; - this._mayPredict = flag; + return match.length == 0 ? null : match[0]; + } - if(oldVal != flag) { - // If there's no model to be activated and we've reached this point, - // the banner should remain inactive, as it already was. - // If it there was one and we've reached this point, we're globally - // deactivating, so we're fine. - if(this.activeModel) { - this.emit('statechange', flag ? 'active' : 'inactive'); - } - } - } + public shutdown() { + this.lmEngine.shutdown(); + } - public get mayCorrect() { - return this._mayCorrect; + public get isActive(): boolean { + if(!this.canEnable()) { + this._mayPredict = false; + return false; } + return (this.activeModel || false) && this._mayPredict; + } - public set mayCorrect(flag: boolean) { - this._mayCorrect = flag; + public canEnable(): boolean { + // Is overridden for dom-aware KMW in case of old IE versions. + return true; + } + + public get mayPredict() { + return this._mayPredict; + } + + public set mayPredict(flag: boolean) { + if(!this.canEnable()) { + return; } - public get wordbreaksAfterSuggestions() { - return this.configuration.wordbreaksAfterSuggestions; + let oldVal = this._mayPredict; + this._mayPredict = flag; + + if(oldVal != flag) { + // If there's no model to be activated and we've reached this point, + // the banner should remain inactive, as it already was. + // If it there was one and we've reached this point, we're globally + // deactivating, so we're fine. + if(this.activeModel) { + let state: StateChangeEnum = flag ? 'active' : 'inactive'; + this._state = state; + this.emit('statechange', state); + } } + } - public tryAcceptSuggestion(source: string): boolean { - let returnObj = {shouldSwallow: false}; - this.emit('tryaccept', source, returnObj); + public get mayCorrect() { + return this._mayCorrect; + } - return returnObj.shouldSwallow; - } + public set mayCorrect(flag: boolean) { + this._mayCorrect = flag; + } - public tryRevertSuggestion(): boolean { - let returnObj = {shouldSwallow: false}; - this.emit('tryrevert', returnObj); + public get wordbreaksAfterSuggestions() { + return this.configuration.wordbreaksAfterSuggestions; + } - return returnObj.shouldSwallow; - } + public tryAcceptSuggestion(source: string): boolean { + // If and when we do auto-correct, the suggestion is to pass this object to the event and + // denote any mutations to the contained value. + //let returnObj = {shouldSwallow: false}; + this.emit('tryaccept', source); + + return false; + } + + public tryRevertSuggestion(): boolean { + // If and when we do auto-revert, the suggestion is to pass this object to the event and + // denote any mutations to the contained value. + //let returnObj = {shouldSwallow: false}; + this.emit('tryrevert'); + + return false; } } \ No newline at end of file diff --git a/common/web/input-processor/src/text/prediction/modelSpec.ts b/common/web/input-processor/src/text/prediction/modelSpec.ts new file mode 100644 index 0000000000..986d946311 --- /dev/null +++ b/common/web/input-processor/src/text/prediction/modelSpec.ts @@ -0,0 +1,27 @@ +/** + * Defines the metadata needed to support and load lexical models compatible with + * Keyman Engine for Web. + */ +export default interface ModelSpec { + /** + * The model's unique identifier. + */ + id: string; + + /** + * The list of supported BCP-47 language codes. Only one language should be supported, + * although multiple variants based on region code (at min) may be specified. + */ + languages: string[]; + + /** + * The path/URL to the file that defines the model. If both `path` and `code` are specified, + * `path` takes precedence. + */ + path: string; + + /** + * The raw JS script defining the model. Only used if `path` is not specified. + */ + code: string; +} \ No newline at end of file diff --git a/common/web/input-processor/src/text/prediction/predictionContext.ts b/common/web/input-processor/src/text/prediction/predictionContext.ts new file mode 100644 index 0000000000..a31a008ea3 --- /dev/null +++ b/common/web/input-processor/src/text/prediction/predictionContext.ts @@ -0,0 +1,351 @@ +import EventEmitter from "eventemitter3"; +import type LanguageProcessor from "./languageProcessor.js"; +import { type ReadySuggestions, type InvalidateSourceEnum } from './languageProcessor.js'; +import { type KeyboardProcessor, type OutputTarget } from "@keymanapp/keyboard-processor"; + +interface PredictionContextEventMap { + update: (suggestions: Suggestion[]) => void; +} + +/** + * Maintains predictive-text state information corresponding to the current context. + */ +export default class PredictionContext extends EventEmitter { + // Historical note: before 17.0, this code was intertwined with /web/source/osk/banner.ts's + // SuggestionBanner class. This class serves as the main implementation of the banner's core logic. + + // Designed for use with auto-correct behavior + private selected: Suggestion; + + private initNewContext: boolean = true; + + private _currentSuggestions: Suggestion[] = []; + private keepSuggestion: Keep; + private revertSuggestion: Reversion; + + private recentAccept: boolean = false; + private revertAcceptancePromise: Promise; + + private swallowPrediction: boolean = false; + + private doRevert: boolean = false; + private recentRevert: boolean = false; + + private langProcessor: LanguageProcessor; + private kbdProcessor: KeyboardProcessor; + + /** + * Represents the active context used when requesting and applying predictive-text operations. + */ + private _currentTarget: OutputTarget; + + public get currentTarget(): OutputTarget { + return this._currentTarget; + } + + public setCurrentTarget(target: OutputTarget): Promise { + const originalTarget = this._currentTarget; + this._currentTarget = target; + + if(originalTarget != target) { + // Note: should be triggered after the corresponding new-context event rule has been processed, + // as that may affect the value of layerId here. + return this.resetContext(); + } else { + return Promise.resolve([]); + } + } + + private readonly suggestionApplier: (suggestion: Suggestion) => Promise; + private readonly suggestionReverter: (reversion: Reversion) => void; + + /** + * Handler for post-processing once a suggestion has been applied: calls + * into the active keyboard's `begin postKeystroke` entry point. + * + * Called after the suggestion is applied but _before_ new predictions are + * requested based on the resulting context. + */ + private readonly postApplicationHandler: () => void; + + public constructor(langProcessor: LanguageProcessor, kbdProcessor: KeyboardProcessor) { + super(); + + if(langProcessor.state == 'inactive') { + throw new Error("Invalid state: no predictive-text model is currently available."); + } + this.langProcessor = langProcessor; + this.kbdProcessor = kbdProcessor; + + const validSuggestionState: () => boolean = () => + this.currentTarget && langProcessor.state == 'configured'; + + this.suggestionApplier = (suggestion) => { + if(validSuggestionState()) { + return langProcessor.applySuggestion(suggestion, this.currentTarget, () => kbdProcessor.layerId); + } + } + + this.suggestionReverter = (reversion) => { + if(validSuggestionState()) { + langProcessor.applyReversion(reversion, this.currentTarget); + } + } + + // As it's called synchronously via event-callback during `this.suggestionApplier`, + // `this.currentTarget` is guaranteed to remain unchanged. + this.postApplicationHandler = () => { + // Tell the keyboard that the current layer has not changed + kbdProcessor.newLayerStore.set(''); + kbdProcessor.oldLayerStore.set(''); + // Call the keyboard's entry point. + kbdProcessor.processPostKeystroke(kbdProcessor.contextDevice, this.currentTarget) + // If we have a RuleBehavior as a result, run it on the target. This should + // only change system store and variable store values. + ?.finalize(kbdProcessor, this.currentTarget, true); + }; + + this.connect(); + } + + private connect() { + this.langProcessor.addListener('invalidatesuggestions', this.invalidateSuggestions); + this.langProcessor.addListener('suggestionsready', this.updateSuggestions); + this.langProcessor.addListener('tryaccept', this.doTryAccept); + this.langProcessor.addListener('tryrevert', this.doTryRevert); + + this.langProcessor.addListener('suggestionapplied', this.postApplicationHandler); + } + + public disconnect() { + this.langProcessor.removeListener('invalidatesuggestions', this.invalidateSuggestions); + this.langProcessor.removeListener('suggestionsready', this.updateSuggestions); + this.langProcessor.removeListener('tryaccept', this.doTryAccept); + this.langProcessor.removeListener('tryrevert', this.doTryRevert); + + this.langProcessor.removeListener('suggestionapplied', this.postApplicationHandler); + this.clearSuggestions(); + } + + public get currentSuggestions(): Suggestion[] { + let suggestions = []; + // Insert 'current text' if/when valid as the leading option. + // Since we don't yet do auto-corrections, we only show 'keep' whenever it's + // a valid word (according to the model). + + if(this.activateKeep() && this.keepSuggestion && this.keepSuggestion.matchesModel) { + suggestions.push(this.keepSuggestion); + } else if(this.doRevert) { + suggestions.push(this.revertSuggestion); + } + + return suggestions.concat(this._currentSuggestions); + } + + /** + * Function apply + * Description Applies the predictive `Suggestion` represented by this `BannerSuggestion`. + */ + private acceptInternal(suggestion: Suggestion): Promise { + if(!suggestion) { + return null; + } + + // Should be safe to convert into an event handled externally. + // layerID can be obtained by whoever/whatever holds the InputProcessor instance. + if(suggestion.tag == 'revert') { + this.suggestionReverter(suggestion as Reversion); + return null; + } else { + return this.suggestionApplier(suggestion); + } + } + + /** + * Applies predictive-text suggestions and post-acceptance reversions to the current + * prediction context. + * + * Note that both cases will additionally trigger a new asynchronous `predict` operation, + * though no corresponding Promise is returned by this function. As such, the current + * suggestions should be considered outdated after calling this method, pending replacement + * upon the completed async `predict`. + * + * @param suggestion Either a `Suggestion` or `Reversion`. + * @returns if `suggestion` is a `Suggestion`, will return a Promise`; else, `null`. + */ + public accept(suggestion: Suggestion): Promise | null { + let _this = this; + + // Selecting a suggestion or a reversion should both clear selection + // and clear the reversion-displaying state of the banner. + this.selected = null; + this.doRevert = false; + + this.revertAcceptancePromise = this.acceptInternal(suggestion); + if(!this.revertAcceptancePromise) { + // We get here either if suggestion acceptance fails or if it was a reversion. + if(suggestion && suggestion.tag == 'revert') { + // Reversion state management + this.recentAccept = false; + this.recentRevert = true; + } + return null; + } + + this.revertAcceptancePromise.then(function(suggestion) { + // Always null-check! + if(suggestion) { + _this.revertSuggestion = suggestion; + } + }); + + this.recentAccept = true; + this.recentRevert = false; + + this.swallowPrediction = true; + + return this.revertAcceptancePromise; + } + + private showRevert() { + // Construct a 'revert suggestion' to facilitate a reversion UI component. + this.doRevert = true; + this.sendUpdateEvent(); + } + + /** + * Receives messages from the keyboard that the 'accept' keystroke has been entered. + * Should return 'false' if the current state allows accepting a suggestion and act accordingly. + * Otherwise, return true. + */ + private doTryAccept = (source: string /*, returnObj: {shouldSwallow: boolean}*/): void => { + //let keyman = com.keyman.singleton; + + if(!this.recentAccept && this.selected) { + this.accept(this.selected); + // returnObj.shouldSwallow = true; + } else if(this.recentAccept && source == 'space') { + this.recentAccept = false; + // // If the model doesn't insert wordbreaks, don't swallow the space. If it does, + // // we consider that insertion to be the results of the first post-accept space. + // returnObj.shouldSwallow = !!keyman.core.languageProcessor.wordbreaksAfterSuggestions; // can be handed outside + } else { + // returnObj.shouldSwallow = false; + } + } + + /** + * Receives messages from the keyboard that the 'revert' keystroke has been entered. + * Should return 'false' if the current state allows reverting a recently-applied suggestion and act accordingly. + * Otherwise, return true. + */ + private doTryRevert = (/*returnObj: {shouldSwallow: boolean}*/): boolean => { + // Has the revert keystroke (BKSP) already been sent once since the last accept? + if(this.doRevert) { + // If so, clear the 'revert' option and start doing normal predictions again. + this.doRevert = false; + this.recentAccept = false; + // Otherwise, did we just accept something before the revert signal was received? + } else if(this.recentAccept) { + this.showRevert(); + this.swallowPrediction = true; + } + + // // We don't yet actually do key-based reversions. + // returnObj.shouldSwallow = false; + return; + } + + /** + * Function invalidateSuggestions + * Scope Public + * Description Clears the suggestions in the suggestion banner + */ + private invalidateSuggestions = (source: InvalidateSourceEnum): void => { + // By default, we assume that the context is the same until we notice otherwise. + this.initNewContext = false; + + if(!this.swallowPrediction || source == 'context') { + this.recentAccept = false; + this.doRevert = false; + this.recentRevert = false; + + if(source == 'context') { + this.swallowPrediction = false; + this.initNewContext = true; + } + } + + // Not checking this can result in a perceptible 'flash' of sorts due to the suggestion-update delay. + if(source != 'new') { + this.clearSuggestions(); + // this.options.forEach((option: BannerSuggestion) => { + // option.update(null); + // }); + } + } + + private clearSuggestions() { + this.updateSuggestions({ + suggestions: [], + transcriptionID: 0 + }); + } + + private activateKeep(): boolean { + return !this.recentAccept && !this.recentRevert && !this.initNewContext; + } + + /** + * Function updateSuggestions + * Scope Public + * @param {Suggestion[]} suggestions Array of suggestions from the lexical model. + * Description Update the displayed suggestions in the SuggestionBanner + */ + private updateSuggestions = (prediction: ReadySuggestions): void => { + let suggestions = prediction.suggestions; + + this._currentSuggestions = suggestions; + + // Do we have a keep suggestion? If so, remove it from the list so that we can control its display position + // and prevent it from being hidden after reversion operations. + this.keepSuggestion = null; + for(let s of suggestions) { + if(s.tag == 'keep') { + this.keepSuggestion = s as Keep; + } + } + + if(this.keepSuggestion) { + this._currentSuggestions.splice(this._currentSuggestions.indexOf(this.keepSuggestion), 1); + } + + // If we've gotten an update request like this, it's almost always user-triggered and means the context has shifted. + if(!this.swallowPrediction) { + this.recentAccept = false; + this.doRevert = false; + this.recentRevert = false; + } else { // This prediction was triggered by a recent 'accept.' Now that it's fulfilled, we clear the flag. + this.swallowPrediction = false; + } + + // The rest is the same, whether from input or from "self-updating" after a reversion to provide new suggestions. + this.sendUpdateEvent(); + } + + public sendUpdateEvent() { + this.emit('update', this.currentSuggestions); + } + + public resetContext(): Promise { + const target = this.currentTarget; + + if(target) { + // Note: should be triggered after the corresponding new-context event rule has been processed, + // as that may affect the value of layerId here. + return this.langProcessor.invalidateContext(target, this.kbdProcessor.layerId); + } else { + return Promise.resolve([]); + } + } +} \ No newline at end of file diff --git a/common/web/input-processor/src/tsconfig.bundled.json b/common/web/input-processor/src/tsconfig.bundled.json deleted file mode 100644 index d16fe709fb..0000000000 --- a/common/web/input-processor/src/tsconfig.bundled.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - // This variant of the tsconfig.json exists to create a 'leaf', 'bundled' - // version of the keyboard-processor build product. The same reference - // cannot be prepended twice in a composite tsc build, posing problems - // for certain down-line builds if the two tsconfigs are not differentiated. - "extends": "./tsconfig.json", - "compilerOptions": { - "outFile": "../build/index.bundled.js", - }, - "references": [ - // Prepended by lm-worker. - { "path": "../../keyman-version", "prepend": true }, - { "path": "../../utils", "prepend": true }, - { "path": "../../keyboard-processor/src", "prepend": true }, - // For headless tests, we need the headless version of the compile. - { "path": "../../../predictive-text/tsconfig.json", "prepend": true } - ], - "files": [ - "text/inputProcessor.ts", - "../../../../node_modules/eventemitter3/index.js", - ] -} diff --git a/common/web/input-processor/src/tsconfig.json b/common/web/input-processor/src/tsconfig.json deleted file mode 100644 index 230233a2d1..0000000000 --- a/common/web/input-processor/src/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "extends": "../../../../tsconfig-base.json", - "compilerOptions": { - "allowJs": true, - "module": "none", - "outDir": "../build/", - "declaration": true, - "inlineSources": true, - "sourceMap": true, - "sourceRoot": "keyman/", - "target": "es5", - "types": ["node"], - "lib": ["es6"], - "outFile": "../build/index.js" - }, - "references": [ - { "path": "../../keyman-version" }, - { "path": "../../utils" }, - { "path": "../../keyboard-processor/src" }, - { "path": "../../../predictive-text/browser.tsconfig.json" }, - ], - "include": ["./**/*.ts"], - "files": [ - "text/inputProcessor.ts", - "../../../../node_modules/eventemitter3/index.js" - ] -} diff --git a/common/web/input-processor/tests/cases/inputProcessor.js b/common/web/input-processor/tests/cases/inputProcessor.js index d69ccd5af9..8c42cee98f 100644 --- a/common/web/input-processor/tests/cases/inputProcessor.js +++ b/common/web/input-processor/tests/cases/inputProcessor.js @@ -1,11 +1,15 @@ -var assert = require('chai').assert; -var fs = require("fs"); -var vm = require("vm"); +import { assert } from 'chai'; +import fs from 'fs'; -let InputProcessor = require('../../build/index.bundled.js'); +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import InputProcessor from '#./text/inputProcessor.js'; +import { KeyboardInterface, MinimalKeymanGlobal, Mock } from '@keymanapp/keyboard-processor'; +import { NodeKeyboardLoader } from '@keymanapp/keyboard-processor/node-keyboard-loader'; +import * as utils from '@keymanapp/web-utils'; // Required initialization setup. -global.com = InputProcessor.com; // exports all keyboard-processor namespacing. global.keyman = {}; // So that keyboard-based checks against the global `keyman` succeed. // 10.0+ dependent keyboards, like khmer_angkor, will otherwise fail to load. @@ -52,10 +56,10 @@ describe('InputProcessor', function() { describe('efficiency tests', function() { let testDistribution = []; - var keyboard; + let keyboardWithHarness; // Easy peasy long context: use the input processor's full source! - let coreSourceCode = fs.readFileSync('build/index.bundled.js', 'utf-8'); + let coreSourceCode = fs.readFileSync('build/lib/index.mjs', 'utf-8'); // At the time this test block was written... 810485 chars. // Let's force it to the same order of magnitude, even if the codebase grows. @@ -63,7 +67,7 @@ describe('InputProcessor', function() { coreSourceCode = coreSourceCode.substring(0, 1000000); } - this.beforeAll(function() { + this.beforeAll(async function() { testDistribution = []; for(let c = 'A'.charCodeAt(0); c <= 'Z'.charCodeAt(0); c++) { @@ -75,27 +79,24 @@ describe('InputProcessor', function() { }); } - // Load the keyboard. We'll need an InputProcessor instance as an intermediary. - let core = new InputProcessor(device); - - // These two lines will load a keyboard from its file; headless-mode `registerKeyboard` will - // automatically set the keyboard as active. - let kbdScript = new vm.Script(fs.readFileSync('../../test/resources/keyboards/test_chirality.js')); - kbdScript.runInThisContext(); - - keyboard = core.activeKeyboard; + // Load the keyboard. + let keyboardLoader = new NodeKeyboardLoader(new KeyboardInterface({}, MinimalKeymanGlobal)); + const keyboard = await keyboardLoader.loadKeyboardFromPath(require.resolve('@keymanapp/common-test-resources/keyboards/test_chirality.js')); + keyboardWithHarness = keyboardLoader.harness; + keyboardWithHarness.activeKeyboard = keyboard; }); describe('without fat-fingering', function() { it('with minimal context (no fat-fingers)', function() { this.timeout(32); // ms let core = new InputProcessor(device); - let context = new com.keyman.text.Mock("", 0); + let context = new Mock("", 0); - core.activeKeyboard = keyboard; - let layout = keyboard.layout(com.keyman.utils.FormFactor.Phone); + core.keyboardProcessor.keyboardInterface = keyboardWithHarness; + let keyboard = keyboardWithHarness.activeKeyboard; + let layout = keyboard.layout(utils.DeviceSpec.FormFactor.Phone); let key = layout.getLayer('default').getKey('K_A'); - let event = key.constructKeyEvent(core.keyboardProcessor, device); + let event = keyboard.constructKeyEvent(key, device, core.keyboardProcessor.stateKeys); let behavior = core.processKeyEvent(event, context); assert.isNotNull(behavior); @@ -103,7 +104,7 @@ describe('InputProcessor', function() { it('with extremely long context (' + coreSourceCode._kmwLength() + ' chars, no fat-fingers)', function() { // Assumes no SMP chars in the source, which is fine. - let context = new com.keyman.text.Mock(coreSourceCode, coreSourceCode._kmwLength()); + let context = new Mock(coreSourceCode, coreSourceCode._kmwLength()); this.timeout(500); // 500 ms, excluding text import. // These often run on VMs, so we'll be a bit generous. @@ -111,10 +112,11 @@ describe('InputProcessor', function() { let core = new InputProcessor(device); // I mean, it IS long context, and time // thresholding is disabled within Node. - core.activeKeyboard = keyboard; - let layout = keyboard.layout(com.keyman.utils.FormFactor.Phone); + core.keyboardProcessor.keyboardInterface = keyboardWithHarness; + let keyboard = keyboardWithHarness.activeKeyboard; + let layout = keyboard.layout(utils.DeviceSpec.FormFactor.Phone); let key = layout.getLayer('default').getKey('K_A'); - let event = key.constructKeyEvent(core.keyboardProcessor, device); + let event = keyboard.constructKeyEvent(key, device, core.keyboardProcessor.stateKeys); let behavior = core.processKeyEvent(event, context); assert.isNotNull(behavior); @@ -125,13 +127,14 @@ describe('InputProcessor', function() { it('with minimal context (with fat-fingers)', function() { this.timeout(32); // ms let core = new InputProcessor(device); - let context = new com.keyman.text.Mock("", 0); + let context = new Mock("", 0); - core.activeKeyboard = keyboard; - let layout = keyboard.layout(com.keyman.utils.FormFactor.Phone); + core.keyboardProcessor.keyboardInterface = keyboardWithHarness; + let keyboard = keyboardWithHarness.activeKeyboard; + let layout = keyboard.layout(utils.DeviceSpec.FormFactor.Phone); let key = layout.getLayer('default').getKey('K_A'); key.keyDistribution = testDistribution; - let event = key.constructKeyEvent(core.keyboardProcessor, device); + let event = keyboard.constructKeyEvent(key, device, core.keyboardProcessor.stateKeys); let behavior = core.processKeyEvent(event, context); assert.isNotNull(behavior); @@ -139,7 +142,7 @@ describe('InputProcessor', function() { it('with extremely long context (' + coreSourceCode._kmwLength() + ' chars, with fat-fingers)', function() { // Assumes no SMP chars in the source, which is fine. - let context = new com.keyman.text.Mock(coreSourceCode, coreSourceCode._kmwLength()); + let context = new Mock(coreSourceCode, coreSourceCode._kmwLength()); this.timeout(500); // 500 ms, excluding text import. // These often run on VMs, so we'll be a bit generous. @@ -150,11 +153,12 @@ describe('InputProcessor', function() { let core = new InputProcessor(device); // It IS long context, and time // thresholding is disabled within Node. - core.activeKeyboard = keyboard; - let layout = keyboard.layout(com.keyman.utils.FormFactor.Phone); + core.keyboardProcessor.keyboardInterface = keyboardWithHarness; + let keyboard = keyboardWithHarness.activeKeyboard; + let layout = keyboard.layout(utils.DeviceSpec.FormFactor.Phone); let key = layout.getLayer('default').getKey('K_A'); key.keyDistribution = testDistribution; - let event = key.constructKeyEvent(core.keyboardProcessor, device); + let event = keyboard.constructKeyEvent(key, device, core.keyboardProcessor.stateKeys); let behavior = core.processKeyEvent(event, context); assert.isNotNull(behavior); diff --git a/common/web/input-processor/tests/cases/languageProcessor.js b/common/web/input-processor/tests/cases/languageProcessor.js index c3e02a9762..06c017b426 100644 --- a/common/web/input-processor/tests/cases/languageProcessor.js +++ b/common/web/input-processor/tests/cases/languageProcessor.js @@ -1,48 +1,44 @@ -var assert = require('chai').assert; -var fs = require("fs"); -var vm = require("vm"); +import { assert } from 'chai'; + +import LanguageProcessor from '#./text/prediction/languageProcessor.js'; +import { SourcemappedWorker as LMWorker } from "@keymanapp/lexical-model-layer/node"; +import { Mock } from '@keymanapp/keyboard-processor'; /* * Unit tests for the Dummy prediction model. */ -// TODO: this relies on esbuild output for lexical-model-compiler; later should use import -var LexicalModelCompiler = require('../../../../../developer/src/kmc-model/build/cjs-src/lexical-model-compiler.cjs').default; -var path = require('path'); - -let InputProcessor = require('../../build/index.bundled.js'); +import { LexicalModelCompiler } from '@keymanapp/kmc-model'; +import { fileURLToPath } from 'url'; +import path from 'path'; // Required initialization setup. -global.com = InputProcessor.com; // exports all keyboard-processor namespacing. global.keyman = {}; // So that keyboard-based checks against the global `keyman` succeed. // 10.0+ dependent keyboards, like khmer_angkor, will otherwise fail to load. // Initialize supplementary plane string extensions String.kmwEnableSupplementaryPlane(false); -let LanguageProcessor = com.keyman.text.prediction.LanguageProcessor; - // Test the KeyboardProcessor interface. describe('LanguageProcessor', function() { describe('[[constructor]]', function () { it('should initialize without errors', function () { - let lp = new LanguageProcessor(); + let lp = new LanguageProcessor(LMWorker.constructInstance()); assert.isNotNull(lp); }); it('has expected default values after initialization', function () { - let languageProcessor = new LanguageProcessor(); + let languageProcessor = new LanguageProcessor(LMWorker.constructInstance()); // These checks are lifted from the keyboard-processor init checks found in // common/web/keyboard-processor/tests/cases/basic-init.js. - assert.isUndefined(languageProcessor.lmEngine); + assert.isDefined(languageProcessor.lmEngine); assert.isUndefined(languageProcessor.activeModel); assert.isFalse(languageProcessor.isActive); assert.isTrue(languageProcessor.mayPredict); // Some aspects of initialization must wait until after construction and overall // load of the core. See /web/source/kmwbase.ts, in the final IIFE. - languageProcessor.init(); assert.isOk(languageProcessor.lmEngine); }); }); @@ -50,6 +46,11 @@ describe('LanguageProcessor', function() { describe('.predict', function() { let compiler = new LexicalModelCompiler(); const MODEL_ID = 'example.qaa.trivial'; + + // ES-module mode leaves out `__dirname`, so we rebuild it using other components. + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const PATH = path.join(__dirname, '../../../../../developer/src/kmc-model/test/fixtures', MODEL_ID); describe('using angle brackets for quotes', function() { @@ -69,8 +70,7 @@ describe('LanguageProcessor', function() { }; it("successfully loads the model", function(done) { - let languageProcessor = new LanguageProcessor(); - languageProcessor.init(); + let languageProcessor = new LanguageProcessor(LMWorker.constructInstance()); languageProcessor.loadModel(modelSpec).then(function() { assert.isOk(languageProcessor.activeModel); // is only set after a successful load. @@ -81,10 +81,9 @@ describe('LanguageProcessor', function() { }); it("generates the expected prediction set", function(done) { - let languageProcessor = new LanguageProcessor(); - languageProcessor.init(); + let languageProcessor = new LanguageProcessor(LMWorker.constructInstance()); - let contextSource = new com.keyman.text.Mock("li", 2); + let contextSource = new Mock("li", 2); let transcription = contextSource.buildTranscriptionFrom(contextSource, null, null); languageProcessor.loadModel(modelSpec).then(function() { @@ -118,10 +117,9 @@ describe('LanguageProcessor', function() { describe("does not alter casing when input is lowercased", function() { it("when input is fully lowercased", function(done) { - let languageProcessor = new LanguageProcessor(); - languageProcessor.init(); + let languageProcessor = new LanguageProcessor(LMWorker.constructInstance()); - let contextSource = new com.keyman.text.Mock("li", 2); + let contextSource = new Mock("li", 2); let transcription = contextSource.buildTranscriptionFrom(contextSource, null, null); languageProcessor.loadModel(modelSpec).then(function() { @@ -138,10 +136,9 @@ describe('LanguageProcessor', function() { }); it("when input has non-initial uppercased letters", function(done) { - let languageProcessor = new LanguageProcessor(); - languageProcessor.init(); + let languageProcessor = new LanguageProcessor(LMWorker.constructInstance()); - let contextSource = new com.keyman.text.Mock("lI", 2); + let contextSource = new Mock("lI", 2); let transcription = contextSource.buildTranscriptionFrom(contextSource, null, null); languageProcessor.loadModel(modelSpec).then(function() { @@ -159,10 +156,9 @@ describe('LanguageProcessor', function() { }); it("unless the suggestion has uppercased letters", function(done) { - let languageProcessor = new LanguageProcessor(); - languageProcessor.init(); + let languageProcessor = new LanguageProcessor(LMWorker.constructInstance()); - let contextSource = new com.keyman.text.Mock("i", 1); + let contextSource = new Mock("i", 1); let transcription = contextSource.buildTranscriptionFrom(contextSource, null, null); languageProcessor.loadModel(modelSpec).then(function() { @@ -181,10 +177,9 @@ describe('LanguageProcessor', function() { describe("uppercases suggestions when input is fully capitalized ", function() { it("for suggestions with default casing (== 'lower')", function(done) { - let languageProcessor = new LanguageProcessor(); - languageProcessor.init(); + let languageProcessor = new LanguageProcessor(LMWorker.constructInstance()); - let contextSource = new com.keyman.text.Mock("LI", 2); + let contextSource = new Mock("LI", 2); let transcription = contextSource.buildTranscriptionFrom(contextSource, null, null); languageProcessor.loadModel(modelSpec).then(function() { @@ -202,10 +197,9 @@ describe('LanguageProcessor', function() { }); it("for precapitalized suggestions", function(done) { - let languageProcessor = new LanguageProcessor(); - languageProcessor.init(); + let languageProcessor = new LanguageProcessor(LMWorker.constructInstance()); - let contextSource = new com.keyman.text.Mock("I", 1); + let contextSource = new Mock("I", 1); let transcription = contextSource.buildTranscriptionFrom(contextSource, null, null); languageProcessor.loadModel(modelSpec).then(function() { @@ -225,10 +219,9 @@ describe('LanguageProcessor', function() { describe("initial-cases suggestions when input uses initial casing ", function() { describe("when input is a single capitalized letter", function() { it("for suggestions with default casing (== 'lower')", function(done) { - let languageProcessor = new LanguageProcessor(); - languageProcessor.init(); + let languageProcessor = new LanguageProcessor(LMWorker.constructInstance()); - let contextSource = new com.keyman.text.Mock("L", 1); + let contextSource = new Mock("L", 1); let transcription = contextSource.buildTranscriptionFrom(contextSource, null, null); languageProcessor.loadModel(modelSpec).then(function() { @@ -248,10 +241,9 @@ describe('LanguageProcessor', function() { describe("input length > 1", function() { it("for suggestions with default casing (== 'lower')", function(done) { - let languageProcessor = new LanguageProcessor(); - languageProcessor.init(); + let languageProcessor = new LanguageProcessor(LMWorker.constructInstance()); - let contextSource = new com.keyman.text.Mock("Li", 2); + let contextSource = new Mock("Li", 2); let transcription = contextSource.buildTranscriptionFrom(contextSource, null, null); languageProcessor.loadModel(modelSpec).then(function() { diff --git a/common/web/input-processor/tests/cases/predictionContext.js b/common/web/input-processor/tests/cases/predictionContext.js new file mode 100644 index 0000000000..3db0e89b58 --- /dev/null +++ b/common/web/input-processor/tests/cases/predictionContext.js @@ -0,0 +1,275 @@ +import { assert } from 'chai'; +import sinon from 'sinon'; + +import { LanguageProcessor, PredictionContext } from '@keymanapp/input-processor'; +import { Worker as LMWorker } from "@keymanapp/lexical-model-layer/node"; +import { DeviceSpec, KeyboardProcessor, Mock } from '@keymanapp/keyboard-processor'; + +function compileDummyModel(suggestionSets) { + return ` +LMLayerWorker.loadModel(new models.DummyModel({ + futureSuggestions: ${JSON.stringify(suggestionSets, null, 2)}, +})); +`; +} + +// Common spec used for each test's setup. It's actually irrelevant for the tests, +// but KeyboardProcessor needs an instance. +const deviceSpec = new DeviceSpec( + DeviceSpec.Browser.Chrome, + DeviceSpec.FormFactor.Desktop, + DeviceSpec.OperatingSystem.Windows +); + +const appleDummySuggestionSets = [[ + // Set 1: + { + transform: { insert: 'e', deleteLeft: 0}, + displayAs: 'apple', + }, { + transform: { insert: 'y', deleteLeft: 0}, + displayAs: 'apply' + }, { + transform: { insert: 'es', deleteLeft: 0}, + displayAs: 'apples' + } +], [ + // Set 2: + { + transform: { insert: '', deleteLeft: 0}, + displayAs: 'apple', + tag: 'keep' + }, { + transform: { insert: 'y', deleteLeft: 0}, + displayAs: 'apply' + }, { + transform: { insert: 's', deleteLeft: 1}, + displayAs: 'apps' + } +], [ + // Set 3: + { + transform: { insert: 'ied', deleteLeft: 2}, + displayAs: 'applied' + } +], [ + { + transform: { insert: ' reverted', deleteLeft: 5}, + displayAs: 'reverted' + } +]]; + +const appleDummyModel = { + id: 'dummy', + languages: ['en'], + code: compileDummyModel(appleDummySuggestionSets) +}; + +describe("PredictionContext", () => { + it('receives predictions as they are generated', async () => { + const langProcessor = new LanguageProcessor(LMWorker.constructInstance()); + await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker. + + const kbdProcessor = new KeyboardProcessor(deviceSpec); + const predictiveContext = new PredictionContext(langProcessor, kbdProcessor); + + let updateFake = sinon.fake(); + predictiveContext.on('update', updateFake); + + let mock = new Mock("appl", 4); // "appl|", with '|' as the caret position. + const initialMock = Mock.from(mock); + const promise = predictiveContext.setCurrentTarget(mock); + + // Initial predictive state: no suggestions. context.initializeState() has not yet been called. + assert.equal(updateFake.callCount, 1); + assert.isEmpty(updateFake.firstCall.args[0]); // should have no suggestions. (if convenient for testing) + + await promise; + let suggestions; + + // Initialization results: our first set of dummy suggestions. + assert.equal(updateFake.callCount, 2); + suggestions = updateFake.secondCall.args[0]; + assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['apple', 'apply', 'apples']); + assert.isNotOk(suggestions.find((obj) => obj.tag == 'keep')); + assert.isNotOk(suggestions.find((obj) => obj.transform.deleteLeft != 0)); + + mock.insertTextBeforeCaret('e'); // appl| + e = apple + let transcription = mock.buildTranscriptionFrom(initialMock, null, true); + await langProcessor.predict(transcription, kbdProcessor.layerId); + + // First predict call results: our second set of dummy suggestions, the first of which includes + // a 'keep' of the original text. + assert.equal(updateFake.callCount, 3); + suggestions = updateFake.thirdCall.args[0]; + assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['apple', 'apply', 'apps']); + assert.equal(suggestions.find((obj) => obj.tag == 'keep').displayAs, 'apple'); + assert.equal(suggestions.find((obj) => obj.transform.deleteLeft != 0).displayAs, 'apps'); + }); + + it('sendUpdateState retrieves the most recent suggestion set', async () => { + const langProcessor = new LanguageProcessor(LMWorker.constructInstance()); + await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker. + + const kbdProcessor = new KeyboardProcessor(deviceSpec); + const predictiveContext = new PredictionContext(langProcessor, kbdProcessor); + + let mock = new Mock("appl", 4); // "appl|", with '|' as the caret position. + const initialSuggestions = await predictiveContext.setCurrentTarget(mock); + + let updateFake = sinon.fake(); + predictiveContext.on('update', updateFake); + predictiveContext.sendUpdateEvent(); // Allows external code to request a re-retrieval of current suggestion state. + + // Now, let's initialize the predictive state - this will load the initial suggestions. + let suggestions; + + // Initialization results: our first set of dummy suggestions. + assert.isTrue(updateFake.calledOnce); + suggestions = updateFake.firstCall.args[0]; + assert.deepEqual(suggestions, initialSuggestions); + + // The array instances may be different, but their contents should be the same instances and in the same order. + assert.sameOrderedMembers(suggestions, initialSuggestions); + }); + + it('suggestion application logic & triggered effects', async () => { + const langProcessor = new LanguageProcessor(LMWorker.constructInstance()); + await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker. + + const kbdProcessor = new KeyboardProcessor(deviceSpec); + const predictiveContext = new PredictionContext(langProcessor, kbdProcessor); + + let textState = new Mock("appl", 4); // "appl|", with '|' as the caret position. + + await predictiveContext.setCurrentTarget(textState); + + let updateFake = sinon.fake(); + predictiveContext.on('update', updateFake); + + let suggestions; + + let previousTextState = Mock.from(textState); + textState.insertTextBeforeCaret('e'); // appl| + e = apple + let transcription = textState.buildTranscriptionFrom(previousTextState, null, true); + await langProcessor.predict(transcription, kbdProcessor.layerId); + + // Verify setup. + assert.equal(updateFake.callCount, 1); + suggestions = updateFake.firstCall.args[0]; + assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['apple', 'apply', 'apps']); + assert.equal(suggestions.find((obj) => obj.tag == 'keep').displayAs, 'apple'); + assert.equal(suggestions.find((obj) => obj.transform.deleteLeft != 0).displayAs, 'apps'); + + // Now for the real test. + previousTextState = Mock.from(textState); // snapshot it! + + const suggestionApply = suggestions.find((obj) => obj.displayAs == 'apply'); + assert.isOk(suggestionApply); + + let promiseForApplyReversion = predictiveContext.accept(suggestionApply); + + assert.equal(updateFake.callCount, 1); // No new 'update' has been raised yet. + + // Check 1: did our active text context get changed? We DID just ask to apply + // a suggestion... + assert.notEqual(textState.getText(), previousTextState.getText()); + assert.equal(textState.getText(), 'apply '); + + let reversion = await promiseForApplyReversion; + // We don't seem to need to additionally rig a wait for the triggered predict call; it + // always completes first. Neat. If test becomes unstable... yeah, rig up a wait for it. + + // Check 2: a second 'update' - post-application predictions! + assert.equal(updateFake.callCount, 2); + suggestions = updateFake.secondCall.args[0]; + assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['applied']); + assert.isNotOk(suggestions.find((obj) => obj.tag == 'keep')); + assert.equal(suggestions[0].transform.deleteLeft, 2); + + // Check 4+: for the returned reversion object. + assert.isOk(reversion); + + // All other reversion details are tested in the 'reversion application logic...' section defined below. + }); + + it('reversion application logic & triggered effects', async () => { + const langProcessor = new LanguageProcessor(LMWorker.constructInstance()); + await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker. + + const kbdProcessor = new KeyboardProcessor(deviceSpec); + const predictiveContext = new PredictionContext(langProcessor, kbdProcessor); + + let textState = new Mock("appl", 4); // "appl|", with '|' as the caret position. + + // Test setup - return to the state at the end of the prior-defined unit test ('suggestion application...') + + await predictiveContext.setCurrentTarget(textState); + + // This is the point in time that a reversion operation will rewind the context to. + const revertBaseTextState = Mock.from(textState); + textState.insertTextBeforeCaret('e'); // appl| + e = apple + let transcription = textState.buildTranscriptionFrom(revertBaseTextState, null, true); + + let suggestionCaptureFake = sinon.fake(); + predictiveContext.once('update', suggestionCaptureFake); + await langProcessor.predict(transcription, kbdProcessor.layerId); + + // We need to capture the suggestion we wish to apply. We could hardcode a forced + // value, but that might become brittle in the long-term. + const originalSuggestionSet = suggestionCaptureFake.firstCall.args[0]; + const suggestionApply = originalSuggestionSet.find((obj) => obj.displayAs == 'apply'); + assert.isOk(suggestionApply); + + let previousTextState = Mock.from(textState); + let reversion = await predictiveContext.accept(suggestionApply); + + // Test setup complete. + + // Check the assertion object (from end of 'suggestion application' checks) - verify setup. + assert.isOk(reversion); + + // Reversion IDs are inverses of the applied suggestion. + // This is the important link used to rewind the context when reverting suggestions. + assert.equal(reversion.id, -suggestionApply.id); + assert.equal(reversion.transformId, -suggestionApply.transformId); + + // Revert display strings include quotes. + // + // We could check this more rigorously by importing from @keymanapp/models-wordbreakers for + // the default quotes. + assert.isTrue(reversion.displayAs.includes(previousTextState.getText())); + assert.equal(reversion.displayAs.length, previousTextState.getText().length + 2); // +2: opening + closing quotes. + + // Fire away! Time to apply the reversion. + previousTextState = Mock.from(textState); + let returnValue = predictiveContext.accept(reversion); + + // 'accepting' a reversion performs a rewind; there's no need for async ops here. + assert.isNull(returnValue); // as per the method's spec. + + // Verify that the rewind + application of reversion worked! + let rewoundTextStateWithInput = Mock.from(revertBaseTextState); // appl + rewoundTextStateWithInput.apply(reversion.transform); // + e + assert.equal(rewoundTextStateWithInput.getText(), 'apple'); // For visual clarity. + + // Note: no space appended. + assert.equal(textState.getText(), rewoundTextStateWithInput.getText()); + + // Note: accepting a reversion will trigger a new prediction that completes + // asynchronously, and unfortunately... we have no handle for it. + + let updateFake = sinon.fake(); + predictiveContext.on('update', updateFake); + + // Now, in order to synchronize... we rely on a Promise. The callback is indeed + // called synchronously. + await new Promise((resolve) => { + predictiveContext.once('update', resolve); + }); + + assert.equal(updateFake.callCount, 1); + const suggestionsPostReversion = updateFake.firstCall.args[0]; + assert.deepEqual(suggestionsPostReversion.map((obj) => obj.displayAs), ['reverted']); + }); +}); \ No newline at end of file diff --git a/common/web/input-processor/tsconfig.json b/common/web/input-processor/tsconfig.json new file mode 100644 index 0000000000..1a03881a16 --- /dev/null +++ b/common/web/input-processor/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig-base.json", + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "module": "es6", + "moduleResolution": "Node16", + "declaration": true, + "inlineSources": true, + "sourceMap": true, + "sourceRoot": "keyman/", + "target": "es5", + "types": ["node"], + "lib": ["es6"], + "baseUrl": "./", + "outDir": "build/obj/", + "tsBuildInfoFile": "build/obj/tsconfig.tsbuildinfo", + "rootDir": "./src" + }, + "references": [ + { "path": "../utils" }, + { "path": "../keyboard-processor" }, + { "path": "../../predictive-text/tsconfig.all.json" }, + ], + // The extra .d.ts is necessary to avoid issues with lack of a Worker type definition when not including + // the standard DOM in the TS "lib" configuration. + "include": ["./src/**/*.ts", "../../predictive-text/src/worker-interface.d.ts"] +} diff --git a/common/web/keyboard-processor/build-bundler.js b/common/web/keyboard-processor/build-bundler.js new file mode 100644 index 0000000000..5c3ecb05a9 --- /dev/null +++ b/common/web/keyboard-processor/build-bundler.js @@ -0,0 +1,58 @@ +/* + * Note: while this file is not meant to exist long-term, it provides a nice + * low-level proof-of-concept for esbuild bundling of the various Web submodules. + * + * Add some extra code at the end of src/index.ts and run it to verify successful bundling! + */ + +import esbuild from 'esbuild'; +import { spawn } from 'child_process'; + +/** @type {esbuild.BuildOptions} */ +const commonConfig = { + bundle: true, + sourcemap: true, + format: "esm", + // Sets 'common/web' as a root folder for module resolution; + // this allows the keyman-version and utils imports to resolve. + // + // We also need to point it at the nested build output folder to resolve in-project + // imports when compiled - esbuild doesn't seem to pick up on the shifted base. + nodePaths: ['..', "build/obj"] +}; + +// Bundled ES module version +esbuild.buildSync({ + entryPoints: ['build/obj/index.js'], + outfile: "build/lib/index.mjs", + format: "esm", + ...commonConfig +}); + +// Bundled CommonJS (classic Node) module version +esbuild.buildSync({ + entryPoints: ['build/obj/index.js'], + outfile: 'build/lib/index.cjs', + bundle: true, + sourcemap: true, + platform: "node", + format: "cjs", + ...commonConfig +}); + + +esbuild.buildSync({ + entryPoints: ['build/obj/keyboards/loaders/dom-keyboard-loader.js'], + outfile: 'build/lib/dom-keyboard-loader.mjs', + format: "esm", + ...commonConfig +}); + +// The node-based keyboard loader needs an extra parameter due to Node-built-in imports: +esbuild.buildSync({ + entryPoints: ['build/obj/keyboards/loaders/node-keyboard-loader.js'], + outfile: 'build/lib/node-keyboard-loader.mjs', + format: "esm", + platform: "node", + ...commonConfig +}); \ No newline at end of file diff --git a/common/web/keyboard-processor/build.sh b/common/web/keyboard-processor/build.sh index ebcab580ab..fc7bf1f1a6 100755 --- a/common/web/keyboard-processor/build.sh +++ b/common/web/keyboard-processor/build.sh @@ -30,7 +30,7 @@ builder_describe \ builder_describe_outputs \ configure /node_modules \ - build /common/web/keyboard-processor/build/index.js + build /common/web/keyboard-processor/build/lib/index.mjs builder_parse "$@" @@ -41,26 +41,35 @@ fi if builder_start_action clean; then npm run clean + rm -rf ./build builder_finish_action success clean fi if builder_start_action build; then - tsc --build "$THIS_SCRIPT_PATH/src/tsconfig.json" + tsc --build "$THIS_SCRIPT_PATH/tsconfig.json" + node ./build-bundler.js + + # Declaration bundling. + tsc --emitDeclarationOnly --outFile ./build/lib/index.d.ts + tsc --emitDeclarationOnly --outFile ./build/lib/dom-keyboard-loader.d.ts -p src/keyboards/loaders/tsconfig.dom.json + tsc --emitDeclarationOnly --outFile ./build/lib/node-keyboard-loader.d.ts -p src/keyboards/loaders/tsconfig.node.json + builder_finish_action success build fi if builder_start_action test; then - tsc --build "$THIS_SCRIPT_PATH/src/tsconfig.bundled.json" - builder_heading "Running Keyboard Processor test suite" - FLAGS= + MOCHA_FLAGS= + KARMA_CONFIG=manual.conf.cjs if builder_has_option --ci; then echo "Replacing user-friendly test reports with CI-friendly versions." - FLAGS="$FLAGS --reporter mocha-teamcity-reporter" + MOCHA_FLAGS="$MOCHA_FLAGS --reporter mocha-teamcity-reporter" + KARMA_CONFIG=CI.conf.cjs fi - mocha --recursive $FLAGS ./tests/cases/ + mocha --recursive $MOCHA_FLAGS ./tests/node/ + karma start ./tests/dom/$KARMA_CONFIG builder_finish_action success test fi \ No newline at end of file diff --git a/common/web/keyboard-processor/package.json b/common/web/keyboard-processor/package.json index c93d6f124d..62a035ba05 100644 --- a/common/web/keyboard-processor/package.json +++ b/common/web/keyboard-processor/package.json @@ -19,6 +19,12 @@ "devDependencies": { "@keymanapp/resources-gosh": "*", "chai": "^4.3.4", + "karma": "^6.4.1", + "karma-chai": "^0.1.0", + "karma-chrome-launcher": "^2.2.0", + "karma-mocha": "^2.0.1", + "karma-mocha-reporter": "^2.2.5", + "karma-teamcity-reporter": "^1.1.0", "mocha": "^10.0.0", "mocha-teamcity-reporter": "^4.0.0", "ts-node": "^10.9.1", @@ -26,7 +32,8 @@ }, "scripts": { "tsc": "tsc", - "clean": "tsc -b --clean src/tsconfig.json && tsc -b --clean src/tsconfig.bundled.json", + "clean": "tsc -b --clean tsconfig.json", + "karma": "karma", "test": "gosh ./test.sh", "mocha": "mocha" }, @@ -35,5 +42,33 @@ "@keymanapp/keyman-version": "*", "@keymanapp/web-utils": "*", "@types/node": "^11.9.4" + }, + "type": "module", + "main": "./build/obj/index.js", + "types": "./build/obj/index.d.ts", + "exports": { + ".": "./build/obj/index.js", + "./node-keyboard-loader": { + "types": "./build/obj/keyboards/loaders/node-keyboard-loader.d.ts", + "import": "./build/obj/keyboards/loaders/node-keyboard-loader.js" + }, + "./dom-keyboard-loader": { + "types": "./build/obj/keyboards/loaders/dom-keyboard-loader.d.ts", + "import": "./build/obj/keyboards/loaders/dom-keyboard-loader.js" + }, + "./lib": { + "types": "./build/lib/index.d.ts", + "import": "./build/lib/index.mjs", + "require": "./build/lib/index.cjs" + }, + "./lib/node-keyboard-loader": { + "types": "./build/lib/node-keyboard-loader.d.ts", + "import": "./build/lib/node-keyboard-loader.mjs" + }, + "./lib/dom-keyboard-loader": { + "types": "./build/lib/dom-keyboard-loader.d.ts", + "import": "./build/lib/dom-keyboard-loader.mjs" + }, + "./obj/*.js": "./build/obj/*.js" } } diff --git a/common/web/keyboard-processor/src/index.ts b/common/web/keyboard-processor/src/index.ts new file mode 100644 index 0000000000..8c60ae3f39 --- /dev/null +++ b/common/web/keyboard-processor/src/index.ts @@ -0,0 +1,45 @@ +export * from "./keyboards/activeLayout.js"; +export * from "./keyboards/defaultLayouts.js"; +export { default as Keyboard } from "./keyboards/keyboard.js"; +export * from "./keyboards/keyboard.js"; +export { KeyboardHarness, KeyboardKeymanGlobal, MinimalCodesInterface, MinimalKeymanGlobal } from "./keyboards/keyboardHarness.js"; +export { default as KeyboardLoaderBase } from "./keyboards/keyboardLoaderBase.js"; +export { + CloudKeyboardFont, + internalizeFont, + InternalKeyboardFont, + KeyboardAPIPropertyMultilangSpec, + KeyboardAPIPropertySpec, + KeyboardInternalPropertySpec, + default as KeyboardProperties, + KeyboardFont, + MetadataObj as RawKeyboardMetadata, + LanguageAPIPropertySpec +} from "./keyboards/keyboardProperties.js"; +export { default as SpacebarText } from "./keyboards/spacebarText.js"; +export { default as StateKeyMap } from "./keyboards/stateKeyMap.js"; + +export { default as Codes } from "./text/codes.js"; +export * from "./text/codes.js"; +export * from "./text/deadkeys.js"; +export { default as DefaultRules } from "./text/defaultRules.js"; +export * from "./text/defaultRules.js"; +export { default as KeyboardInterface } from "./text/kbdInterface.js"; +export * from "./text/kbdInterface.js"; +export { default as KeyboardProcessor } from "./text/keyboardProcessor.js"; +export * from "./text/keyboardProcessor.js"; +export { default as KeyEvent } from "./text/keyEvent.js"; +export * from "./text/keyEvent.js"; +export { default as KeyMapping } from "./text/keyMapping.js"; +export { default as OutputTarget } from "./text/outputTarget.js"; +export * from "./text/outputTarget.js"; +export { default as RuleBehavior } from "./text/ruleBehavior.js"; +export * from "./text/systemStores.js"; + +export * from "@keymanapp/web-utils"; + +// At the top level, there should be no default export. + +// Without the line below... OutputTarget would likely be aliased there, as it's +// the last `export { default as _ }` => `export * from` pairing seen above. +export default undefined; \ No newline at end of file diff --git a/common/web/keyboard-processor/src/keyboards/activeLayout.ts b/common/web/keyboard-processor/src/keyboards/activeLayout.ts index 398682becd..065f8c5193 100644 --- a/common/web/keyboard-processor/src/keyboards/activeLayout.ts +++ b/common/web/keyboard-processor/src/keyboards/activeLayout.ts @@ -1,782 +1,767 @@ -namespace com.keyman.keyboards { - type KeyDistribution = text.KeyDistribution; - - // TS 3.9 changed behavior of getters to make them - // non-enumerable by default. This broke our 'polyfill' - // functions which depended on enumeration to copy the - // relevant props over. - // https://github.com/microsoft/TypeScript/pull/32264#issuecomment-677718191 - function Enumerable( - target: unknown, - propertyKey: string, - descriptor: PropertyDescriptor - ) { - descriptor.enumerable = true; +import Codes from "../text/codes.js"; +import KeyEvent, { KeyEventSpec } from "../text/keyEvent.js"; +import KeyMapping from "../text/keyMapping.js"; +import type { KeyDistribution } from "../text/keyEvent.js"; +import type { LayoutKey, LayoutRow, LayoutLayer, LayoutFormFactor, ButtonClass } from "./defaultLayouts.js"; +import type Keyboard from "./keyboard.js"; + +import KeyboardProcessor from "../text/keyboardProcessor.js"; + +import { deepCopy, type DeviceSpec } from "@keymanapp/web-utils"; + +// TS 3.9 changed behavior of getters to make them +// non-enumerable by default. This broke our 'polyfill' +// functions which depended on enumeration to copy the +// relevant props over. +// https://github.com/microsoft/TypeScript/pull/32264#issuecomment-677718191 +function Enumerable( + target: unknown, + propertyKey: string, + descriptor: PropertyDescriptor +) { + descriptor.enumerable = true; +}; + +export class ActiveKey implements LayoutKey { + static readonly DEFAULT_PAD=15; // Padding to left of key, in virtual units + static readonly DEFAULT_RIGHT_MARGIN=15; // Padding to right of right-most key, in virtual units + static readonly DEFAULT_KEY_WIDTH=100; // Width of a key, if not specified, in virtual units + + // Defines key defaults + static readonly DEFAULT_KEY = { + text: '', + width: ActiveKey.DEFAULT_KEY_WIDTH, + sp: 0, + pad: ActiveKey.DEFAULT_PAD }; - export class ActiveKey implements LayoutKey { - static readonly DEFAULT_PAD=15; // Padding to left of key, in virtual units - static readonly DEFAULT_RIGHT_MARGIN=15; // Padding to right of right-most key, in virtual units - static readonly DEFAULT_KEY_WIDTH=100; // Width of a key, if not specified, in virtual units - - // Defines key defaults - static readonly DEFAULT_KEY = { - text: '', - width: ActiveKey.DEFAULT_KEY_WIDTH, - sp: 0, - pad: ActiveKey.DEFAULT_PAD - }; - - /** WARNING - DO NOT USE DIRECTLY outside of @keymanapp/keyboard-processor! */ - id?: string; - - // These are fine. - width?: number; - pad?: number; - - layer: string; - displayLayer: string; - nextlayer: string; - sp?: ButtonClass; - - private baseKeyEvent: text.KeyEvent; - isMnemonic: boolean = false; - - proportionalPad: number; - proportionalX: number; - proportionalWidth: number; - - sk?: ActiveKey[]; - - // Keeping things simple here, as this was added LATE in 14.0 beta. - // Could definitely extend in the future to instead return an object - // that denotes the 'nature' of the key. - // - isUnicode - // - isHardwareKey - // - etc. - - // Reference for the terminology in the comments below: - // https://help.keyman.com/developer/current-version/guides/develop/creating-a-touch-keyboard-layout-for-amharic-the-nitty-gritty - - /** - * Matches the key code as set within Keyman Developer for the layout. - * For example, K_R or U_0020. Denotes either physical keys or virtual keys with custom output, - * with no additional metadata like layer or active modifiers. - * - * Is used to determine the keycode for input events, rule-matching, and keystroke processing. - */ - @Enumerable - public get baseKeyID(): string { - if(typeof this.id === 'undefined') { - return undefined; - } - - return this.id; - } - - @Enumerable - public get isPadding(): boolean { - // Does not include 9 (class: blank) as that may be an intentional 'catch' for misplaced - // keystrokes. - return this['sp'] == 10; // Button class: hidden. - } - - /** - * A unique identifier based on both the key ID & the 'desktop layer' to be used for the key. - * - * Allows diambiguation of scenarios where the same key ID is used twice within a layer, but - * with different innate modifiers. (Refer to https://github.com/keymanapp/keyman/issues/4617) - * The 'desktop layer' may be omitted if it matches the key's display layer. - * - * Examples, given a 'default' display layer, matching keys to Keyman keyboard language: - * - * ``` - * "K_Q" - * + [K_Q] - * "K_Q+shift" - * + [K_Q SHIFT] - * ``` - * - * Useful when the active layer of an input-event is already known. - */ - @Enumerable - public get coreID(): string { - if(typeof this.id === 'undefined') { - return undefined; - } - - let baseID = this.id || ''; + /** WARNING - DO NOT USE DIRECTLY outside of @keymanapp/keyboard-processor! */ + id?: string; + + // These are fine. + width?: number; + pad?: number; + + layer: string; + displayLayer: string; + nextlayer: string; + sp?: ButtonClass; + + _baseKeyEvent: KeyEvent; + isMnemonic: boolean = false; + + proportionalPad: number; + proportionalX: number; + proportionalWidth: number; + + sk?: ActiveKey[]; + + // Keeping things simple here, as this was added LATE in 14.0 beta. + // Could definitely extend in the future to instead return an object + // that denotes the 'nature' of the key. + // - isUnicode + // - isHardwareKey + // - etc. + + // Reference for the terminology in the comments below: + // https://help.keyman.com/developer/current-version/guides/develop/creating-a-touch-keyboard-layout-for-amharic-the-nitty-gritty + + /** + * Matches the key code as set within Keyman Developer for the layout. + * For example, K_R or U_0020. Denotes either physical keys or virtual keys with custom output, + * with no additional metadata like layer or active modifiers. + * + * Is used to determine the keycode for input events, rule-matching, and keystroke processing. + */ + @Enumerable + public get baseKeyID(): string { + if(typeof this.id === 'undefined') { + return undefined; + } - if(this.displayLayer != this.layer) { - baseID = baseID + '+' + this.layer; - } + return this.id; + } - return baseID; - } - - /** - * A keyboard-unique identifier to be used for any display elements representing this key - * in user interfaces and/or on-screen keyboards. - * - * Distinguishes between otherwise-identical keys on different layers of an OSK. - * Includes identifying information about the key's display layer. - * - * Examples, given a 'default' display layer, matching keys to Keyman keyboard language: - * - * ``` - * "default-K_Q" - * + [K_Q] - * "default-K_Q+shift" - * + [K_Q SHIFT] - * ``` - * - * Useful when only the active keyboard is known about an input event. - */ - @Enumerable - public get elementID(): string { - if(typeof this.id === 'undefined') { - return undefined; - } + @Enumerable + public get isPadding(): boolean { + // Does not include 9 (class: blank) as that may be an intentional 'catch' for misplaced + // keystrokes. + return this['sp'] == 10; // Button class: hidden. + } - return this.displayLayer + '-' + this.coreID; + /** + * A unique identifier based on both the key ID & the 'desktop layer' to be used for the key. + * + * Allows diambiguation of scenarios where the same key ID is used twice within a layer, but + * with different innate modifiers. (Refer to https://github.com/keymanapp/keyman/issues/4617) + * The 'desktop layer' may be omitted if it matches the key's display layer. + * + * Examples, given a 'default' display layer, matching keys to Keyman keyboard language: + * + * ``` + * "K_Q" + * + [K_Q] + * "K_Q+shift" + * + [K_Q SHIFT] + * ``` + * + * Useful when the active layer of an input-event is already known. + */ + @Enumerable + public get coreID(): string { + if(typeof this.id === 'undefined') { + return undefined; } - /** - * Converts key IDs of the U_* form to their corresponding UTF-16 text. - * If an ID not matching the pattern is received, returns null. - * @param id - * @returns - */ - static unicodeIDToText(id: string, errorCallback?: (codeAsString: string) => void) { - if(!id || id.substring(0,2) != 'U_') { - return null; - } + let baseID = this.id || ''; - let result = ''; - const codePoints = id.substring(2).split('_'); - for(let codePoint of codePoints) { - const codePointValue = parseInt(codePoint, 16); - if (((0x0 <= codePointValue) && (codePointValue <= 0x1F)) || - ((0x80 <= codePointValue) && (codePointValue <= 0x9F)) || - isNaN(codePointValue)) { - if(errorCallback) { - errorCallback(codePoint); - } - continue; - } else { - // String.fromCharCode() is inadequate to handle the entire range of Unicode - // Someday after upgrading to ES2015, can use String.fromCodePoint() - result += String.kmwFromCharCode(codePointValue); - } - } - return result ? result : null; + if(this.displayLayer != this.layer) { + baseID = baseID + '+' + this.layer; } - static sanitize(rawKey: LayoutKey) { - if(typeof rawKey.width == 'string') { - rawKey.width = parseInt(rawKey.width, 10); - } - // Handles NaN cases as well as 'set to 0' cases; both are intentional here. - rawKey.width ||= ActiveKey.DEFAULT_KEY_WIDTH; + return baseID; + } - if(typeof rawKey.pad == 'string') { - rawKey.pad = parseInt(rawKey.pad, 10); - } - rawKey.pad ||= ActiveKey.DEFAULT_PAD; + /** + * A keyboard-unique identifier to be used for any display elements representing this key + * in user interfaces and/or on-screen keyboards. + * + * Distinguishes between otherwise-identical keys on different layers of an OSK. + * Includes identifying information about the key's display layer. + * + * Examples, given a 'default' display layer, matching keys to Keyman keyboard language: + * + * ``` + * "default-K_Q" + * + [K_Q] + * "default-K_Q+shift" + * + [K_Q SHIFT] + * ``` + * + * Useful when only the active keyboard is known about an input event. + */ + @Enumerable + public get elementID(): string { + if(typeof this.id === 'undefined') { + return undefined; + } - if(typeof rawKey.sp == 'string') { - rawKey.sp = Number.parseInt(rawKey.sp, 10) as ButtonClass; - } - rawKey.sp ||= 0; // The default button class. - } - - static polyfill(key: LayoutKey, layout: ActiveLayout, displayLayer: string) { - // Add class functions to the existing layout object, allowing it to act as an ActiveLayout. - let dummy = new ActiveKey(); - let proto = Object.getPrototypeOf(dummy); - - for(let prop in dummy) { - if(!key.hasOwnProperty(prop)) { - let descriptor = Object.getOwnPropertyDescriptor(proto, prop); - if(descriptor) { - // It's a computed property! Copy the descriptor onto the key's object. - Object.defineProperty(key, prop, descriptor); - } else { - key[prop] = dummy[prop]; - } - } - } + return this.displayLayer + '-' + this.coreID; + } - // Ensure subkeys are also properly extended. - if(key.sk) { - for(let subkey of key.sk) { - ActiveKey.polyfill(subkey, layout, displayLayer); - } - } + @Enumerable + public get baseKeyEvent(): KeyEvent { + return deepCopy(this._baseKeyEvent); + } - let aKey = key as ActiveKey; - aKey.displayLayer = displayLayer; - aKey.layer = aKey.layer || displayLayer; - - // Compute the key's base KeyEvent properties for use in future event generation - aKey.constructBaseKeyEvent(layout, displayLayer); - } - - private constructBaseKeyEvent(layout: ActiveLayout, displayLayer: string) { - // Get key name and keyboard shift state (needed only for default layouts and physical keyboard handling) - // Note - virtual keys should be treated case-insensitive, so we force uppercasing here. - let layer = this.layer || displayLayer || ''; - let keyName= this.id ? this.id.toUpperCase() : null; - - // Start: mirrors _GetKeyEventProperties - - - // First check the virtual key, and process shift, control, alt or function keys - var Lkc: text.KeyEvent = { - // Override key shift state if specified for key in layout (corrected for popup keys KMEW-93) - Lmodifiers: text.KeyboardProcessor.getModifierState(layer), - Lstates: text.KeyboardProcessor.getStateFromLayer(layer), - Lcode: keyName ? text.Codes.keyCodes[keyName] : 0, - LisVirtualKey: true, - vkCode: 0, - kName: keyName, - kLayer: layer, - kbdLayer: displayLayer, - kNextLayer: this.nextlayer, - device: null, - isSynthetic: true - }; - - if(layout.keyboard) { - let keyboard = layout.keyboard; - - // Include *limited* support for mnemonic keyboards (Sept 2012) - // If a touch layout has been defined for a mnemonic keyout, do not perform mnemonic mapping for rules on touch devices. - if(keyboard.isMnemonic && !(layout.isDefault && layout.formFactor != 'desktop')) { - if(Lkc.Lcode != text.Codes.keyCodes['K_SPACE']) { // exception required, March 2013 - // Jan 2019 - interesting that 'K_SPACE' also affects the caps-state check... - Lkc.vkCode = Lkc.Lcode; - this.isMnemonic = true; - } - } else { - Lkc.vkCode=Lkc.Lcode; - } + /** + * Converts key IDs of the U_* form to their corresponding UTF-16 text. + * If an ID not matching the pattern is received, returns null. + * @param id + * @returns + */ + static unicodeIDToText(id: string, errorCallback?: (codeAsString: string) => void) { + if(!id || id.substring(0,2) != 'U_') { + return null; + } - // Support version 1.0 KeymanWeb keyboards that do not define positional vs mnemonic - if(!keyboard.definesPositionalOrMnemonic) { - // Not the best pattern, but currently safe - we don't look up any properties of any of the - // arguments in this use case, and the object's scope is extremely limited. - Lkc.Lcode = KeyMapping._USKeyCodeToCharCode(this.constructKeyEvent(null, null)); - Lkc.LisVirtualKey=false; + let result = ''; + const codePoints = id.substring(2).split('_'); + for(let codePoint of codePoints) { + const codePointValue = parseInt(codePoint, 16); + if (((0x0 <= codePointValue) && (codePointValue <= 0x1F)) || + ((0x80 <= codePointValue) && (codePointValue <= 0x9F)) || + isNaN(codePointValue)) { + if(errorCallback) { + errorCallback(codePoint); } + continue; + } else { + // String.fromCharCode() is inadequate to handle the entire range of Unicode + // Someday after upgrading to ES2015, can use String.fromCodePoint() + result += String.kmwFromCharCode(codePointValue); } + } + return result ? result : null; + } - this.baseKeyEvent = Lkc; + static sanitize(rawKey: LayoutKey) { + if(typeof rawKey.width == 'string') { + rawKey.width = parseInt(rawKey.width, 10); } + // Handles NaN cases as well as 'set to 0' cases; both are intentional here. + rawKey.width ||= ActiveKey.DEFAULT_KEY_WIDTH; - constructKeyEvent(keyboardProcessor: text.KeyboardProcessor, device: utils.DeviceSpec): text.KeyEvent { - // Make a deep copy of our preconstructed key event, filling it out from there. - let Lkc = utils.deepCopy(this.baseKeyEvent); - Lkc.device = device; + if(typeof rawKey.pad == 'string') { + rawKey.pad = parseInt(rawKey.pad, 10); + } + rawKey.pad ||= ActiveKey.DEFAULT_PAD; - if(this.isMnemonic) { - text.KeyboardProcessor.setMnemonicCode(Lkc, this.layer.indexOf('shift') != -1, keyboardProcessor ? keyboardProcessor.stateKeys['K_CAPS'] : false); - } + if(typeof rawKey.sp == 'string') { + rawKey.sp = Number.parseInt(rawKey.sp, 10) as ButtonClass; + } + rawKey.sp ||= 0; // The default button class. + } - // Performs common pre-analysis for both 'native' and 'embedded' OSK key & subkey input events. - // This part depends on the keyboard processor's active state. - if(keyboardProcessor) { - keyboardProcessor.setSyntheticEventDefaults(Lkc); - - // If it's a state key modifier, trigger its effects as part of the - // keystroke. - const bitmap = { - 'K_CAPS': text.Codes.stateBitmasks.CAPS, - 'K_NUMLOCK': text.Codes.stateBitmasks.NUM_LOCK, - 'K_SCROLL': text.Codes.stateBitmasks.SCROLL_LOCK - }; - const bitmask = bitmap[Lkc.kName]; - - if(bitmask) { - Lkc.Lstates ^= bitmask; - Lkc.LmodifierChange = true; + static polyfill(key: LayoutKey, keyboard: Keyboard, layout: ActiveLayout, displayLayer: string) { + // Add class functions to the existing layout object, allowing it to act as an ActiveLayout. + let dummy = new ActiveKey(); + let proto = Object.getPrototypeOf(dummy); + + for(let prop in dummy) { + if(!key.hasOwnProperty(prop)) { + let descriptor = Object.getOwnPropertyDescriptor(proto, prop); + if(descriptor) { + // It's a computed property! Copy the descriptor onto the key's object. + Object.defineProperty(key, prop, descriptor); + } else { + key[prop] = dummy[prop]; } } - - return Lkc; } - public getSubkey(coreID: string): ActiveKey { - if(this.sk) { - for(let key of this.sk) { - if(key.coreID == coreID) { - return key; - } - } + // Ensure subkeys are also properly extended. + if(key.sk) { + for(let subkey of key.sk) { + ActiveKey.polyfill(subkey, keyboard, layout, displayLayer); } - - return null; } - } - export class ActiveRow implements LayoutRow { - // Identify key labels (e.g. *Shift*) that require the special OSK font - static readonly SPECIAL_LABEL=/\*\w+\*/; + let aKey = key as ActiveKey; + aKey.displayLayer = displayLayer; + aKey.layer = aKey.layer || displayLayer; - id: number; - key: ActiveKey[]; + // Compute the key's base KeyEvent properties for use in future event generation + aKey.constructBaseKeyEvent(keyboard, layout, displayLayer); + } - /** - * Used for calculating fat-fingering offsets. - */ - proportionalY: number; + private constructBaseKeyEvent(keyboard: Keyboard, layout: ActiveLayout, displayLayer: string) { + // Get key name and keyboard shift state (needed only for default layouts and physical keyboard handling) + // Note - virtual keys should be treated case-insensitive, so we force uppercasing here. + let layer = this.layer || displayLayer || ''; + let keyName= this.id ? this.id.toUpperCase() : null; + + // Start: mirrors _GetKeyEventProperties + + // First check the virtual key, and process shift, control, alt or function keys + let props: KeyEventSpec = { + // Override key shift state if specified for key in layout (corrected for popup keys KMEW-93) + Lmodifiers: Codes.getModifierState(layer), + Lstates: Codes.getStateFromLayer(layer), + Lcode: keyName ? Codes.keyCodes[keyName] : 0, + LisVirtualKey: true, + vkCode: 0, + kName: keyName, + kLayer: layer, + kbdLayer: displayLayer, + kNextLayer: this.nextlayer, + device: null, + isSynthetic: true + }; - private constructor() { + let Lkc: KeyEvent = new KeyEvent(props); - } + if(layout.keyboard) { + let keyboard = layout.keyboard; - static sanitize(rawRow: LayoutRow) { - for(const key of rawRow.key) { - // Test for a trailing comma included in spec, added as null object by IE - // It has only ever appeared at the end of a row's spec. - if(key == null) { - rawRow.key.length = rawRow.key.length-1; - } else { - ActiveKey.sanitize(key); + // Include *limited* support for mnemonic keyboards (Sept 2012) + // If a touch layout has been defined for a mnemonic keyout, do not perform mnemonic mapping for rules on touch devices. + if(keyboard.isMnemonic && !(layout.isDefault && layout.formFactor != 'desktop')) { + if(Lkc.Lcode != Codes.keyCodes['K_SPACE']) { // exception required, March 2013 + // Jan 2019 - interesting that 'K_SPACE' also affects the caps-state check... + Lkc.vkCode = Lkc.Lcode; + this.isMnemonic = true; } + } else { + Lkc.vkCode=Lkc.Lcode; } - if(typeof rawRow.id == 'string') { - rawRow.id = Number.parseInt(rawRow.id, 10); + // Support version 1.0 KeymanWeb keyboards that do not define positional vs mnemonic + if(!keyboard.definesPositionalOrMnemonic) { + // Not the best pattern, but currently safe - we don't look up any properties of any of the + // arguments in this use case, and the object's scope is extremely limited. + Lkc.Lcode = KeyMapping._USKeyCodeToCharCode(keyboard.constructKeyEvent(null, null, { + K_CAPS: false, + K_NUMLOCK: false, + K_SCROLL: false + })); + Lkc.LisVirtualKey=false; } } - static polyfill(row: LayoutRow, layout: ActiveLayout, displayLayer: string, totalWidth: number, proportionalY: number) { - // Apply defaults, setting the width and other undefined properties for each key - let keys=row['key']; - for(let j=0; j 0) { - const finalKey = keys[keys.length-1] as ActiveKey; + } - // If a single key, and padding is negative, add padding to right align the key - if(keys.length == 1 && finalKey.pad < 0) { - const keyPercent = finalKey.width/totalWidth; - const padPercent = 1-(totalPercent + keyPercent + rightMargin); + static sanitize(rawRow: LayoutRow) { + for(const key of rawRow.key) { + // Test for a trailing comma included in spec, added as null object by IE + // It has only ever appeared at the end of a row's spec. + if(key == null) { + rawRow.key.length = rawRow.key.length-1; + } else { + ActiveKey.sanitize(key); + } + } - // compute center's default x-coord (used in headless modes) - setProportions(finalKey, padPercent, keyPercent, totalPercent); - } else { - const padPercent = finalKey.pad/totalWidth; - const keyPercent = 1-(totalPercent + padPercent + rightMargin); + if(typeof rawRow.id == 'string') { + rawRow.id = Number.parseInt(rawRow.id, 10); + } + } - // compute center's default x-coord (used in headless modes) - setProportions(finalKey, padPercent, keyPercent, totalPercent); + static polyfill(row: LayoutRow, keyboard: Keyboard, layout: ActiveLayout, displayLayer: string, totalWidth: number, proportionalY: number) { + // Apply defaults, setting the width and other undefined properties for each key + let keys=row['key']; + for(let j=0; j 0) { + const finalKey = keys[keys.length-1] as ActiveKey; - constructor() { + // If a single key, and padding is negative, add padding to right align the key + if(keys.length == 1 && finalKey.pad < 0) { + const keyPercent = finalKey.width/totalWidth; + const padPercent = 1-(totalPercent + keyPercent + rightMargin); + // compute center's default x-coord (used in headless modes) + setProportions(finalKey, padPercent, keyPercent, totalPercent); + } else { + const padPercent = finalKey.pad/totalWidth; + const keyPercent = 1-(totalPercent + padPercent + rightMargin); + + // compute center's default x-coord (used in headless modes) + setProportions(finalKey, padPercent, keyPercent, totalPercent); + } } - static sanitize(rawLayer: LayoutLayer) { - for(const row of rawLayer.row) { - ActiveRow.sanitize(row); + // Add class functions to the existing layout object, allowing it to act as an ActiveLayout. + let dummy = new ActiveRow(); + for(let key in dummy) { + if(!row.hasOwnProperty(key)) { + row[key] = dummy[key]; } } - static polyfill(layer: LayoutLayer, layout: ActiveLayout) { - layer.aligned=false; + let aRow = row as ActiveRow; + aRow.proportionalY = proportionalY; + } - // Create a DIV for each row of the group - let rows=layer['row']; + populateKeyMap(map: {[keyId: string]: ActiveKey}) { + this.key.forEach(function(key: ActiveKey) { + if(key.coreID) { + map[key.coreID] = key; + } + }); + } +} - // Calculate the maximum row width (in layout units) - let totalWidth=0; - for(const row of rows) { - let width=0; - const keys=row['key']; +export class ActiveLayer implements LayoutLayer { + row: ActiveRow[]; + id: string; - for(const key of keys) { - // So long as `sanitize` is called first, these coercions are safe. - width += (key.width as number) + (key.pad as number); - } + // These already exist on the objects, pre-polyfill... + // but they still need to be proactively declared on this type. + capsKey?: ActiveKey; + numKey?: ActiveKey; + scrollKey?: ActiveKey; - if(width > totalWidth) { - totalWidth = width; - } - } + totalWidth: number; - // Add default right margin - if(layout.formFactor == 'desktop') { - totalWidth += 5; // TODO: resolve difference between touch and desktop; why don't we use ActiveKey.DEFAULT_RIGHT_MARGIN? - } else { - totalWidth += ActiveKey.DEFAULT_RIGHT_MARGIN; - } + defaultKeyProportionalWidth: number; + rowProportionalHeight: number; - let rowCount = layer.row.length; - for(let i=0; i probability, use a function parameter in place - // of the formula in the loop below. - for(let key in keyDists) { - totalMass += keyProbs[key] = 1 / (Math.pow(keyDists[key], 2) + 1e-6); // Prevent div-by-0 errors. + if(width > totalWidth) { + totalWidth = width; } + } + + // Add default right margin + if(layout.formFactor == 'desktop') { + totalWidth += 5; // TODO: resolve difference between touch and desktop; why don't we use ActiveKey.DEFAULT_RIGHT_MARGIN? + } else { + totalWidth += ActiveKey.DEFAULT_RIGHT_MARGIN; + } - for(let key in keyProbs) { - keyProbs[key] /= totalMass; + let rowCount = layer.row.length; + for(let i=0; i probability, use a function parameter in place + // of the formula in the loop below. + for(let key in keyDists) { + totalMass += keyProbs[key] = 1 / (Math.pow(keyDists[key], 2) + 1e-6); // Prevent div-by-0 errors. + } + + for(let key in keyProbs) { + keyProbs[key] /= totalMass; + } - let keyDists: {[keyId: string]: number} = {}; + return keyProbs; + } - // This double-nested loop computes a pseudo-distance for the touch from each key. Quite useful for - // generating a probability distribution. - this.row.forEach(function(row: ActiveRow): void { - row.key.forEach(function(key: ActiveKey): void { - // If the key lacks an ID, just skip it. Sometimes used for padding. - if(!key.baseKeyID) { + /** + * Computes a squared 'pseudo-distance' for the touch from each key. (Not a proper metric.) + * Intended for use in generating a probability distribution over the keys based on the touch input. + * @param touchCoords A proportional (x, y) coordinate of the touch within the keyboard's geometry. + * Should be within [0, 0] to [1, 1]. + * @param kbdScaleRatio The ratio of the keyboard's horizontal scale to its vertical scale. + * For a 400 x 200 keyboard, should be 2. + */ + private keyTouchDistances(touchCoords: {x: number, y: number}, kbdScaleRatio: number): {[keyId: string]: number} { + let layer = this; + + let keyDists: {[keyId: string]: number} = {}; + + // This double-nested loop computes a pseudo-distance for the touch from each key. Quite useful for + // generating a probability distribution. + this.row.forEach(function(row: ActiveRow): void { + row.key.forEach(function(key: ActiveKey): void { + // If the key lacks an ID, just skip it. Sometimes used for padding. + if(!key.baseKeyID) { + return; + } else { + // Attempt to filter out known non-output keys. + // Results in a more optimized distribution. + if(Codes.isKnownOSKModifierKey(key.baseKeyID)) { + return; + } else if(key.isPadding) { // to the user, blank / padding keys do not exist. return; - } else { - // Attempt to filter out known non-output keys. - // Results in a more optimized distribution. - if(text.Codes.isKnownOSKModifierKey(key.baseKeyID)) { - return; - } else if(key.isPadding) { // to the user, blank / padding keys do not exist. - return; - } - } - // These represent the within-key distance of the touch from the key's center. - // Both should be on the interval [0, 0.5]. - let dx = Math.abs(touchCoords.x - key.proportionalX); - let dy = Math.abs(touchCoords.y - row.proportionalY); - - // If the touch isn't within the key, these store the out-of-key distance - // from the closest point on the key being checked. - let distX: number, distY: number; - - if(dx > 0.5 * key.proportionalWidth) { - distX = (dx - 0.5 * key.proportionalWidth); - dx = 0.5; - } else { - distX = 0; - dx /= key.proportionalWidth; } + } + // These represent the within-key distance of the touch from the key's center. + // Both should be on the interval [0, 0.5]. + let dx = Math.abs(touchCoords.x - key.proportionalX); + let dy = Math.abs(touchCoords.y - row.proportionalY); + + // If the touch isn't within the key, these store the out-of-key distance + // from the closest point on the key being checked. + let distX: number, distY: number; + + if(dx > 0.5 * key.proportionalWidth) { + distX = (dx - 0.5 * key.proportionalWidth); + dx = 0.5; + } else { + distX = 0; + dx /= key.proportionalWidth; + } - if(dy > 0.5 * layer.rowProportionalHeight) { - distY = (dy - 0.5 * layer.rowProportionalHeight); - dy = 0.5; - } else { - distY = 0; - dy /= layer.rowProportionalHeight; - } + if(dy > 0.5 * layer.rowProportionalHeight) { + distY = (dy - 0.5 * layer.rowProportionalHeight); + dy = 0.5; + } else { + distY = 0; + dy /= layer.rowProportionalHeight; + } - // Now that the differentials are computed, it's time to do distance scaling. - // - // For out-of-key distance, we scale the X component by the keyboard's aspect ratio - // to get the actual out-of-key distance rather than proportional. - distX *= kbdScaleRatio; - - // While the keys are rarely perfect squares, we map all within-key distance - // to a square shape. (ALT/CMD should seem as close to SPACE as a 'B'.) - // - // For that square, we take the rowHeight as its edge lengths. - distX += dx * layer.rowProportionalHeight; - distY += dy * layer.rowProportionalHeight; - - let distance = distX * distX + distY * distY; - keyDists[key.coreID] = distance; - }); + // Now that the differentials are computed, it's time to do distance scaling. + // + // For out-of-key distance, we scale the X component by the keyboard's aspect ratio + // to get the actual out-of-key distance rather than proportional. + distX *= kbdScaleRatio; + + // While the keys are rarely perfect squares, we map all within-key distance + // to a square shape. (ALT/CMD should seem as close to SPACE as a 'B'.) + // + // For that square, we take the rowHeight as its edge lengths. + distX += dx * layer.rowProportionalHeight; + distY += dy * layer.rowProportionalHeight; + + let distance = distX * distX + distY * distY; + keyDists[key.coreID] = distance; }); + }); - return keyDists; - } + return keyDists; + } - getKey(keyId: string) { - // Keys usually are specified in a "long form" prefixed with their layer's ID. - if(keyId.indexOf(this.id + '-') == 0) { - keyId = keyId.replace(this.id + '-', ''); - } + getKey(keyId: string) { + // Keys usually are specified in a "long form" prefixed with their layer's ID. + if(keyId.indexOf(this.id + '-') == 0) { + keyId = keyId.replace(this.id + '-', ''); + } - let idComponents = keyId.split('::'); - if(idComponents.length > 1) { - let baseKey = this.keyMap[idComponents[0]]; - return baseKey.getSubkey(idComponents[1]); - } else { - return this.keyMap[keyId]; - } + let idComponents = keyId.split('::'); + if(idComponents.length > 1) { + let baseKey = this.keyMap[idComponents[0]]; + return baseKey.getSubkey(idComponents[1]); + } else { + return this.keyMap[keyId]; } } +} - export class ActiveLayout implements LayoutFormFactor{ - layer: ActiveLayer[]; - font: string; - keyLabels: boolean; - isDefault?: boolean; - keyboard: Keyboard; - formFactor: utils.FormFactor; - - /** - * Facilitates mapping layer id strings to their specification objects. - */ - layerMap: {[layerId: string]: ActiveLayer}; - - private constructor() { - - } - - getLayer(layerId: string): ActiveLayer { - return this.layerMap[layerId]; - } - - /** - * Refer to https://github.com/keymanapp/keyman/issues/254, which mentions - * KD-11 from a prior issue-tracking system from the closed-source days that - * resulted in an unintended extra empty row. - * - * It'll be pretty rare to see a keyboard affected by the bug, but we don't - * 100% control all keyboards out there, so it's best we make sure the edge - * case is covered. - * - * @param layers The layer group to be loaded for the form factor. Will be - * mutated by this operation. - */ - static correctLayerEmptyRowBug(layers: LayoutLayer[]) { - for(let n=0; n=0; i--) { - if(!Array.isArray(rows[i]['key']) || rows[i]['key'].length == 0) { - rows.splice(i, 1) - } +export class ActiveLayout implements LayoutFormFactor{ + layer: ActiveLayer[]; + font: string; + keyLabels: boolean; + isDefault?: boolean; + keyboard: Keyboard; + formFactor: DeviceSpec.FormFactor; + + /** + * Facilitates mapping layer id strings to their specification objects. + */ + layerMap: {[layerId: string]: ActiveLayer}; + + private constructor() { + + } + + getLayer(layerId: string): ActiveLayer { + return this.layerMap[layerId]; + } + + /** + * Refer to https://github.com/keymanapp/keyman/issues/254, which mentions + * KD-11 from a prior issue-tracking system from the closed-source days that + * resulted in an unintended extra empty row. + * + * It'll be pretty rare to see a keyboard affected by the bug, but we don't + * 100% control all keyboards out there, so it's best we make sure the edge + * case is covered. + * + * @param layers The layer group to be loaded for the form factor. Will be + * mutated by this operation. + */ + static correctLayerEmptyRowBug(layers: LayoutLayer[]) { + for(let n=0; n=0; i--) { + if(!Array.isArray(rows[i]['key']) || rows[i]['key'].length == 0) { + rows.splice(i, 1) } } } + } - static sanitize(rawLayout: LayoutFormFactor) { - ActiveLayout.correctLayerEmptyRowBug(rawLayout.layer); + static sanitize(rawLayout: LayoutFormFactor) { + ActiveLayout.correctLayerEmptyRowBug(rawLayout.layer); - for(const layer of rawLayout.layer) { - ActiveLayer.sanitize(layer); - } + for(const layer of rawLayout.layer) { + ActiveLayer.sanitize(layer); } + } - /** - * - * @param layout - * @param formFactor - */ - static polyfill(layout: LayoutFormFactor, keyboard: Keyboard, formFactor: utils.FormFactor): ActiveLayout { - if(layout == null) { - throw new Error("Cannot build an ActiveLayout for a null specification."); - } + /** + * + * @param layout + * @param formFactor + */ + static polyfill(layout: LayoutFormFactor, keyboard: Keyboard, formFactor: DeviceSpec.FormFactor): ActiveLayout { + if(layout == null) { + throw new Error("Cannot build an ActiveLayout for a null specification."); + } - /* Standardize the layout object's data types. - * - * In older versions of KMW, some numeric properties were long represented as strings instead, - * and that lives on within a _lot_ of keyboards. The data should be sanitized before it - * is processed by this method. - */ - this.sanitize(layout); - - // Create a separate OSK div for each OSK layer, only one of which will ever be visible - var n: number; - let layerMap: {[layerId: string]: ActiveLayer} = {}; - - let layers=layout.layer; - - // Add class functions to the existing layout object, allowing it to act as an ActiveLayout. - let dummy = new ActiveLayout(); - for(let key in dummy) { - if(!layout.hasOwnProperty(key)) { - layout[key] = dummy[key]; - } - } + /* Standardize the layout object's data types. + * + * In older versions of KMW, some numeric properties were long represented as strings instead, + * and that lives on within a _lot_ of keyboards. The data should be sanitized before it + * is processed by this method. + */ + this.sanitize(layout); - let aLayout = layout as ActiveLayout; - aLayout.keyboard = keyboard; - aLayout.formFactor = formFactor; + // Create a separate OSK div for each OSK layer, only one of which will ever be visible + var n: number; + let layerMap: {[layerId: string]: ActiveLayer} = {}; - for(n=0; n?~~~~~ '; + + static readonly DEFAULT_RAW_SPEC = {'F':'Tahoma', 'BK': Layouts.dfltText}; + + // Cross-reference with the ids in osk.setButtonClass. + static buttonClasses: {[name: string]: ButtonClass} = { + 'DEFAULT':0, + 'SHIFT':1, + 'SHIFT-ON':2, + 'SPECIAL':3, + 'SPECIAL-ON':4, + 'DEADKEY':8, + 'BLANK':9, + 'HIDDEN':10 + }; - export type LayoutSpec = { - "desktop"?: LayoutFormFactor, - "phone"?: LayoutFormFactor, - "tablet"?: LayoutFormFactor - } + static modifierSpecials = { + 'leftalt': '*LAlt*', + 'rightalt': '*RAlt*', + 'alt': '*Alt*', + 'leftctrl': '*LCtrl*', + 'rightctrl': '*RCtrl*', + 'ctrl': '*Ctrl*', + 'ctrl-alt': '*AltGr*', + 'leftctrl-leftalt': '*LAltCtrl*', + 'rightctrl-rightalt': '*RAltCtrl*', + 'leftctrl-leftalt-shift': '*LAltCtrlShift*', + 'rightctrl-rightalt-shift': '*RAltCtrlShift*', + 'shift': '*Shift*', + 'shift-alt': '*AltShift*', + 'shift-ctrl': '*CtrlShift*', + 'shift-ctrl-alt': '*AltCtrlShift*', + 'leftalt-shift': '*LAltShift*', + 'rightalt-shift': '*RAltShift*', + 'leftctrl-shift': '*LCtrlShift*', + 'rightctrl-shift': '*RCtrlShift*' + }; - // This class manages default layout construction for consumption by OSKs without a specified layout. - export class Layouts { - static dfltCodes=[ - "K_BKQUOTE","K_1","K_2","K_3","K_4","K_5","K_6","K_7","K_8","K_9","K_0", - "K_HYPHEN","K_EQUAL","K_*","K_*","K_*","K_Q","K_W","K_E","K_R","K_T", - "K_Y","K_U","K_I","K_O","K_P","K_LBRKT","K_RBRKT","K_BKSLASH","K_*", - "K_*","K_*","K_A","K_S","K_D","K_F","K_G","K_H","K_J","K_K","K_L", - "K_COLON","K_QUOTE","K_*","K_*","K_*","K_*","K_*","K_oE2", - "K_Z","K_X","K_C","K_V","K_B","K_N","K_M","K_COMMA","K_PERIOD", - "K_SLASH","K_*","K_*","K_*","K_*","K_*","K_SPACE" - ]; - - static dfltText='`1234567890-=\xA7~~qwertyuiop[]\\~~~asdfghjkl;\'~~~~~?zxcvbnm,./~~~~~ ' - +'~!@#$%^&*()_+\xA7~~QWERTYUIOP{}\\~~~ASDFGHJKL:"~~~~~?ZXCVBNM<>?~~~~~ '; - - static readonly DEFAULT_RAW_SPEC = {'F':'Tahoma', 'BK': Layouts.dfltText}; - - // Cross-reference with the ids in osk.setButtonClass. - static buttonClasses: {[name: string]: ButtonClass} = { - 'DEFAULT':0, - 'SHIFT':1, - 'SHIFT-ON':2, - 'SPECIAL':3, - 'SPECIAL-ON':4, - 'DEADKEY':8, - 'BLANK':9, - 'HIDDEN':10 - }; - - static modifierSpecials = { - 'leftalt': '*LAlt*', - 'rightalt': '*RAlt*', - 'alt': '*Alt*', - 'leftctrl': '*LCtrl*', - 'rightctrl': '*RCtrl*', - 'ctrl': '*Ctrl*', - 'ctrl-alt': '*AltGr*', - 'leftctrl-leftalt': '*LAltCtrl*', - 'rightctrl-rightalt': '*RAltCtrl*', - 'leftctrl-leftalt-shift': '*LAltCtrlShift*', - 'rightctrl-rightalt-shift': '*RAltCtrlShift*', - 'shift': '*Shift*', - 'shift-alt': '*AltShift*', - 'shift-ctrl': '*CtrlShift*', - 'shift-ctrl-alt': '*AltCtrlShift*', - 'leftalt-shift': '*LAltShift*', - 'rightalt-shift': '*RAltShift*', - 'leftctrl-shift': '*LCtrlShift*', - 'rightctrl-shift': '*RCtrlShift*' - }; - - /** - * Build a default layout for keyboards with no explicit layout - * - * @param {Object} PVK raw specifications - * @param {Keyboard} keyboard keyboard object (as loaded) - * @param {string} formFactor (really utils.FormFactor) - * @return {LayoutFormFactor} - */ - static buildDefaultLayout(PVK, keyboard: Keyboard, formFactor: string): LayoutFormFactor { - // Build a layout using the default for the device - var layoutType=formFactor; - - if(typeof Layouts.dfltLayout[layoutType] != 'object') { - layoutType = 'desktop'; - } + /** + * Build a default layout for keyboards with no explicit layout + * + * @param {Object} PVK raw specifications + * @param {Keyboard} keyboard keyboard object (as loaded) + * @param {string} formFactor (really utils.FormFactor) + * @return {LayoutFormFactor} + */ + static buildDefaultLayout(PVK, keyboard: Keyboard, formFactor: string): LayoutFormFactor { + // Build a layout using the default for the device + var layoutType=formFactor; + + if(typeof Layouts.dfltLayout[layoutType] != 'object') { + layoutType = 'desktop'; + } - let kbdBitmask = Codes.modifierBitmasks['NON_CHIRAL']; - // An unfortunate dependency there. Should probably also set a version within web-core for use. - let kbdDevVersion = utils.Version.CURRENT; - if(keyboard) { - kbdBitmask = keyboard.modifierBitmask; - kbdDevVersion = keyboard.compilerVersion; - } + let kbdBitmask = Codes.modifierBitmasks['NON_CHIRAL']; + // An unfortunate dependency there. Should probably also set a version within web-core for use. + let kbdDevVersion = Version.CURRENT; + if(keyboard) { + kbdBitmask = keyboard.modifierBitmask; + kbdDevVersion = keyboard.compilerVersion; + } - if(!PVK) { - PVK = this.DEFAULT_RAW_SPEC; - } + if(!PVK) { + PVK = this.DEFAULT_RAW_SPEC; + } - // Clone the default layout object for this device - var layout: LayoutFormFactor = utils.deepCopy(Layouts.dfltLayout[layoutType]); + // Clone the default layout object for this device + var layout: LayoutFormFactor = deepCopy(Layouts.dfltLayout[layoutType]); - var n,layers=layout['layer'], keyLabels: KLS=PVK['KLS'], key102=PVK['K102']; - var i, j, k, m, row, rows: LayoutRow[], key: LayoutKey, keys: LayoutKey[]; - var chiral: boolean = (kbdBitmask & Codes.modifierBitmasks.IS_CHIRAL) != 0; + var n,layers=layout['layer'], keyLabels: KLS=PVK['KLS'], key102=PVK['K102']; + var i, j, k, m, row, rows: LayoutRow[], key: LayoutKey, keys: LayoutKey[]; + var chiral: boolean = (kbdBitmask & Codes.modifierBitmasks.IS_CHIRAL) != 0; - if(PVK['F']) { - // The KeymanWeb compiler generates a string of the format `[italic ][bold ] 1em ""` - // We will ignore the bold, italic and font size spec - let legacyFontSpec = /^(?:(?:italic|bold) )* *[0-9.eE-]+(?:[a-z]+) "(.+)"$/.exec(PVK['F']); - if(legacyFontSpec) { - layout.font = legacyFontSpec[1]; - } + if(PVK['F']) { + // The KeymanWeb compiler generates a string of the format `[italic ][bold ] 1em ""` + // We will ignore the bold, italic and font size spec + let legacyFontSpec = /^(?:(?:italic|bold) )* *[0-9.eE-]+(?:[a-z]+) "(.+)"$/.exec(PVK['F']); + if(legacyFontSpec) { + layout.font = legacyFontSpec[1]; } + } - var kmw10Plus = !(typeof keyLabels == 'undefined' || !keyLabels); - if(!kmw10Plus) { - // Save the processed key label information to the keyboard's general data. - // Makes things more efficient elsewhere and for reloading after keyboard swaps. - keyLabels = PVK['KLS'] = Layouts.processLegacyDefinitions(PVK['BK']); - } + var kmw10Plus = !(typeof keyLabels == 'undefined' || !keyLabels); + if(!kmw10Plus) { + // Save the processed key label information to the keyboard's general data. + // Makes things more efficient elsewhere and for reloading after keyboard swaps. + keyLabels = PVK['KLS'] = Layouts.processLegacyDefinitions(PVK['BK']); + } - // Identify key labels (e.g. *Shift*) that require the special OSK font - var specialLabel=/\*\w+\*/; + // Identify key labels (e.g. *Shift*) that require the special OSK font + var specialLabel=/\*\w+\*/; - // *** Step 1: instantiate the layer objects. *** + // *** Step 1: instantiate the layer objects. *** - // Get the list of valid layers, enforcing that the 'default' layer must be the first one processed. - var validIdList = Object.getOwnPropertyNames(keyLabels), invalidIdList = []; - validIdList.splice(validIdList.indexOf('default'), 1); - validIdList = [ 'default' ].concat(validIdList); + // Get the list of valid layers, enforcing that the 'default' layer must be the first one processed. + var validIdList = Object.getOwnPropertyNames(keyLabels), invalidIdList = []; + validIdList.splice(validIdList.indexOf('default'), 1); + validIdList = [ 'default' ].concat(validIdList); - // Automatic AltGr emulation if the 'leftctrl-leftalt' layer is otherwise undefined. - if(keyboard && keyboard.emulatesAltGr) { - // We insert only the layers that need to be emulated. - if((validIdList.indexOf('leftctrl-leftalt') == -1) && validIdList.indexOf('rightalt') != -1) { - validIdList.push('leftctrl-leftalt'); - keyLabels['leftctrl-leftalt'] = keyLabels['rightalt']; - } + // Automatic AltGr emulation if the 'leftctrl-leftalt' layer is otherwise undefined. + if(keyboard && keyboard.emulatesAltGr) { + // We insert only the layers that need to be emulated. + if((validIdList.indexOf('leftctrl-leftalt') == -1) && validIdList.indexOf('rightalt') != -1) { + validIdList.push('leftctrl-leftalt'); + keyLabels['leftctrl-leftalt'] = keyLabels['rightalt']; + } - if((validIdList.indexOf('leftctrl-leftalt-shift') == -1) && validIdList.indexOf('rightalt-shift') != -1) { - validIdList.push('leftctrl-leftalt-shift'); - keyLabels['leftctrl-leftalt-shift'] = keyLabels['rightalt-shift']; - } + if((validIdList.indexOf('leftctrl-leftalt-shift') == -1) && validIdList.indexOf('rightalt-shift') != -1) { + validIdList.push('leftctrl-leftalt-shift'); + keyLabels['leftctrl-leftalt-shift'] = keyLabels['rightalt-shift']; } + } - // If there is no predefined layout, even touch layouts will follow the desktop's - // setting for the displayUnderlying flag. As the desktop layout uses a different - // format for its layout spec, that's found at the field referenced below. - layout["displayUnderlying"] = keyboard ? !!keyboard.scriptObject['KDU'] : false; + // If there is no predefined layout, even touch layouts will follow the desktop's + // setting for the displayUnderlying flag. As the desktop layout uses a different + // format for its layout spec, that's found at the field referenced below. + layout["displayUnderlying"] = keyboard ? !!keyboard.scriptObject['KDU'] : false; - // For desktop devices, we must create all layers, even if invalid. - if(formFactor == 'desktop') { - invalidIdList = Layouts.generateLayerIds(chiral); + // For desktop devices, we must create all layers, even if invalid. + if(formFactor == 'desktop') { + invalidIdList = Layouts.generateLayerIds(chiral); - // Filter out all ids considered valid. (We also don't want duplicates in the following list...) - for(n=0; n 0) { - layers[n]=utils.deepCopy(layers[0]); - } - layers[n]['id']=idList[n]; - layers[n]['nextlayer']=idList[n]; // This would only be different for a dynamic keyboard - - // Extraced into a helper method to improve readability. - Layouts.formatDefaultLayer(layers[n], chiral, formFactor, !!key102); + for(n=0; n 0) { + layers[n]=deepCopy(layers[0]); } + layers[n]['id']=idList[n]; + layers[n]['nextlayer']=idList[n]; // This would only be different for a dynamic keyboard - // *** Step 2: Layer objects now exist; time to fill them with the appropriate key labels and key styles *** - for(n=0; n= 0 && kx < layerSpec.length) key['text']=layerSpec[kx]; - } + // Extraced into a helper method to improve readability. + Layouts.formatDefaultLayer(layers[n], chiral, formFactor, !!key102); + } - // Legacy (pre 12.0) behavior: fall back to US English keycap text as default for the base two layers - // if a key cap is not otherwise defined. (Any intentional 'ghost' keys must be explicitly defined.) - if(isDefault && kbdDevVersion.precedes(utils.Version.NO_DEFAULT_KEYCAPS)) { - if(key['id'] != 'K_SPACE' && kx+65 * isShift < Layouts.dfltText.length && key['text'] !== null) { - key['text'] = key['text'] || Layouts.dfltText[kx+65*isShift]; - } - } + // *** Step 2: Layer objects now exist; time to fill them with the appropriate key labels and key styles *** + for(n=0; n= 0 && kx < layerSpec.length) key['text']=layerSpec[kx]; } - // Leave any unmarked key caps as null strings - if(key['text'] !== null) { - key['text'] = key['text'] || ''; + // Legacy (pre 12.0) behavior: fall back to US English keycap text as default for the base two layers + // if a key cap is not otherwise defined. (Any intentional 'ghost' keys must be explicitly defined.) + if(isDefault && kbdDevVersion.precedes(Version.NO_DEFAULT_KEYCAPS)) { + if(key['id'] != 'K_SPACE' && kx+65 * isShift < Layouts.dfltText.length && key['text'] !== null) { + key['text'] = key['text'] || Layouts.dfltText[kx+65*isShift]; + } } + } - // Detect important tracking keys. - switch(key['id']) { - case "K_SHIFT": - shiftKey=key; - break; - case "K_TAB": - nextKey=key; - break; - case "K_CAPS": - capsKey=key; - break; - case "K_NUMLOCK": - numKey=key; - break; - case "K_SCROLL": - scrollKey=key; - break; - } + // Leave any unmarked key caps as null strings + if(key['text'] !== null) { + key['text'] = key['text'] || ''; + } - // Remove pop-up shift keys referencing invalid layers (Build 349) - if(key['sk'] != null) { - for(k=0; k 0 && shiftKey != null) { - shiftKey['sp']=Layouts.buttonClasses['SHIFT-ON']; - shiftKey['sk']=null; - shiftKey['text'] = Layouts.modifierSpecials[layers[n].id] ? Layouts.modifierSpecials[layers[n].id] : "*Shift*"; + if(key['sk'].length == 0) { + key['sk']=null; + } } } } - return layout; - } - - /** - * Function getLayerId - * Scope Private - * @param {number} m shift modifier code - * @return {string} layer string from shift modifier code (desktop keyboards) - * Description Get name of layer from code, where the modifer order is determined by ascending bit-flag value. - */ - static getLayerId(m: number): string { - let modifierCodes = Codes.modifierCodes; - - var s=''; - if(m == 0) { - return 'default'; - } else { - if(m & modifierCodes['LCTRL']) { - s = (s.length > 0 ? s + '-' : '') + 'leftctrl'; - } - if(m & modifierCodes['RCTRL']) { - s = (s.length > 0 ? s + '-' : '') + 'rightctrl'; + // We're done with the layer keys initialization pass. Time to do post-analysis layer-level init where necessary. + layer.shiftKey=shiftKey; + layer.capsKey=capsKey; + layer.numKey=numKey; + layer.scrollKey=scrollKey; + + // Set modifier key appearance and behaviour for non-desktop devices using the default layout + if(formFactor != 'desktop') { + if(n > 0 && shiftKey != null) { + shiftKey['sp']=Layouts.buttonClasses['SHIFT-ON']; + shiftKey['sk']=null; + shiftKey['text'] = Layouts.modifierSpecials[layers[n].id] ? Layouts.modifierSpecials[layers[n].id] : "*Shift*"; } - if(m & modifierCodes['LALT']) { - s = (s.length > 0 ? s + '-' : '') + 'leftalt'; - } - if(m & modifierCodes['RALT']) { - s = (s.length > 0 ? s + '-' : '') + 'rightalt'; - } - if(m & modifierCodes['SHIFT']) { - s = (s.length > 0 ? s + '-' : '') + 'shift'; - } - if(m & modifierCodes['CTRL']) { - s = (s.length > 0 ? s + '-' : '') + 'ctrl'; - } - if(m & modifierCodes['ALT']) { - s = (s.length > 0 ? s + '-' : '') + 'alt'; - } - return s; } } - /** - * Generates a list of potential layer ids for the specified chirality mode. - * - * @param {boolean} chiral // Does the keyboard use chiral modifiers or not? - */ - static generateLayerIds(chiral: boolean): string[] { - var layerCnt, offset; - - if(chiral) { - layerCnt=32; - offset=0x01; - } else { - layerCnt=8; - offset=0x10; + return layout; + } + + /** + * Function getLayerId + * Scope Private + * @param {number} m shift modifier code + * @return {string} layer string from shift modifier code (desktop keyboards) + * Description Get name of layer from code, where the modifer order is determined by ascending bit-flag value. + */ + static getLayerId(m: number): string { + let modifierCodes = Codes.modifierCodes; + + var s=''; + if(m == 0) { + return 'default'; + } else { + if(m & modifierCodes['LCTRL']) { + s = (s.length > 0 ? s + '-' : '') + 'leftctrl'; } + if(m & modifierCodes['RCTRL']) { + s = (s.length > 0 ? s + '-' : '') + 'rightctrl'; + } + if(m & modifierCodes['LALT']) { + s = (s.length > 0 ? s + '-' : '') + 'leftalt'; + } + if(m & modifierCodes['RALT']) { + s = (s.length > 0 ? s + '-' : '') + 'rightalt'; + } + if(m & modifierCodes['SHIFT']) { + s = (s.length > 0 ? s + '-' : '') + 'shift'; + } + if(m & modifierCodes['CTRL']) { + s = (s.length > 0 ? s + '-' : '') + 'ctrl'; + } + if(m & modifierCodes['ALT']) { + s = (s.length > 0 ? s + '-' : '') + 'alt'; + } + return s; + } + } - var layerIds = []; + /** + * Generates a list of potential layer ids for the specified chirality mode. + * + * @param {boolean} chiral // Does the keyboard use chiral modifiers or not? + */ + static generateLayerIds(chiral: boolean): string[] { + var layerCnt, offset; + + if(chiral) { + layerCnt=32; + offset=0x01; + } else { + layerCnt=8; + offset=0x10; + } - for(var i=0; i < layerCnt; i++) { - layerIds.push(Layouts.getLayerId(i * offset)); - } + var layerIds = []; - return layerIds; + for(var i=0; i < layerCnt; i++) { + layerIds.push(Layouts.getLayerId(i * offset)); } - /** - * Sets a formatting property for the modifier keys when constructing a default layout for a keyboard. - * - * @param {Object} layer // One layer specification - * @param {boolean} chiral // Whether or not the keyboard uses chiral modifier information. - * @param {string} formFactor // The form factor of the device the layout is being constructed for. - * @param {boolean} key102 // Whether or not the extended key 102 should be hidden. - */ - static formatDefaultLayer(layer: LayoutLayer, chiral: boolean, formFactor: string, key102: boolean) { - var layerId = layer['id']; - let buttonClasses = Layouts.buttonClasses; - - // Correct appearance of state-dependent modifier keys according to group - for(var i=0; i -/// -/// +import Codes from "../text/codes.js"; +import { Layouts, type LayoutFormFactor } from "./defaultLayouts.js"; +import { ActiveKey, ActiveLayout } from "./activeLayout.js"; +import KeyEvent from "../text/keyEvent.js"; +import type OutputTarget from "../text/outputTarget.js"; + +import type { ComplexKeyboardStore } from "../text/kbdInterface.js"; + +import { Version, DeviceSpec } from "@keymanapp/web-utils"; +import StateKeyMap from "./stateKeyMap.js"; + +/** + * Stores preprocessed properties of a keyboard for quick retrieval later. + */ +class CacheTag { + stores: {[storeName: string]: ComplexKeyboardStore}; + + constructor() { + this.stores = {}; + } +} + +export enum LayoutState { + NOT_LOADED = undefined, + POLYFILLED = 1, + CALIBRATED = 2 +} + +export interface VariableStoreDictionary { + [name: string]: string; +}; + + +/** + * Acts as a wrapper class for Keyman keyboards compiled to JS, providing type information + * and keyboard-centered functionality in an object-oriented way without modifying the + * wrapped keyboard itself. + */ +export default class Keyboard { + public static DEFAULT_SCRIPT_OBJECT = { + 'gs': function(outputTarget, keystroke) { return false; }, // no matching rules; rely on defaultRuleOutput entirely + 'KI': '', // The currently-existing default keyboard ID; we already have checks that focus against this. + 'KN': '', + 'KV': Layouts.DEFAULT_RAW_SPEC, + 'KM': 0 // May not be the best default, but this matches current behavior when there is no activeKeyboard. + } -namespace com.keyman.keyboards { /** - * Stores preprocessed properties of a keyboard for quick retrieval later. + * This is the object provided to KeyboardInterface.registerKeyboard - that is, the keyboard + * being wrapped. + * + * TODO: Make this private instead. But there are a LOT of references that must be rooted out first. */ - class CacheTag { - stores: {[storeName: string]: text.ComplexKeyboardStore}; + public readonly scriptObject: any; + private layoutStates: {[layout: string]: LayoutState}; - constructor() { - this.stores = {}; + constructor(keyboardScript: any) { + if(keyboardScript) { + this.scriptObject = keyboardScript; + } else { + this.scriptObject = Keyboard.DEFAULT_SCRIPT_OBJECT; } + this.layoutStates = {}; } - export enum LayoutState { - NOT_LOADED = undefined, - POLYFILLED = 1, - CALIBRATED = 2 + /** + * Calls the keyboard's `gs` function, which represents the keyboard source's begin Unicode group. + */ + process(outputTarget: OutputTarget, keystroke: KeyEvent): boolean { + return this.scriptObject['gs'](outputTarget, keystroke); } - export interface VariableStoreDictionary { - [name: string]: string; - }; + /** + * Calls the keyboard's `gn` function, which represents the keyboard source's begin newContext group. + */ + processNewContextEvent(outputTarget: OutputTarget, keystroke: KeyEvent): boolean { + return this.scriptObject['gn'] ? this.scriptObject['gn'](outputTarget, keystroke) : false; + } + + /** + * Calls the keyboard's `gpk` function, which represents the keyboard source's begin postKeystroke group. + */ + processPostKeystroke(outputTarget: OutputTarget, keystroke: KeyEvent): boolean { + return this.scriptObject['gpk'] ? this.scriptObject['gpk'](outputTarget, keystroke) : false; + } + get isHollow(): boolean { + return this.scriptObject == Keyboard.DEFAULT_SCRIPT_OBJECT; + } + + get id(): string { + return this.scriptObject['KI']; + } + + get name(): string { + return this.scriptObject['KN']; + } /** - * Acts as a wrapper class for Keyman keyboards compiled to JS, providing type information - * and keyboard-centered functionality in an object-oriented way without modifying the - * wrapped keyboard itself. + * Cache variable store values + * + * Primarily used for predictive text to prevent variable store + * values from being changed in 'fat finger' processing. + * + * KVS is available in keyboards compiled with Keyman Developer 15 + * and later versions. See #2924. + * + * @returns an object with each property referencing a variable store */ - export class Keyboard { - public static DEFAULT_SCRIPT_OBJECT = { - 'gs': function(outputTarget, keystroke) { return false; }, // no matching rules; rely on defaultRuleOutput entirely - 'KI': '', // The currently-existing default keyboard ID; we already have checks that focus against this. - 'KN': '', - 'KV': Layouts.DEFAULT_RAW_SPEC, - 'KM': 0 // May not be the best default, but this matches current behavior when there is no activeKeyboard. + get variableStores(): VariableStoreDictionary { + const storeNames = this.scriptObject['KVS']; + let values = {}; + if(Array.isArray(storeNames)) { + for(let store of storeNames) { + values[store] = this.scriptObject[store]; + } } + return values; + } - /** - * This is the object provided to KeyboardInterface.registerKeyboard - that is, the keyboard - * being wrapped. - * - * TODO: Make this private instead. But there are a LOT of references that must be rooted out first. - */ - public readonly scriptObject: any; - private layoutStates: {[layout: string]: LayoutState}; - - constructor(keyboardScript: any) { - if(keyboardScript) { - this.scriptObject = keyboardScript; - } else { - this.scriptObject = Keyboard.DEFAULT_SCRIPT_OBJECT; + /** + * Restore variable store values from cache + * + * KVS is available in keyboards compiled with Keyman Developer 15 + * and later versions. See #2924. + * + * @param values name-value pairs for each store value + */ + set variableStores(values: VariableStoreDictionary) { + const storeNames = this.scriptObject['KVS']; + if(Array.isArray(storeNames)) { + for(let store of storeNames) { + // If the value is not present in the cache, don't overwrite it; + // while this is not used in initial implementation, we could use + // it in future to update a single variable store value rather than + // the whole cache. + if(typeof values[store] == 'string') { + this.scriptObject[store] = values[store]; + } } - this.layoutStates = {}; } + } - /** - * Calls the keyboard's `gs` function, which represents the keyboard source's begin Unicode group. - */ - process(outputTarget: text.OutputTarget, keystroke: text.KeyEvent): boolean { - return this.scriptObject['gs'](outputTarget, keystroke); - } + // TODO: Better typing. + private get _legacyLayoutSpec(): any { + return this.scriptObject['KV']; // used with buildDefaultLayout; layout must be constructed at runtime. + } - /** - * Calls the keyboard's `gn` function, which represents the keyboard source's begin newContext group. - */ - processNewContextEvent(outputTarget: text.OutputTarget, keystroke: text.KeyEvent): boolean { - return this.scriptObject['gn'] ? this.scriptObject['gn'](outputTarget, keystroke) : false; - } + // May return null if no layouts exist or have been initialized. + private get _layouts(): {[formFactor: string]: LayoutFormFactor} { + return this.scriptObject['KVKL']; // This one is compiled by Developer's visual keyboard layout editor. + } - /** - * Calls the keyboard's `gpk` function, which represents the keyboard source's begin postKeystroke group. - */ - processPostKeystroke(outputTarget: text.OutputTarget, keystroke: text.KeyEvent): boolean { - return this.scriptObject['gpk'] ? this.scriptObject['gpk'](outputTarget, keystroke) : false; - } + private set _layouts(value) { + this.scriptObject['KVKL'] = value; + } - get isHollow(): boolean { - return this.scriptObject == Keyboard.DEFAULT_SCRIPT_OBJECT; - } + get compilerVersion(): Version { + return new Version(this.scriptObject['KVER']); + } - get id(): string { - return this.scriptObject['KI']; - } + get isMnemonic(): boolean { + return !!this.scriptObject['KM']; + } - get name(): string { - return this.scriptObject['KN']; - } + get definesPositionalOrMnemonic(): boolean { + return typeof this.scriptObject['KM'] != 'undefined'; + } - /** - * Cache variable store values - * - * Primarily used for predictive text to prevent variable store - * values from being changed in 'fat finger' processing. - * - * KVS is available in keyboards compiled with Keyman Developer 15 - * and later versions. See #2924. - * - * @returns an object with each property referencing a variable store - */ - get variableStores(): VariableStoreDictionary { - const storeNames = this.scriptObject['KVS']; - let values = {}; - if(Array.isArray(storeNames)) { - for(let store of storeNames) { - values[store] = this.scriptObject[store]; - } - } - return values; - } + /** + * HTML help text, as specified by either the &kmw_helptext or &kmw_helpfile system stores. + * + * Reference: https://help.keyman.com/developer/language/reference/kmw_helptext, + * https://help.keyman.com/developer/language/reference/kmw_helpfile + */ + get helpText(): string { + return this.scriptObject['KH']; + } - /** - * Restore variable store values from cache - * - * KVS is available in keyboards compiled with Keyman Developer 15 - * and later versions. See #2924. - * - * @param values name-value pairs for each store value - */ - set variableStores(values: VariableStoreDictionary) { - const storeNames = this.scriptObject['KVS']; - if(Array.isArray(storeNames)) { - for(let store of storeNames) { - // If the value is not present in the cache, don't overwrite it; - // while this is not used in initial implementation, we could use - // it in future to update a single variable store value rather than - // the whole cache. - if(typeof values[store] == 'string') { - this.scriptObject[store] = values[store]; - } - } - } - } + /** + * Embedded JS script designed for use with a keyboard's HTML help text. Always defined + * within the file referenced by &kmw_embedjs in a keyboard's source, though that file + * may also contain _other_ script definitions as well. (`KHF` must be explicitly defined + * within that file.) + */ + get hasScript(): boolean { + return !!this.scriptObject['KHF']; + } - // TODO: Better typing. - private get _legacyLayoutSpec(): any { - return this.scriptObject['KV']; // used with buildDefaultLayout; layout must be constructed at runtime. - } + /** + * Embeds a custom script for use by the OSK, which may be interactive (like with sil_euro_latin). + * Note: this must be called AFTER any contents of `helpText` have been inserted into the DOM. + * (See sil_euro_latin's source -> sil_euro_latin_js.txt) + * + * Reference: https://help.keyman.com/developer/language/reference/kmw_embedjs + */ + embedScript(e: any) { + // e: Expects the OSKManager's _Box element. We don't add type info here b/c it would + // reference the DOM. + this.scriptObject['KHF'](e); + } - // May return null if no layouts exist or have been initialized. - private get _layouts(): {[formFactor: string]: LayoutFormFactor} { - return this.scriptObject['KVKL']; // This one is compiled by Developer's visual keyboard layout editor. - } + get oskStyling(): string { + return this.scriptObject['KCSS']; + } - private set _layouts(value) { - this.scriptObject['KVKL'] = value; + /** + * true if this keyboard uses a (legacy) pick list (Chinese, Japanese, Korean, etc.) + * + * TODO: Make a property on keyboards (say, `isPickList` / `KPL`) to signal this when we + * get around to better, generalized picker-list support. + */ + get isCJK(): boolean { // I3363 (Build 301) + var lg: string; + if(typeof(this.scriptObject['KLC']) != 'undefined') { + lg = this.scriptObject['KLC']; + } else if(typeof(this.scriptObject['LanguageCode']) != 'undefined') { + lg = this.scriptObject['LanguageCode']; } - get compilerVersion(): utils.Version { - return new utils.Version(this.scriptObject['KVER']); - } + // While some of these aren't proper BCP-47 language codes, the CJK keyboards predate our use of BCP-47. + // So, we preserve the old ISO 639-3 codes, as that's what the keyboards are matching against. + return ((lg == 'cmn') || (lg == 'jpn') || (lg == 'kor')); + } + + get isRTL(): boolean { + return !!this.scriptObject['KRTL']; + } + + /** + * Obtains the currently-active modifier bitmask for the active keyboard. + */ + get modifierBitmask(): number { + // NON_CHIRAL is the default bitmask if KMBM is not defined. + // We always need a bitmask to compare against, as seen in `isChiral`. + return this.scriptObject['KMBM'] || Codes.modifierBitmasks['NON_CHIRAL']; + } - get isMnemonic(): boolean { - return !!this.scriptObject['KM']; + get isChiral(): boolean { + return !!(this.modifierBitmask & Codes.modifierBitmasks['IS_CHIRAL']); + } + + get desktopFont(): string { + if(this.scriptObject['KV']) { + return this.scriptObject['KV']['F']; + } else { + return null; } + } - get definesPositionalOrMnemonic(): boolean { - return typeof this.scriptObject['KM'] != 'undefined'; + private get cacheTag(): CacheTag { + let tag = this.scriptObject['_kmw']; + + if(!tag) { + tag = new CacheTag(); + this.scriptObject['_kmw'] = tag; } - /** - * HTML help text, as specified by either the &kmw_helptext or &kmw_helpfile system stores. - * - * Reference: https://help.keyman.com/developer/language/reference/kmw_helptext, - * https://help.keyman.com/developer/language/reference/kmw_helpfile - */ - get helpText(): string { - return this.scriptObject['KH']; + return tag; + } + + get explodedStores(): {[storeName: string]: ComplexKeyboardStore} { + return this.cacheTag.stores; + } + + /** + * Signifies whether or not a layout or OSK should include AltGr / Right-alt emulation for this keyboard. + * @param {Object=} keyLabels + * @return {boolean} + */ + get emulatesAltGr(): boolean { + let modifierCodes = Codes.modifierCodes; + + // If we're not chiral, we're not emulating. + if(!this.isChiral) { + return false; } - /** - * Embedded JS script designed for use with a keyboard's HTML help text. Always defined - * within the file referenced by &kmw_embedjs in a keyboard's source, though that file - * may also contain _other_ script definitions as well. (`KHF` must be explicitly defined - * within that file.) - */ - get hasScript(): boolean { - return !!this.scriptObject['KHF']; + if(this._legacyLayoutSpec == null) { + return false; } - /** - * Embeds a custom script for use by the OSK, which may be interactive (like with sil_euro_latin). - * Note: this must be called AFTER any contents of `helpText` have been inserted into the DOM. - * (See sil_euro_latin's source -> sil_euro_latin_js.txt) - * - * Reference: https://help.keyman.com/developer/language/reference/kmw_embedjs - */ - embedScript(e: any) { - // e: Expects the OSKManager's _Box element. We don't add type info here b/c it would - // reference the DOM. - this.scriptObject['KHF'](e); + // Only exists in KMW 10.0+, but before that Web had no chirality support, so... return false. + let layers = this._legacyLayoutSpec['KLS']; + if(!layers) { + return false; } - get oskStyling(): string { - return this.scriptObject['KCSS']; + var emulationMask = modifierCodes['LCTRL'] | modifierCodes['LALT']; + var unshiftedEmulationLayer = layers[Layouts.getLayerId(emulationMask)]; + var shiftedEmulationLayer = layers[Layouts.getLayerId(modifierCodes['SHIFT'] | emulationMask)]; + + // buildDefaultLayout ensures that these are aliased to the original modifier set being emulated. + // As a result, we can directly test for reference equality. + // + // This allows us to still return `true` after creating the layers for emulation; during keyboard + // construction, the two layers should be null for AltGr emulation to succeed. + if(unshiftedEmulationLayer != null && + unshiftedEmulationLayer != layers[Layouts.getLayerId(modifierCodes['RALT'])]) { + return false; } - /** - * true if this keyboard uses a (legacy) pick list (Chinese, Japanese, Korean, etc.) - * - * TODO: Make a property on keyboards (say, `isPickList` / `KPL`) to signal this when we - * get around to better, generalized picker-list support. - */ - get isCJK(): boolean { // I3363 (Build 301) - var lg: string; - if(typeof(this.scriptObject['KLC']) != 'undefined') { - lg = this.scriptObject['KLC']; - } else if(typeof(this.scriptObject['LanguageCode']) != 'undefined') { - lg = this.scriptObject['LanguageCode']; - } + if(shiftedEmulationLayer != null && + shiftedEmulationLayer != layers[Layouts.getLayerId(modifierCodes['RALT'] | modifierCodes['SHIFT'])]) { + return false; + } - // While some of these aren't proper BCP-47 language codes, the CJK keyboards predate our use of BCP-47. - // So, we preserve the old ISO 639-3 codes, as that's what the keyboards are matching against. - return ((lg == 'cmn') || (lg == 'jpn') || (lg == 'kor')); + // It's technically possible for the OSK to not specify anything while allowing chiral input. A last-ditch catch: + var bitmask = this.modifierBitmask; + if((bitmask & emulationMask) != emulationMask) { + // At least one of the emulation modifiers is never used by the keyboard! We can confirm everything's safe. + return true; } - get isRTL(): boolean { - return !!this.scriptObject['KRTL']; + if(unshiftedEmulationLayer == null && shiftedEmulationLayer == null) { + // We've run out of things to go on; we can't detect if chiral AltGr emulation is intended or not. + // TODO: handle this again! + // if(!osk.altGrWarning) { + // console.warn("Could not detect if AltGr emulation is safe, but defaulting to active emulation!") + // // Avoid spamming the console with warnings on every call of the method. + // osk.altGrWarning = true; + // } + return true; } + return true; + } - /** - * Obtains the currently-active modifier bitmask for the active keyboard. - */ - get modifierBitmask(): number { - // NON_CHIRAL is the default bitmask if KMBM is not defined. - // We always need a bitmask to compare against, as seen in `isChiral`. - return this.scriptObject['KMBM'] || text.Codes.modifierBitmasks['NON_CHIRAL']; + get usesSupplementaryPlaneChars(): boolean { + let kbd = this.scriptObject; + // I3319 - SMP extension, I3363 (Build 301) + return kbd && ((kbd['KS'] && kbd['KS'] == 1) || kbd['KN'] == 'Hieroglyphic'); + } + + usesDesktopLayoutOnDevice(device: DeviceSpec) { + if(this.scriptObject['KVKL']) { + // A custom mobile layout is defined... but are we using it? + return device.formFactor == DeviceSpec.FormFactor.Desktop; + } else { + return true; } + } - get isChiral(): boolean { - return !!(this.modifierBitmask & text.Codes.modifierBitmasks['IS_CHIRAL']); + /** + * @param {number} _PCommand event code (16,17,18) or 0 + * @param {Object} _PTarget target element + * @param {number} _PData 1 or 0 + * Notifies keyboard of keystroke or other event + */ + notify(_PCommand: number, _PTarget: OutputTarget, _PData: number) { // I2187 + // Good example use case - the Japanese CJK-picker keyboard + if(typeof(this.scriptObject['KNS']) == 'function') { + this.scriptObject['KNS'](_PCommand, _PTarget, _PData); } + } - get desktopFont(): string { - if(this.scriptObject['KV']) { - return this.scriptObject['KV']['F']; - } else { - return null; + private findOrConstructLayout(formFactor: DeviceSpec.FormFactor): LayoutFormFactor { + if(this._layouts) { + // Search for viable layouts. `null` is allowed for desktop form factors when help text is available, + // so we check explicitly against `undefined`. + if(this._layouts[formFactor] !== undefined) { + return this._layouts[formFactor]; + } else if(formFactor == DeviceSpec.FormFactor.Phone && this._layouts[DeviceSpec.FormFactor.Tablet]) { + return this._layouts[DeviceSpec.FormFactor.Phone] = this._layouts[DeviceSpec.FormFactor.Tablet]; + } else if(formFactor == DeviceSpec.FormFactor.Tablet && this._layouts[DeviceSpec.FormFactor.Phone]) { + return this._layouts[DeviceSpec.FormFactor.Tablet] = this._layouts[DeviceSpec.FormFactor.Phone]; } } - private get cacheTag(): CacheTag { - let tag = this.scriptObject['_kmw']; - - if(!tag) { - tag = new CacheTag(); - this.scriptObject['_kmw'] = tag; + // No pre-built layout available; time to start constructing it via defaults. + // First, if we have non-default keys specified by the ['BK'] array, we've got + // enough to work with to build a default layout. + let rawSpecifications: any = null; // TODO: better typing, same type as this._legacyLayoutSpec. + if(this._legacyLayoutSpec != null && this._legacyLayoutSpec['KLS']) { // KLS is only specified whenever there are non-default keys. + rawSpecifications = this._legacyLayoutSpec; + } else if(this._legacyLayoutSpec != null && this._legacyLayoutSpec['BK'] != null) { + var keyCaps=this._legacyLayoutSpec['BK']; + for(var i=0; i 0) { + rawSpecifications = this._legacyLayoutSpec; + break; + } } + } - return tag; + // If we don't have key definitions to use for a layout but also lack help text or are a touch-based layout, + // we make a default layout anyway. We have to show display something usable. + if(!rawSpecifications && (this.helpText == '' || formFactor != DeviceSpec.FormFactor.Desktop)) { + rawSpecifications = {'F':'Tahoma', 'BK': Layouts.dfltText}; } - get explodedStores(): {[storeName: string]: text.ComplexKeyboardStore} { - return this.cacheTag.stores; + // Regardless of success, we'll want to initialize the field that backs the property; + // may as well cache the default layout we just built, or a 'null' if it shouldn't exist.. + if(!this._layouts) { + this._layouts = {}; } - /** - * Signifies whether or not a layout or OSK should include AltGr / Right-alt emulation for this keyboard. - * @param {Object=} keyLabels - * @return {boolean} - */ - get emulatesAltGr(): boolean { - let modifierCodes = text.Codes.modifierCodes; - - // If we're not chiral, we're not emulating. - if(!this.isChiral) { - return false; - } + // Final check - do we construct a layout, or is this a case where helpText / insertHelpHTML should take over? + if(rawSpecifications) { + // Now to generate a layout from our raw specifications. + let layout = this._layouts[formFactor] = Layouts.buildDefaultLayout(rawSpecifications, this, formFactor); + layout.isDefault = true; + return layout; + } else { + // The fact that it doesn't exist will indicate that help text/HTML should be inserted instead. + this._layouts[formFactor] = null; // provides a cached value for the check at the top of this method. + return null; + } + } - if(this._legacyLayoutSpec == null) { - return false; + /** + * Returns an ActiveLayout object representing the keyboard's layout for this form factor. May return null if a custom desktop "help" OSK is defined, as with sil_euro_latin. + * + * In such cases, please use either `helpText` or `insertHelpHTML` instead. + * @param formFactor {string} The desired form factor for the layout. + */ + public layout(formFactor: DeviceSpec.FormFactor): ActiveLayout { + let rawLayout = this.findOrConstructLayout(formFactor); + + if(rawLayout) { + // Prevents accidentally reprocessing layouts; it's a simple enough check. + if(this.layoutStates[formFactor] == LayoutState.NOT_LOADED) { + rawLayout = ActiveLayout.polyfill(rawLayout, this, formFactor); + this.layoutStates[formFactor] = LayoutState.POLYFILLED; } - // Only exists in KMW 10.0+, but before that Web had no chirality support, so... return false. - let layers = this._legacyLayoutSpec['KLS']; - if(!layers) { - return false; - } + return rawLayout as ActiveLayout; + } else { + return null; + } + } - var emulationMask = modifierCodes['LCTRL'] | modifierCodes['LALT']; - var unshiftedEmulationLayer = layers[Layouts.getLayerId(emulationMask)]; - var shiftedEmulationLayer = layers[Layouts.getLayerId(modifierCodes['SHIFT'] | emulationMask)]; - - // buildDefaultLayout ensures that these are aliased to the original modifier set being emulated. - // As a result, we can directly test for reference equality. - // - // This allows us to still return `true` after creating the layers for emulation; during keyboard - // construction, the two layers should be null for AltGr emulation to succeed. - if(unshiftedEmulationLayer != null && - unshiftedEmulationLayer != layers[Layouts.getLayerId(modifierCodes['RALT'])]) { - return false; - } + public refreshLayouts() { + let formFactors = [ DeviceSpec.FormFactor.Desktop, DeviceSpec.FormFactor.Phone, DeviceSpec.FormFactor.Tablet ]; - if(shiftedEmulationLayer != null && - shiftedEmulationLayer != layers[Layouts.getLayerId(modifierCodes['RALT'] | modifierCodes['SHIFT'])]) { - return false; - } + let _this = this; - // It's technically possible for the OSK to not specify anything while allowing chiral input. A last-ditch catch: - var bitmask = this.modifierBitmask; - if((bitmask & emulationMask) != emulationMask) { - // At least one of the emulation modifiers is never used by the keyboard! We can confirm everything's safe. - return true; - } + formFactors.forEach(function(form) { + // Currently doesn't work if we reset it to POLYFILLED, likely due to how 'calibration' + // currently works. + _this.layoutStates[form] = LayoutState.NOT_LOADED; + }); + } - if(unshiftedEmulationLayer == null && shiftedEmulationLayer == null) { - // We've run out of things to go on; we can't detect if chiral AltGr emulation is intended or not. - // TODO: handle this again! - // if(!osk.altGrWarning) { - // console.warn("Could not detect if AltGr emulation is safe, but defaulting to active emulation!") - // // Avoid spamming the console with warnings on every call of the method. - // osk.altGrWarning = true; - // } - return true; - } - return true; + public markLayoutCalibrated(formFactor: DeviceSpec.FormFactor) { + if(this.layoutStates[formFactor] != LayoutState.NOT_LOADED) { + this.layoutStates[formFactor] = LayoutState.CALIBRATED; } + } - get usesSupplementaryPlaneChars(): boolean { - let kbd = this.scriptObject; - // I3319 - SMP extension, I3363 (Build 301) - return kbd && ((kbd['KS'] && kbd['KS'] == 1) || kbd['KN'] == 'Hieroglyphic'); - } + public getLayoutState(formFactor: DeviceSpec.FormFactor) { + return this.layoutStates[formFactor]; + } - usesDesktopLayoutOnDevice(device: utils.DeviceSpec) { - if(this.scriptObject['KVKL']) { - // A custom mobile layout is defined... but are we using it? - return device.formFactor == utils.FormFactor.Desktop; - } else { - return true; - } - } - /** - * @param {number} _PCommand event code (16,17,18) or 0 - * @param {Object} _PTarget target element - * @param {number} _PData 1 or 0 - * Notifies keyboard of keystroke or other event - */ - notify(_PCommand: number, _PTarget: text.OutputTarget, _PData: number) { // I2187 - // Good example use case - the Japanese CJK-picker keyboard - if(typeof(this.scriptObject['KNS']) == 'function') { - this.scriptObject['KNS'](_PCommand, _PTarget, _PData); - } + constructNullKeyEvent(device: DeviceSpec, stateKeys?: StateKeyMap): KeyEvent { + stateKeys = stateKeys || { + K_CAPS: false, + K_NUMLOCK: false, + K_SCROLL: false } - private findOrConstructLayout(formFactor: utils.FormFactor): LayoutFormFactor { - if(this._layouts) { - // Search for viable layouts. `null` is allowed for desktop form factors when help text is available, - // so we check explicitly against `undefined`. - if(this._layouts[formFactor] !== undefined) { - return this._layouts[formFactor]; - } else if(formFactor == utils.FormFactor.Phone && this._layouts[utils.FormFactor.Tablet]) { - return this._layouts[utils.FormFactor.Phone] = this._layouts[utils.FormFactor.Tablet]; - } else if(formFactor == utils.FormFactor.Tablet && this._layouts[utils.FormFactor.Phone]) { - return this._layouts[utils.FormFactor.Tablet] = this._layouts[utils.FormFactor.Phone]; - } - } + const keyEvent = KeyEvent.constructNullKeyEvent(device); + this.setSyntheticEventDefaults(keyEvent, stateKeys); + return keyEvent; + } - // No pre-built layout available; time to start constructing it via defaults. - // First, if we have non-default keys specified by the ['BK'] array, we've got - // enough to work with to build a default layout. - let rawSpecifications: any = null; // TODO: better typing, same type as this._legacyLayoutSpec. - if(this._legacyLayoutSpec != null && this._legacyLayoutSpec['KLS']) { // KLS is only specified whenever there are non-default keys. - rawSpecifications = this._legacyLayoutSpec; - } else if(this._legacyLayoutSpec != null && this._legacyLayoutSpec['BK'] != null) { - var keyCaps=this._legacyLayoutSpec['BK']; - for(var i=0; i 0) { - rawSpecifications = this._legacyLayoutSpec; - break; - } - } - } + constructKeyEvent(key: ActiveKey, device: DeviceSpec, stateKeys: StateKeyMap): KeyEvent { + // Make a deep copy of our preconstructed key event, filling it out from there. + const Lkc = key.baseKeyEvent; + Lkc.device = device; - // If we don't have key definitions to use for a layout but also lack help text or are a touch-based layout, - // we make a default layout anyway. We have to show display something usable. - if(!rawSpecifications && (this.helpText == '' || formFactor != utils.FormFactor.Desktop)) { - rawSpecifications = {'F':'Tahoma', 'BK': Layouts.dfltText}; - } + if(this.isMnemonic) { + Lkc.setMnemonicCode(key.layer.indexOf('shift') != -1, stateKeys['K_CAPS']); + } - // Regardless of success, we'll want to initialize the field that backs the property; - // may as well cache the default layout we just built, or a 'null' if it shouldn't exist.. - if(!this._layouts) { - this._layouts = {}; - } + // Performs common pre-analysis for both 'native' and 'embedded' OSK key & subkey input events. + // This part depends on the keyboard processor's active state. + this.setSyntheticEventDefaults(Lkc, stateKeys); - // Final check - do we construct a layout, or is this a case where helpText / insertHelpHTML should take over? - if(rawSpecifications) { - // Now to generate a layout from our raw specifications. - let layout = this._layouts[formFactor] = Layouts.buildDefaultLayout(rawSpecifications, this, formFactor); - layout.isDefault = true; - return layout; - } else { - // The fact that it doesn't exist will indicate that help text/HTML should be inserted instead. - this._layouts[formFactor] = null; // provides a cached value for the check at the top of this method. - return null; - } + // If it's a state key modifier, trigger its effects as part of the + // keystroke. + const bitmap = { + 'K_CAPS': Codes.stateBitmasks.CAPS, + 'K_NUMLOCK': Codes.stateBitmasks.NUM_LOCK, + 'K_SCROLL': Codes.stateBitmasks.SCROLL_LOCK + }; + const bitmask = bitmap[Lkc.kName]; + + if(bitmask) { + Lkc.Lstates ^= bitmask; + Lkc.LmodifierChange = true; } - /** - * Returns an ActiveLayout object representing the keyboard's layout for this form factor. May return null if a custom desktop "help" OSK is defined, as with sil_euro_latin. - * - * In such cases, please use either `helpText` or `insertHelpHTML` instead. - * @param formFactor {string} The desired form factor for the layout. - */ - public layout(formFactor: utils.FormFactor): ActiveLayout { - let rawLayout = this.findOrConstructLayout(formFactor); - - if(rawLayout) { - // Prevents accidentally reprocessing layouts; it's a simple enough check. - if(this.layoutStates[formFactor] == LayoutState.NOT_LOADED) { - rawLayout = ActiveLayout.polyfill(rawLayout, this, formFactor); - this.layoutStates[formFactor] = LayoutState.POLYFILLED; - } + return Lkc; + } - return rawLayout as ActiveLayout; - } else { - return null; + setSyntheticEventDefaults(Lkc: KeyEvent, stateKeys: StateKeyMap) { + // Set the flags for the state keys - for desktop devices. For touch + // devices, the only state key in use currently is Caps Lock, which is set + // when the 'caps' layer is active in ActiveKey::constructBaseKeyEvent. + if(!Lkc.device.touchable) { + Lkc.Lstates |= stateKeys['K_CAPS'] ? Codes.modifierCodes['CAPS'] : Codes.modifierCodes['NO_CAPS']; + Lkc.Lstates |= stateKeys['K_NUMLOCK'] ? Codes.modifierCodes['NUM_LOCK'] : Codes.modifierCodes['NO_NUM_LOCK']; + Lkc.Lstates |= stateKeys['K_SCROLL'] ? Codes.modifierCodes['SCROLL_LOCK'] : Codes.modifierCodes['NO_SCROLL_LOCK']; + } + + // Set LisVirtualKey to false to ensure that nomatch rule does fire for U_xxxx keys + if(Lkc.kName && Lkc.kName.substr(0,2) == 'U_') { + Lkc.LisVirtualKey=false; + } + + // Get code for non-physical keys (T_KOKAI, U_05AB etc) + if(typeof Lkc.Lcode == 'undefined') { + Lkc.Lcode = this.getVKDictionaryCode(Lkc.kName);// Updated for Build 347 + if(!Lkc.Lcode) { + // Special case for U_xxxx keys. This vk code will never be used + // in a keyboard, so we use this to ensure that keystroke processing + // occurs for the key. + Lkc.Lcode = 1; } } - public refreshLayouts() { - let formFactors = [ utils.FormFactor.Desktop, utils.FormFactor.Phone, utils.FormFactor.Tablet ]; - - let _this = this; - - formFactors.forEach(function(form) { - // Currently doesn't work if we reset it to POLYFILLED, likely due to how 'calibration' - // currently works. - _this.layoutStates[form] = LayoutState.NOT_LOADED; - }); + // Handles modifier states when the OSK is emulating rightalt through the leftctrl-leftalt layer. + if((Lkc.Lmodifiers & Codes.modifierBitmasks['ALT_GR_SIM']) == Codes.modifierBitmasks['ALT_GR_SIM'] && this.emulatesAltGr) { + Lkc.Lmodifiers &= ~Codes.modifierBitmasks['ALT_GR_SIM']; + Lkc.Lmodifiers |= Codes.modifierCodes['RALT']; } + } - public markLayoutCalibrated(formFactor: utils.FormFactor) { - if(this.layoutStates[formFactor] != LayoutState.NOT_LOADED) { - this.layoutStates[formFactor] = LayoutState.CALIBRATED; + /** + * @summary Look up a custom virtual key code in the virtual key code dictionary KVKD. + * On first run, will build the dictionary. + * + * `VKDictionary` is constructed from the keyboard's `KVKD` member. This list is constructed + * at compile-time and is a list of 'additional' virtual key codes, starting at 256 (i.e. + * outside the range of standard virtual key codes). These additional codes are both + * `[T_xxx]` and `[U_xxxx]` custom key codes from the Keyman keyboard language. However, + * `[U_xxxx]` keys only generate an entry in `KVKD` if there is a corresponding rule that + * is associated with them in the keyboard rules. If the `[U_xxxx]` key code is only + * referenced as the id of a key in the touch layout, then it does not get an entry in + * the `KVKD` property. + * + * @private + * @param {string} keyName custom virtual key code to lookup in the dictionary + * @return {number} key code > 255 on success, or 0 if not found + */ + getVKDictionaryCode(keyName: string) { + if(!this.scriptObject['VKDictionary']) { + const a=[]; + if(typeof this.scriptObject['KVKD'] == 'string') { + // Build the VK dictionary + // TODO: Move the dictionary build into the compiler -- so compiler generates code such as following. + // Makes the VKDictionary member unnecessary. + // this.KVKD={"K_ABC":256,"K_DEF":257,...}; + const s=this.scriptObject['KVKD'].split(' '); + for(var i=0; i { + this.harness.install(); + const promise = this.loadKeyboardInternal(uri); + + return promise; + } + + protected abstract loadKeyboardInternal(uri: string): Promise; +} \ No newline at end of file diff --git a/common/web/keyboard-processor/src/keyboards/keyboardProperties.ts b/common/web/keyboard-processor/src/keyboards/keyboardProperties.ts new file mode 100644 index 0000000000..eda68a3aa7 --- /dev/null +++ b/common/web/keyboard-processor/src/keyboards/keyboardProperties.ts @@ -0,0 +1,251 @@ +import SpacebarText from './spacebarText.js'; + +export interface InternalKeyboardFont { + family: string; + filename?: never; + files: string | string[]; // internal + source?: never; + path: string; +} + +interface CloudKeyboardFont1 { + family: string; + filename: string | string[]; + files?: never; + source?: never; +} + +interface CloudKeyboardFont2 { + family: string; + filename?: never; + files?: never; + source: string | string[]; +} + +export type CloudKeyboardFont = CloudKeyboardFont1 | CloudKeyboardFont2; + +/** + * Converts one of three public-facing font-specification formats into a consistent structure + * used generally among the Keyman JS/TS modules. + * @param fontObj + * @param fontPath + * @returns + */ +export function internalizeFont(fontObj: CloudKeyboardFont, fontPath: string): InternalKeyboardFont { + if(!fontObj) { + return undefined; + } else { + return { + family: fontObj.family, + path: fontPath, + files: fontObj.filename || fontObj.source + } + } +} + +export type KeyboardFont = CloudKeyboardFont | InternalKeyboardFont; + +// Filename properties are deliberately omitted here; we can add that at higher-levels where it matters +// via 'mix-in'. +// +// For example, the OSK module doesn't care about the filename of a loaded keyboard. It doesn't do +// keyboard loading on its own whatsoever. + +// Corresponds to Keyman Engine for Web's internal "keyboard stub" format. +// Also referred to by KMW 2.0-era loaders: https://help.keyman.com/developer/8.0/docs/reference_kmw20_example +export interface KeyboardInternalPropertySpec { + KI: string, + KFont: InternalKeyboardFont, + KOskFont: InternalKeyboardFont, + displayName?: string, + KN?: string, + KL?: string, + KLC?: string +}; + +export type LanguageAPIPropertySpec = { + id: string, + name: string, + font: CloudKeyboardFont, + oskFont: CloudKeyboardFont, + region?: number|string +} + +/** + * Corresponds to the documented API for the Web engine's `addKeyboards` function + * when a single language object is specified - not an array. + * + * See https://help.keyman.com/DEVELOPER/ENGINE/WEB/15.0/reference/core/addKeyboards, + * "Using an `object`". + */ +export type KeyboardAPIPropertySpec = { + id: string, + name: string, + + /** + * @deprecated Replaced with `languages`. + */ + language?: LanguageAPIPropertySpec; + languages: LanguageAPIPropertySpec; +} + +/** + * Corresponds to the documented API for the Web engine's `addKeyboards` function + * when a language array is specified for the object. + * + * See https://help.keyman.com/DEVELOPER/ENGINE/WEB/15.0/reference/core/addKeyboards, + * "Using an `object`". + */ +export type KeyboardAPIPropertyMultilangSpec = { + id: string, + name: string, + + /** + * @deprecated Replaced with `languages`. + */ + language?: LanguageAPIPropertySpec[]; + languages: LanguageAPIPropertySpec[]; +} + +export type MetadataObj = KeyboardInternalPropertySpec | KeyboardAPIPropertySpec | KeyboardAPIPropertyMultilangSpec; + +export default class KeyboardProperties implements KeyboardInternalPropertySpec { + KI: string; + KN: string; + KL: string; + KLC: string; + KFont: InternalKeyboardFont; + KOskFont: InternalKeyboardFont; + _displayName?: string; + + public spacebarTextMode: SpacebarText; + + public constructor(metadataObj: MetadataObj, fontPath?: string); + public constructor(keyboardId: string, languageCode: string); + public constructor(arg1: MetadataObj | string, arg2?: string | SpacebarText) { + if(!(typeof arg1 == 'string')) { + if(arg1['KI'] || arg1['KL'] || arg1['KLC'] || arg1['KFont'] || arg1['KOskFont']) { + const other = arg1 as KeyboardInternalPropertySpec; + this.KI = other.KI; + this.KN = other.KN; + this.KL = other.KL; + this.KLC = other.KLC; + this.KFont = other.KFont; + this.KOskFont = other.KOskFont; + this._displayName = (other instanceof KeyboardProperties) ? other._displayName : other.displayName; + } else { + let apiStub = arg1 as KeyboardAPIPropertySpec; // TODO: could be an array, as currently specified. :( + + apiStub.languages ||= apiStub.language; + + this.KI = apiStub.id, + this.KN = apiStub.name, + this.KL = apiStub.languages.name, + this.KLC = apiStub.languages.id, + this.KFont = internalizeFont(apiStub.languages.font, arg2), + this.KOskFont = internalizeFont(apiStub.languages.oskFont, arg2) + } + } else { + this.KI = arg1; + this.KLC = arg2; + } + } + + public static fromMultilanguageAPIStub(apiStub: KeyboardAPIPropertyMultilangSpec, spacebarTextMode?: SpacebarText): KeyboardProperties[] { + let stubs: KeyboardProperties[] = []; + + apiStub.languages ||= apiStub.language; + + for(let langSpec of apiStub.languages) { + let stub: KeyboardAPIPropertySpec = { + id: apiStub.id, + name: apiStub.name, + languages: langSpec + }; + + stubs.push(new KeyboardProperties(stub, spacebarTextMode)); + } + + return stubs; + } + + public get id(): string { + return this.KI; + } + + public get name(): string { + return this.KN; + } + + public get langId(): string { + return this.KLC; + } + + public get langName(): string { + return this.KL; + } + + public get displayName(): string { + if(this._displayName) { + return this._displayName; + } + + // else, construct it. + const kbdName = this.KN; + const lgName = this.KL; + + switch (this.spacebarTextMode) { + case SpacebarText.KEYBOARD: + return kbdName; + case SpacebarText.LANGUAGE: + return lgName; + case SpacebarText.LANGUAGE_KEYBOARD: + return (kbdName == lgName) ? lgName : lgName + ' - ' + kbdName; + case SpacebarText.BLANK: + return ''; + default: + return kbdName; + } + } + + public set displayName(name: string) { + this._displayName = name; + } + + public get textFont() { + return this.KFont; + } + + public get oskFont() { + return this.KOskFont; + } + + /** + * Generates an error for objects with specification levels insufficient for use in the on-screen-keyboard + * module, complete with a message about one or more details in need of correction. + * @returns A preconstructed `Error` instance that may be thrown by the caller. + */ + public validateForOSK(): Error { + if(!this.KLC) { + if(this.KI || this.KN) { + return new Error(`No language code was specified for use with the ${this.KI || this.KN} keyboard`); + } else { + return new Error("No language code was specified for use with the corresponding keyboard") + } + } + + if(this.displayName === undefined || (this.spacebarTextMode != SpacebarText.BLANK && !this.displayName)) { + return new Error("A display name is missing for this keyboard and cannot be generated.") + } + + return null; + } + + public validateForCustomKeyboard(): Error { + if(!this.KI || !this.KN || !this.KL || !this.KLC) { + return new Error("To use a custom keyboard, you must specify keyboard id, keyboard name, language and language code."); + } else { + return null; + } + } +} \ No newline at end of file diff --git a/common/web/keyboard-processor/src/keyboards/loaders/dom-keyboard-loader.ts b/common/web/keyboard-processor/src/keyboards/loaders/dom-keyboard-loader.ts new file mode 100644 index 0000000000..989d5b6477 --- /dev/null +++ b/common/web/keyboard-processor/src/keyboards/loaders/dom-keyboard-loader.ts @@ -0,0 +1 @@ +export * from './domKeyboardLoader.js'; \ No newline at end of file diff --git a/common/web/keyboard-processor/src/keyboards/loaders/domKeyboardLoader.ts b/common/web/keyboard-processor/src/keyboards/loaders/domKeyboardLoader.ts new file mode 100644 index 0000000000..08dbac38ce --- /dev/null +++ b/common/web/keyboard-processor/src/keyboards/loaders/domKeyboardLoader.ts @@ -0,0 +1,73 @@ +// Enables DOM types, but just for this one module. + +/// + +import { Keyboard, KeyboardHarness, KeyboardLoaderBase, MinimalKeymanGlobal } from '@keymanapp/keyboard-processor'; + +import { ManagedPromise } from '@keymanapp/web-utils'; + +export class DOMKeyboardLoader extends KeyboardLoaderBase { + public readonly element: HTMLIFrameElement; + private readonly performCacheBusting: boolean; + + constructor() + constructor(harness: KeyboardHarness); + constructor(harness: KeyboardHarness, cacheBust?: boolean) + constructor(harness?: KeyboardHarness, cacheBust?: boolean) { + if(harness && harness._jsGlobal != window) { + // Copy the String typing over; preserve string extensions! + harness._jsGlobal['String'] = window['String']; + } + + if(!harness) { + super(new KeyboardHarness(window, MinimalKeymanGlobal)); + } else { + super(harness); + } + + this.performCacheBusting = cacheBust || false; + } + + protected loadKeyboardInternal(uri: string): Promise { + const promise = new ManagedPromise(); + + if(this.performCacheBusting) { + uri = this.cacheBust(uri); + } + + try { + const document = this.harness._jsGlobal.document; + const script = document.createElement('script'); + document.head.appendChild(script); + script.onerror = promise.reject; + script.onload = () => { + if(this.harness.loadedKeyboard) { + const keyboard = this.harness.loadedKeyboard; + this.harness.loadedKeyboard = null; + promise.resolve(keyboard); + } else { + promise.reject(); + } + } + + promise.finally(() => { + // It is safe to remove the script once it has been run (https://stackoverflow.com/a/37393041) + script.remove(); + }); + + // Now that EVERYTHING ELSE is ready, establish the link to the keyboard's script. + script.src = uri; + } catch (err) { + return Promise.reject(err); + } + + return promise.corePromise; + } + + private cacheBust(uri: string) { + // Our WebView version directly sets the keyboard path, and it may replace the file + // after KMW has loaded. We need cache-busting to prevent the new version from + // being ignored. + return uri + "?v=" + (new Date()).getTime(); /*cache buster*/ + } +} \ No newline at end of file diff --git a/common/web/keyboard-processor/src/keyboards/loaders/node-keyboard-loader.ts b/common/web/keyboard-processor/src/keyboards/loaders/node-keyboard-loader.ts new file mode 100644 index 0000000000..9630e113b0 --- /dev/null +++ b/common/web/keyboard-processor/src/keyboards/loaders/node-keyboard-loader.ts @@ -0,0 +1 @@ +export * from './nodeKeyboardLoader.js'; \ No newline at end of file diff --git a/common/web/keyboard-processor/src/keyboards/loaders/nodeKeyboardLoader.ts b/common/web/keyboard-processor/src/keyboards/loaders/nodeKeyboardLoader.ts new file mode 100644 index 0000000000..6f7e7fa196 --- /dev/null +++ b/common/web/keyboard-processor/src/keyboards/loaders/nodeKeyboardLoader.ts @@ -0,0 +1,41 @@ +import { Keyboard, KeyboardHarness, KeyboardLoaderBase, MinimalKeymanGlobal } from '@keymanapp/keyboard-processor'; + +import vm from 'vm'; +import fs from 'fs'; +import { globalObject } from '@keymanapp/web-utils'; + +export class NodeKeyboardLoader extends KeyboardLoaderBase { + constructor() + constructor(harness: KeyboardHarness); + constructor(harness?: KeyboardHarness) { + if(!harness) { + super(new KeyboardHarness(vm.createContext(), MinimalKeymanGlobal)); + } else { + // If we're going to sandbox, make sure the sandbox is sufficient! + if(globalObject() != harness._jsGlobal) { + vm.createContext(harness._jsGlobal); + // Ensure any and all string extensions are available within the sandbox context! + harness._jsGlobal.String = globalObject().String; + } + + super(harness); + } + } + + protected loadKeyboardInternal(uri: string): Promise { + try { + // `fs` does not like 'file:///'; it IS "File System" oriented, after all, and wants a path, not a URI. + if(uri.indexOf('file:///') == 0) { + uri = uri.substring('file:///'.length); + } + const script = new vm.Script(fs.readFileSync(uri).toString()); + script.runInContext(this.harness._jsGlobal); + } catch (err) { + return Promise.reject(err); + } + + const keyboard = this.harness.loadedKeyboard; + this.harness.loadedKeyboard = null; + return Promise.resolve(keyboard); + } +} \ No newline at end of file diff --git a/common/web/keyboard-processor/src/keyboards/loaders/tsconfig.dom.json b/common/web/keyboard-processor/src/keyboards/loaders/tsconfig.dom.json new file mode 100644 index 0000000000..5120d36a6c --- /dev/null +++ b/common/web/keyboard-processor/src/keyboards/loaders/tsconfig.dom.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig-base.json", + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "module": "es6", + "moduleResolution": "Node16", + "declaration": true, + "inlineSources": true, + "sourceMap": true, + "sourceRoot": "keyman/", + "target": "es5", + "types": ["node"], + "lib": ["es6"], + "experimentalDecorators": true, + "baseUrl": "../../../", + "outDir": "../../../build/obj/keyboards/loaders/", + "tsBuildInfoFile": "../../../build/obj/keyboards/loaders/tsconfig.dom.tsbuildinfo", + "rootDir": "." + }, + "references": [ + { "path": "../../../tsconfig.common.json" } + ], + "include": ["dom-keyboard-loader.ts", "domKeyboardLoader.ts"], +} diff --git a/common/web/keyboard-processor/src/keyboards/loaders/tsconfig.node.json b/common/web/keyboard-processor/src/keyboards/loaders/tsconfig.node.json new file mode 100644 index 0000000000..7175be8333 --- /dev/null +++ b/common/web/keyboard-processor/src/keyboards/loaders/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig-base.json", + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "module": "es6", + "moduleResolution": "Node16", + "declaration": true, + "inlineSources": true, + "sourceMap": true, + "sourceRoot": "keyman/", + "target": "es5", + "types": ["node"], + "lib": ["es6"], + "experimentalDecorators": true, + "baseUrl": "../../../", + "outDir": "../../../build/obj/keyboards/loaders/", + "tsBuildInfoFile": "../../../build/obj/keyboards/loaders/tsconfig.node.tsbuildinfo", + "rootDir": "." + }, + "references": [ + { "path": "../../../tsconfig.common.json" } + ], + "include": ["node-keyboard-loader.ts", "nodeKeyboardLoader.ts"], +} diff --git a/common/web/keyboard-processor/src/keyboards/spacebarText.ts b/common/web/keyboard-processor/src/keyboards/spacebarText.ts new file mode 100644 index 0000000000..d98e7b6462 --- /dev/null +++ b/common/web/keyboard-processor/src/keyboards/spacebarText.ts @@ -0,0 +1,9 @@ +// Compiles completely out if `const enum`, making it unavailable in JS-based unit tests. +enum SpacebarText { + KEYBOARD = 'keyboard', + LANGUAGE = 'language', + LANGUAGE_KEYBOARD = 'languageKeyboard', + BLANK = 'blank' +}; + +export default SpacebarText; \ No newline at end of file diff --git a/common/web/keyboard-processor/src/keyboards/stateKeyMap.ts b/common/web/keyboard-processor/src/keyboards/stateKeyMap.ts new file mode 100644 index 0000000000..c8e9ee8b76 --- /dev/null +++ b/common/web/keyboard-processor/src/keyboards/stateKeyMap.ts @@ -0,0 +1,9 @@ +/** + * Provided by @keymanapp/keyboard-processor's `KeyboardProcessor` class and utilized by the OSK + * to provide state feedback on any corresponding keys visible in the OSK. + */ +export default interface StateKeyMap { + K_CAPS: boolean, + K_NUMLOCK: boolean, + K_SCROLL: boolean +} \ No newline at end of file diff --git a/common/web/keyboard-processor/src/keyboards/test.ts b/common/web/keyboard-processor/src/keyboards/test.ts new file mode 100644 index 0000000000..65d3beae0c --- /dev/null +++ b/common/web/keyboard-processor/src/keyboards/test.ts @@ -0,0 +1,5 @@ +// This specific module is allowed to reference the DOM in order to facilitate loading the keyboards +// into the DOM. + +/// + diff --git a/common/web/keyboard-processor/src/text/codes.ts b/common/web/keyboard-processor/src/text/codes.ts index 48fde0d728..5bbd26f512 100644 --- a/common/web/keyboard-processor/src/text/codes.ts +++ b/common/web/keyboard-processor/src/text/codes.ts @@ -1,103 +1,169 @@ -namespace com.keyman.text { - export var Codes = { - // Define Keyman Developer modifier bit-flags (exposed for use by other modules) - // Compare against /common/include/kmx_file.h. CTRL+F "#define LCTRLFLAG" to find the secton. - modifierCodes: { - "LCTRL":0x0001, // LCTRLFLAG - "RCTRL":0x0002, // RCTRLFLAG - "LALT":0x0004, // LALTFLAG - "RALT":0x0008, // RALTFLAG - "SHIFT":0x0010, // K_SHIFTFLAG - "CTRL":0x0020, // K_CTRLFLAG - "ALT":0x0040, // K_ALTFLAG - // TENTATIVE: Represents command keys, which some OSes use for shortcuts we don't - // want to block. No rule will ever target a modifier set with this bit set to 1. - "META":0x0080, // K_METAFLAG - "CAPS":0x0100, // CAPITALFLAG - "NO_CAPS":0x0200, // NOTCAPITALFLAG - "NUM_LOCK":0x0400, // NUMLOCKFLAG - "NO_NUM_LOCK":0x0800, // NOTNUMLOCKFLAG - "SCROLL_LOCK":0x1000, // SCROLLFLAG - "NO_SCROLL_LOCK":0x2000, // NOTSCROLLFLAG - "VIRTUAL_KEY":0x4000, // ISVIRTUALKEY - "VIRTUAL_CHAR_KEY":0x8000 // VIRTUALCHARKEY // Unused by KMW, but reserved for use by other Keyman engines. - }, - - modifierBitmasks: { - "ALL":0x007F, - "ALT_GR_SIM": (0x0001 | 0x0004), - "CHIRAL":0x001F, // The base bitmask for chiral keyboards. Includes SHIFT, which is non-chiral. - "IS_CHIRAL":0x000F, // Used to test if a bitmask uses a chiral modifier. - "NON_CHIRAL":0x0070 // The default bitmask, for non-chiral keyboards - }, - - stateBitmasks: { - "ALL":0x3F00, - "CAPS":0x0300, - "NUM_LOCK":0x0C00, - "SCROLL_LOCK":0x3000 - }, - - // Define standard keycode numbers (exposed for use by other modules) - keyCodes: { - "K_BKSP":8,"K_TAB":9,"K_ENTER":13, - "K_SHIFT":16,"K_CONTROL":17,"K_ALT":18,"K_PAUSE":19,"K_CAPS":20, - "K_ESC":27,"K_SPACE":32,"K_PGUP":33, - "K_PGDN":34,"K_END":35,"K_HOME":36,"K_LEFT":37,"K_UP":38, - "K_RIGHT":39,"K_DOWN":40,"K_SEL":41,"K_PRINT":42,"K_EXEC":43, - "K_INS":45,"K_DEL":46,"K_HELP":47,"K_0":48, - "K_1":49,"K_2":50,"K_3":51,"K_4":52,"K_5":53,"K_6":54,"K_7":55, - "K_8":56,"K_9":57,"K_A":65,"K_B":66,"K_C":67,"K_D":68,"K_E":69, - "K_F":70,"K_G":71,"K_H":72,"K_I":73,"K_J":74,"K_K":75,"K_L":76, - "K_M":77,"K_N":78,"K_O":79,"K_P":80,"K_Q":81,"K_R":82,"K_S":83, - "K_T":84,"K_U":85,"K_V":86,"K_W":87,"K_X":88,"K_Y":89,"K_Z":90, - "K_NP0":96,"K_NP1":97,"K_NP2":98, - "K_NP3":99,"K_NP4":100,"K_NP5":101,"K_NP6":102, - "K_NP7":103,"K_NP8":104,"K_NP9":105,"K_NPSTAR":106, - "K_NPPLUS":107,"K_SEPARATOR":108,"K_NPMINUS":109,"K_NPDOT":110, - "K_NPSLASH":111,"K_F1":112,"K_F2":113,"K_F3":114,"K_F4":115, - "K_F5":116,"K_F6":117,"K_F7":118,"K_F8":119,"K_F9":120, - "K_F10":121,"K_F11":122,"K_F12":123,"K_NUMLOCK":144,"K_SCROLL":145, - "K_LSHIFT":160,"K_RSHIFT":161,"K_LCONTROL":162,"K_RCONTROL":163, - "K_LALT":164,"K_RALT":165, - "K_COLON":186,"K_EQUAL":187,"K_COMMA":188,"K_HYPHEN":189, - "K_PERIOD":190,"K_SLASH":191,"K_BKQUOTE":192, - "K_LBRKT":219,"K_BKSLASH":220,"K_RBRKT":221, - "K_QUOTE":222,"K_oE2":226,"K_OE2":226, - "K_LOPT":50001,"K_ROPT":50002, - "K_NUMERALS":50003,"K_SYMBOLS":50004,"K_CURRENCIES":50005, - "K_UPPER":50006,"K_LOWER":50007,"K_ALPHA":50008, - "K_SHIFTED":50009,"K_ALTGR":50010, - "K_TABBACK":50011,"K_TABFWD":50012 - }, - - codesUS: [ - ['0123456789',';=,-./`', '[\\]\''], - [')!@#$%^&*(',':+<_>?~', '{|}"'] - ], - - isKnownOSKModifierKey(keyID: string): boolean { - switch(keyID) { - case 'K_SHIFT': - case 'K_LOPT': - case 'K_ROPT': - case 'K_NUMLOCK': // Often used for numeric layers. - case 'K_CAPS': +// TODO: Move to separate folder: 'codes' +// We should start splitting off code needed by keyboards even without a KeyboardProcessor active. +// There's an upcoming `/common/web/types` package that 'codes' and 'keyboards' may fit well within. +// In fact, there's a file there (on its branch) that should be merged with this one! + +const Codes = { + // Define Keyman Developer modifier bit-flags (exposed for use by other modules) + // Compare against /common/include/kmx_file.h. CTRL+F "#define LCTRLFLAG" to find the secton. + modifierCodes: { + "LCTRL":0x0001, // LCTRLFLAG + "RCTRL":0x0002, // RCTRLFLAG + "LALT":0x0004, // LALTFLAG + "RALT":0x0008, // RALTFLAG + "SHIFT":0x0010, // K_SHIFTFLAG + "CTRL":0x0020, // K_CTRLFLAG + "ALT":0x0040, // K_ALTFLAG + // TENTATIVE: Represents command keys, which some OSes use for shortcuts we don't + // want to block. No rule will ever target a modifier set with this bit set to 1. + "META":0x0080, // K_METAFLAG + "CAPS":0x0100, // CAPITALFLAG + "NO_CAPS":0x0200, // NOTCAPITALFLAG + "NUM_LOCK":0x0400, // NUMLOCKFLAG + "NO_NUM_LOCK":0x0800, // NOTNUMLOCKFLAG + "SCROLL_LOCK":0x1000, // SCROLLFLAG + "NO_SCROLL_LOCK":0x2000, // NOTSCROLLFLAG + "VIRTUAL_KEY":0x4000, // ISVIRTUALKEY + "VIRTUAL_CHAR_KEY":0x8000 // VIRTUALCHARKEY // Unused by KMW, but reserved for use by other Keyman engines. + }, + + modifierBitmasks: { + "ALL":0x007F, + "ALT_GR_SIM": (0x0001 | 0x0004), + "CHIRAL":0x001F, // The base bitmask for chiral keyboards. Includes SHIFT, which is non-chiral. + "IS_CHIRAL":0x000F, // Used to test if a bitmask uses a chiral modifier. + "NON_CHIRAL":0x0070 // The default bitmask, for non-chiral keyboards + }, + + stateBitmasks: { + "ALL":0x3F00, + "CAPS":0x0300, + "NUM_LOCK":0x0C00, + "SCROLL_LOCK":0x3000 + }, + + // Define standard keycode numbers (exposed for use by other modules) + keyCodes: { + "K_BKSP":8,"K_TAB":9,"K_ENTER":13, + "K_SHIFT":16,"K_CONTROL":17,"K_ALT":18,"K_PAUSE":19,"K_CAPS":20, + "K_ESC":27,"K_SPACE":32,"K_PGUP":33, + "K_PGDN":34,"K_END":35,"K_HOME":36,"K_LEFT":37,"K_UP":38, + "K_RIGHT":39,"K_DOWN":40,"K_SEL":41,"K_PRINT":42,"K_EXEC":43, + "K_INS":45,"K_DEL":46,"K_HELP":47,"K_0":48, + "K_1":49,"K_2":50,"K_3":51,"K_4":52,"K_5":53,"K_6":54,"K_7":55, + "K_8":56,"K_9":57,"K_A":65,"K_B":66,"K_C":67,"K_D":68,"K_E":69, + "K_F":70,"K_G":71,"K_H":72,"K_I":73,"K_J":74,"K_K":75,"K_L":76, + "K_M":77,"K_N":78,"K_O":79,"K_P":80,"K_Q":81,"K_R":82,"K_S":83, + "K_T":84,"K_U":85,"K_V":86,"K_W":87,"K_X":88,"K_Y":89,"K_Z":90, + "K_NP0":96,"K_NP1":97,"K_NP2":98, + "K_NP3":99,"K_NP4":100,"K_NP5":101,"K_NP6":102, + "K_NP7":103,"K_NP8":104,"K_NP9":105,"K_NPSTAR":106, + "K_NPPLUS":107,"K_SEPARATOR":108,"K_NPMINUS":109,"K_NPDOT":110, + "K_NPSLASH":111,"K_F1":112,"K_F2":113,"K_F3":114,"K_F4":115, + "K_F5":116,"K_F6":117,"K_F7":118,"K_F8":119,"K_F9":120, + "K_F10":121,"K_F11":122,"K_F12":123,"K_NUMLOCK":144,"K_SCROLL":145, + "K_LSHIFT":160,"K_RSHIFT":161,"K_LCONTROL":162,"K_RCONTROL":163, + "K_LALT":164,"K_RALT":165, + "K_COLON":186,"K_EQUAL":187,"K_COMMA":188,"K_HYPHEN":189, + "K_PERIOD":190,"K_SLASH":191,"K_BKQUOTE":192, + "K_LBRKT":219,"K_BKSLASH":220,"K_RBRKT":221, + "K_QUOTE":222,"K_oE2":226,"K_OE2":226, + "K_LOPT":50001,"K_ROPT":50002, + "K_NUMERALS":50003,"K_SYMBOLS":50004,"K_CURRENCIES":50005, + "K_UPPER":50006,"K_LOWER":50007,"K_ALPHA":50008, + "K_SHIFTED":50009,"K_ALTGR":50010, + "K_TABBACK":50011,"K_TABFWD":50012 + }, + + codesUS: [ + ['0123456789',';=,-./`', '[\\]\''], + [')!@#$%^&*(',':+<_>?~', '{|}"'] + ], + + isKnownOSKModifierKey(keyID: string): boolean { + switch(keyID) { + case 'K_SHIFT': + case 'K_LOPT': + case 'K_ROPT': + case 'K_NUMLOCK': // Often used for numeric layers. + case 'K_CAPS': + return true; + default: + if(Codes.keyCodes[keyID] >= 50000) { // A few are used by `sil_euro_latin`. + return true; // is a 'K_' key defined for layer shifting or 'control' use. + } + // Refer to text/codes.ts - these are Keyman-custom "keycodes" used for + // layer shifting keys. To be safe, we currently let K_TABBACK and + // K_TABFWD through, though we might be able to drop them too. + const code = Codes[keyID]; + if(code > 50000 && code < 50011) { return true; - default: - if(Codes.keyCodes[keyID] >= 50000) { // A few are used by `sil_euro_latin`. - return true; // is a 'K_' key defined for layer shifting or 'control' use. - } - // Refer to text/codes.ts - these are Keyman-custom "keycodes" used for - // layer shifting keys. To be safe, we currently let K_TABBACK and - // K_TABFWD through, though we might be able to drop them too. - let code = com.keyman.text.Codes[keyID]; - if(code > 50000 && code < 50011) { - return true; - } - } - - return false; + } + } + + return false; + }, + + + /** + * Get modifier key state from layer id + * + * @param {string} layerId layer id (e.g. ctrlshift) + * @return {number} modifier key state (desktop keyboards) + */ + getModifierState(layerId: string): number { + var modifier=0; + if(layerId.indexOf('shift') >= 0) { + modifier |= Codes.modifierCodes['SHIFT']; + } + + // The chiral checks must not be directly exclusive due each other to visual OSK feedback. + var ctrlMatched=false; + if(layerId.indexOf('leftctrl') >= 0) { + modifier |= Codes.modifierCodes['LCTRL']; + ctrlMatched=true; } + if(layerId.indexOf('rightctrl') >= 0) { + modifier |= Codes.modifierCodes['RCTRL']; + ctrlMatched=true; + } + if(layerId.indexOf('ctrl') >= 0 && !ctrlMatched) { + modifier |= Codes.modifierCodes['CTRL']; + } + + var altMatched=false; + if(layerId.indexOf('leftalt') >= 0) { + modifier |= Codes.modifierCodes['LALT']; + altMatched=true; + } + if(layerId.indexOf('rightalt') >= 0) { + modifier |= Codes.modifierCodes['RALT']; + altMatched=true; + } + if(layerId.indexOf('alt') >= 0 && !altMatched) { + modifier |= Codes.modifierCodes['ALT']; + } + + return modifier; + }, + + /** + * Get state key state from layer id + * + * @param {string} layerId layer id (e.g. caps) + * @return {number} modifier key state (desktop keyboards) + */ + getStateFromLayer(layerId: string): number { + var modifier=0; + + if(layerId.indexOf('caps') >= 0) { + modifier |= Codes.modifierCodes['CAPS']; + } else { + modifier |= Codes.modifierCodes['NO_CAPS']; + } + + return modifier; } -} \ No newline at end of file +} + +export default Codes; \ No newline at end of file diff --git a/common/web/keyboard-processor/src/text/deadkeys.ts b/common/web/keyboard-processor/src/text/deadkeys.ts index d0f775ae04..e5b32a788f 100644 --- a/common/web/keyboard-processor/src/text/deadkeys.ts +++ b/common/web/keyboard-processor/src/text/deadkeys.ts @@ -1,160 +1,157 @@ -namespace com.keyman.text { - // Defines the base Deadkey-tracking object. - - export class Deadkey { - p: number; // Position of deadkey - d: number; // Numerical id of the deadkey - o: number; // Ordinal value of the deadkey (resolves same-place conflicts) - matched: number; - - static ordinalSeed: number = 0; - - constructor(pos: number, id: number) { - this.p = pos; - this.d = id; - this.o = Deadkey.ordinalSeed++; - } +// Defines the base Deadkey-tracking object. +export class Deadkey { + p: number; // Position of deadkey + d: number; // Numerical id of the deadkey + o: number; // Ordinal value of the deadkey (resolves same-place conflicts) + matched: number; + + static ordinalSeed: number = 0; + + constructor(pos: number, id: number) { + this.p = pos; + this.d = id; + this.o = Deadkey.ordinalSeed++; + } - match(p: number, d: number): boolean { - var result:boolean = (this.p == p && this.d == d); + match(p: number, d: number): boolean { + var result:boolean = (this.p == p && this.d == d); - return result; - } - - set(): void { - this.matched = 1; - } + return result; + } - reset(): void { - this.matched = 0; - } + set(): void { + this.matched = 1; + } - before(other: Deadkey): boolean { - return this.o < other.o; - } + reset(): void { + this.matched = 0; + } - clone(): Deadkey { - let dk = new Deadkey(this.p, this.d); - dk.o = this.o; + before(other: Deadkey): boolean { + return this.o < other.o; + } - return dk; - } + clone(): Deadkey { + let dk = new Deadkey(this.p, this.d); + dk.o = this.o; - /** - * Sorts the deadkeys in reverse order. - */ - static sortFunc = function(a: Deadkey, b: Deadkey) { - // We want descending order, so we want 'later' deadkeys first. - if(a.p != b.p) { - return b.p - a.p; - } else { - return b.o - a.o; - } - }; + return dk; } - // Object-orients deadkey management. - export class DeadkeyTracker { - dks: Deadkey[] = []; + /** + * Sorts the deadkeys in reverse order. + */ + static sortFunc = function(a: Deadkey, b: Deadkey) { + // We want descending order, so we want 'later' deadkeys first. + if(a.p != b.p) { + return b.p - a.p; + } else { + return b.o - a.o; + } + }; +} + +// Object-orients deadkey management. +export class DeadkeyTracker { + dks: Deadkey[] = []; + + toSortedArray(): Deadkey[] { + this.dks = this.dks.sort(Deadkey.sortFunc); + return [].concat(this.dks); + } - toSortedArray(): Deadkey[] { - this.dks = this.dks.sort(Deadkey.sortFunc); - return [].concat(this.dks); - } + clone(): DeadkeyTracker { + let dkt = new DeadkeyTracker(); + let dks = this.toSortedArray(); - clone(): DeadkeyTracker { - let dkt = new DeadkeyTracker(); - let dks = this.toSortedArray(); + // Make sure to clone the deadkeys themselves - the Deadkey object is mutable. + dkt.dks = []; + dks.forEach(function(value: Deadkey) { + dkt.dks.push(value.clone()); + }); - // Make sure to clone the deadkeys themselves - the Deadkey object is mutable. - dkt.dks = []; - dks.forEach(function(value: Deadkey) { - dkt.dks.push(value.clone()); - }); + return dkt; + } - return dkt; - } - - /** - * Function isMatch - * Scope Public - * @param {number} caretPos current cursor position - * @param {number} n expected offset of deadkey from cursor - * @param {number} d deadkey - * @return {boolean} True if deadkey found selected context matches val - * Description Match deadkey at current cursor position - */ - isMatch(caretPos: number, n: number, d: number): boolean { - if(this.dks.length == 0) { - return false; // I3318 + /** + * Function isMatch + * Scope Public + * @param {number} caretPos current cursor position + * @param {number} n expected offset of deadkey from cursor + * @param {number} d deadkey + * @return {boolean} True if deadkey found selected context matches val + * Description Match deadkey at current cursor position + */ + isMatch(caretPos: number, n: number, d: number): boolean { + if(this.dks.length == 0) { + return false; // I3318 + } + + var sp=caretPos; + n = sp - n; + for(var i = 0; i < this.dks.length; i++) { + // Don't re-match an already-matched deadkey. It's possible to have two identical + // entries, and they should be kept separately. + if(this.dks[i].match(n, d) && !this.dks[i].matched) { + this.dks[i].set(); + // Assumption: since we match the first possible entry in the array, we + // match the entry with the lower ordinal - the 'first' deadkey in the position. + return true; // I3318 } + } - var sp=caretPos; - n = sp - n; - for(var i = 0; i < this.dks.length; i++) { - // Don't re-match an already-matched deadkey. It's possible to have two identical - // entries, and they should be kept separately. - if(this.dks[i].match(n, d) && !this.dks[i].matched) { - this.dks[i].set(); - // Assumption: since we match the first possible entry in the array, we - // match the entry with the lower ordinal - the 'first' deadkey in the position. - return true; // I3318 - } - } + this.resetMatched(); // I3318 - this.resetMatched(); // I3318 + return false; + } - return false; - } + add(dk: Deadkey) { + this.dks = this.dks.concat(dk); + } - add(dk: Deadkey) { - this.dks = this.dks.concat(dk); - } + remove(dk: Deadkey) { + var index = this.dks.indexOf(dk); + this.dks.splice(index, 1); + } - remove(dk: Deadkey) { - var index = this.dks.indexOf(dk); - this.dks.splice(index, 1); - } + clear() { + this.dks = []; + } - clear() { - this.dks = []; + resetMatched() { + for(let dk of this.dks) { + dk.reset(); } + } - resetMatched() { - for(let dk of this.dks) { - dk.reset(); - } - } - - deleteMatched(): void { - for(var Li = 0; Li < this.dks.length; Li++) { - if(this.dks[Li].matched) { - this.dks.splice(Li--, 1); // Don't forget to decrement! - } + deleteMatched(): void { + for(var Li = 0; Li < this.dks.length; Li++) { + if(this.dks[Li].matched) { + this.dks.splice(Li--, 1); // Don't forget to decrement! } } + } - /** - * Function adjustPositions (formerly _DeadkeyAdjustPos) - * Scope Private - * @param {number} Lstart start position in context - * @param {number} Ldelta characters to adjust by - * Description Adjust saved positions of deadkeys in context - */ - adjustPositions(Lstart: number, Ldelta: number): void { - if(Ldelta == 0) { - return; - } - - for(let dk of this.dks) { - if(dk.p > Lstart) { - dk.p += Ldelta; - } + /** + * Function adjustPositions (formerly _DeadkeyAdjustPos) + * Scope Private + * @param {number} Lstart start position in context + * @param {number} Ldelta characters to adjust by + * Description Adjust saved positions of deadkeys in context + */ + adjustPositions(Lstart: number, Ldelta: number): void { + if(Ldelta == 0) { + return; + } + + for(let dk of this.dks) { + if(dk.p > Lstart) { + dk.p += Ldelta; } } + } - count(): number { - return this.dks.length; - } + count(): number { + return this.dks.length; } } \ No newline at end of file diff --git a/common/web/keyboard-processor/src/text/defaultOutput.ts b/common/web/keyboard-processor/src/text/defaultOutput.ts deleted file mode 100644 index 9d96f4bd1c..0000000000 --- a/common/web/keyboard-processor/src/text/defaultOutput.ts +++ /dev/null @@ -1,191 +0,0 @@ -// Establishes key-code definitions. -/// -// Defines our generalized "KeyEvent" class. -/// - -namespace com.keyman.text { - export enum EmulationKeystrokes { - Enter = '\n', - Backspace = '\b' - } - - /** - * Defines a collection of static library functions that define KeymanWeb's default (implied) keyboard rule behaviors. - */ - export class DefaultOutput { - private constructor() { - } - - static codeForEvent(Lkc: KeyEvent) { - return Codes.keyCodes[Lkc.kName] || Lkc.Lcode;; - } - - /** - * Serves as a default keycode lookup table. This may be referenced safely by mnemonic handling without fear of side-effects. - * Also used by Processor.defaultRuleBehavior to generate output after filtering for special cases. - */ - public static forAny(Lkc: KeyEvent, isMnemonic: boolean, ruleBehavior?: RuleBehavior) { - var char = ''; - - // A pretty simple table of lookups, corresponding VERY closely to the original defaultKeyOutput. - if((char = DefaultOutput.forSpecialEmulation(Lkc, ruleBehavior)) != null) { - return char; - } else if(!isMnemonic && ((char = DefaultOutput.forNumpadKeys(Lkc, ruleBehavior)) != null)) { - return char; - } else if((char = DefaultOutput.forUnicodeKeynames(Lkc, ruleBehavior)) != null) { - return char; - } else if((char = DefaultOutput.forBaseKeys(Lkc, ruleBehavior)) != null) { - return char; - } else { - // // For headless and embeddded, we may well allow '\t'. It's DOM mode that has other uses. - // // Not originally defined for text output within defaultKeyOutput. - // // We can't enable it yet, as it'll cause hardware keystrokes in the DOM to output '\t' rather - // // than rely on the browser-default handling. - let code = DefaultOutput.codeForEvent(Lkc); - switch(code) { - // case Codes.keyCodes['K_TAB']: - // case Codes.keyCodes['K_TABBACK']: - // case Codes.keyCodes['K_TABFWD']: - // return '\t'; - default: - return null; - } - } - } - - /** - * isCommand - returns a boolean indicating if a non-text event should be triggered by the keystroke. - */ - public static isCommand(Lkc: KeyEvent): boolean { - let code = DefaultOutput.codeForEvent(Lkc); - - switch(code) { - // Should we ever implement them: - // case Codes.keyCodes['K_LEFT']: // would not output text, but would alter the caret's position in the context. - // case Codes.keyCodes['K_RIGHT']: - // return true; - default: - return false; - } - } - - /** - * Used when a RuleBehavior represents a non-text "command" within the Engine. This will generally - * trigger events that require context reset - often by moving the caret or by moving what OutputTarget - * the caret is in. However, we let those events perform the actual context reset. - * - * Note: is extended by DOM-aware KeymanWeb code. - */ - public static applyCommand(Lkc: KeyEvent, outputTarget: OutputTarget): void { - // Notes for potential default-handling extensions: - // - // switch(code) { - // // Problem: clusters, and doing them right. - // // The commented-out code below should be a decent starting point, but clusters make it complex. - // // Mostly based on pre-12.0 code, but the general idea should be relatively clear. - // - // case Codes.keyCodes['K_LEFT']: - // if(touchAlias) { - // var caretPos = keymanweb.getTextCaret(Lelem); - // keymanweb.setTextCaret(Lelem, caretPos - 1 >= 0 ? caretPos - 1 : 0); - // } - // break; - // case Codes.keyCodes['K_RIGHT']: - // if(touchAlias) { - // var caretPos = keymanweb.getTextCaret(Lelem); - // keymanweb.setTextCaret(Lelem, caretPos + 1); - // } - // if(code == VisualKeyboard.keyCodes['K_RIGHT']) { - // break; - // } - // } - // - // Note that these would be useful even outside of a DOM context. - } - - /** - * Codes matched here generally have default implementations when in a browser but require emulation - * for 'synthetic' `OutputTarget`s like `Mock`s, which have no default text handling. - */ - public static forSpecialEmulation(Lkc: KeyEvent, ruleBehavior?: RuleBehavior): EmulationKeystrokes { - let code = DefaultOutput.codeForEvent(Lkc); - - switch(code) { - case Codes.keyCodes['K_BKSP']: - return EmulationKeystrokes.Backspace; - case Codes.keyCodes['K_ENTER']: - return EmulationKeystrokes.Enter; - // case Codes.keyCodes['K_DEL']: - // return '\u007f'; // 127, ASCII / Unicode control code for DEL. - default: - return null; - } - } - - // Should not be used for mnenomic keyboards. forAny()'s use of this method checks first. - public static forNumpadKeys(Lkc: KeyEvent, ruleBehavior?: RuleBehavior) { - // Translate numpad keystrokes into their non-numpad equivalents - if(Lkc.Lcode >= Codes.keyCodes["K_NP0"] && Lkc.Lcode <= Codes.keyCodes["K_NPSLASH"]) { - // Number pad, numlock on - if(Lkc.Lcode < 106) { - var Lch = Lkc.Lcode-48; - } else { - Lch = Lkc.Lcode-64; - } - let ch = String._kmwFromCharCode(Lch); //I3319 - return ch; - } else { - return null; - } - } - - // Test for fall back to U_xxxxxx key id - // For this first test, we ignore the keyCode and use the keyName - public static forUnicodeKeynames(Lkc: KeyEvent, ruleBehavior?: RuleBehavior) { - const keyName = Lkc.kName; - - return keyboards.ActiveKey.unicodeIDToText(keyName, (codeWithError) => { - ruleBehavior.errorLog = ("Suppressing Unicode control code in " + keyName + ": " + codeWithError); - }); - } - - // Test for otherwise unimplemented keys on the the base default & shift layers. - // Those keys must be blocked by keyboard rules if intentionally unimplemented; otherwise, this function will trigger. - public static forBaseKeys(Lkc: KeyEvent, ruleBehavior?: RuleBehavior) { - let n = Lkc.Lcode; - let keyShiftState = Lkc.Lmodifiers; - - // check if exact match to SHIFT's code. Only the 'default' and 'shift' layers should have default key outputs. - // TODO: Extend to allow AltGr as well - better mnemonic support. - if(keyShiftState == Codes.modifierCodes['SHIFT']) { - keyShiftState = 1; - } else if(keyShiftState != 0) { - if(ruleBehavior) { - ruleBehavior.warningLog = "KMW only defines default key output for the 'default' and 'shift' layers!"; - } - return null; - } - - // Now that keyShiftState is either 0 or 1, we can use the following structure to determine the default output. - try { - if(n == Codes.keyCodes['K_SPACE']) { - return ' '; - } else if(n >= Codes.keyCodes['K_0'] && n <= Codes.keyCodes['K_9']) { // The number keys. - return Codes.codesUS[keyShiftState][0][n-Codes.keyCodes['K_0']]; - } else if(n >= Codes.keyCodes['K_A'] && n <= Codes.keyCodes['K_Z']) { // The base letter keys - return String.fromCharCode(n+(keyShiftState?0:32)); // 32 is the offset from uppercase to lowercase. - } else if(n >= Codes.keyCodes['K_COLON'] && n <= Codes.keyCodes['K_BKQUOTE']) { - return Codes.codesUS[keyShiftState][1][n-Codes.keyCodes['K_COLON']]; - } else if(n >= Codes.keyCodes['K_LBRKT'] && n <= Codes.keyCodes['K_QUOTE']) { - return Codes.codesUS[keyShiftState][2][n-Codes.keyCodes['K_LBRKT']]; - } - } catch (e) { - if(ruleBehavior) { - ruleBehavior.errorLog = "Error detected with default mapping for key: code = " + n + ", shift state = " + (keyShiftState == 1 ? 'shift' : 'default'); - } - } - - return null; - } - } -} diff --git a/common/web/keyboard-processor/src/text/defaultRules.ts b/common/web/keyboard-processor/src/text/defaultRules.ts new file mode 100644 index 0000000000..5ad708bd67 --- /dev/null +++ b/common/web/keyboard-processor/src/text/defaultRules.ts @@ -0,0 +1,225 @@ +// TODO: Move to separate folder: 'codes' +// We should start splitting off code needed by keyboards even without a KeyboardProcessor active. +// There's an upcoming `/common/web/types` package that 'codes' and 'keyboards' may fit well within. + +import Codes from "./codes.js"; +import type KeyEvent from "./keyEvent.js"; +import type OutputTarget from "./outputTarget.js"; + +// The only members referenced are to produce warning and error logs. A little abstraction +// via an optional 'logger' interface can maintain it while facilitating a the split alluded +// to above. +// +// Alternatively, we could just... not take in the parameter at all, which'd also facilitate +// the future modularization effort. +import RuleBehavior from "./ruleBehavior.js"; + +export enum EmulationKeystrokes { + Enter = '\n', + Backspace = '\b' +} + +/** + * Defines a collection of static library functions that define KeymanWeb's default (implied) keyboard rule behaviors. + */ +export default class DefaultRules { + public constructor() { + } + + codeForEvent(Lkc: KeyEvent) { + return Codes.keyCodes[Lkc.kName] || Lkc.Lcode;; + } + + /** + * Serves as a default keycode lookup table. This may be referenced safely by mnemonic handling without fear of side-effects. + * Also used by Processor.defaultRuleBehavior to generate output after filtering for special cases. + */ + public forAny(Lkc: KeyEvent, isMnemonic: boolean, ruleBehavior?: RuleBehavior) { + var char = ''; + + // A pretty simple table of lookups, corresponding VERY closely to the original defaultKeyOutput. + if((char = this.forSpecialEmulation(Lkc)) != null) { + return char; + } else if(!isMnemonic && ((char = this.forNumpadKeys(Lkc)) != null)) { + return char; + } else if((char = this.forUnicodeKeynames(Lkc, ruleBehavior)) != null) { + return char; + } else if((char = this.forBaseKeys(Lkc, ruleBehavior)) != null) { + return char; + } else { + // // For headless and embeddded, we may well allow '\t'. It's DOM mode that has other uses. + // // Not originally defined for text output within defaultKeyOutput. + // // We can't enable it yet, as it'll cause hardware keystrokes in the DOM to output '\t' rather + // // than rely on the browser-default handling. + let code = this.codeForEvent(Lkc); + switch(code) { + // case Codes.keyCodes['K_TAB']: + // case Codes.keyCodes['K_TABBACK']: + // case Codes.keyCodes['K_TABFWD']: + // return '\t'; + default: + return null; + } + } + } + + /** + * isCommand - returns a boolean indicating if a non-text event should be triggered by the keystroke. + */ + public isCommand(Lkc: KeyEvent): boolean { + let code = this.codeForEvent(Lkc); + + switch(code) { + // Should we ever implement them: + // case Codes.keyCodes['K_LEFT']: // would not output text, but would alter the caret's position in the context. + // case Codes.keyCodes['K_RIGHT']: + // return true; + default: + return false; + } + } + + /** + * Used when a RuleBehavior represents a non-text "command" within the Engine. This will generally + * trigger events that require context reset - often by moving the caret or by moving what OutputTarget + * the caret is in. However, we let those events perform the actual context reset. + * + * Note: is extended by DOM-aware KeymanWeb code. + */ + public applyCommand(Lkc: KeyEvent, outputTarget: OutputTarget): void { + // Notes for potential default-handling extensions: + // + // switch(code) { + // // Problem: clusters, and doing them right. + // // The commented-out code below should be a decent starting point, but clusters make it complex. + // // Mostly based on pre-12.0 code, but the general idea should be relatively clear. + // + // case Codes.keyCodes['K_LEFT']: + // if(touchAlias) { + // var caretPos = keymanweb.getTextCaret(Lelem); + // keymanweb.setTextCaret(Lelem, caretPos - 1 >= 0 ? caretPos - 1 : 0); + // } + // break; + // case Codes.keyCodes['K_RIGHT']: + // if(touchAlias) { + // var caretPos = keymanweb.getTextCaret(Lelem); + // keymanweb.setTextCaret(Lelem, caretPos + 1); + // } + // if(code == VisualKeyboard.keyCodes['K_RIGHT']) { + // break; + // } + // } + // + // Note that these would be useful even outside of a DOM context. + } + + /** + * Codes matched here generally have default implementations when in a browser but require emulation + * for 'synthetic' `OutputTarget`s like `Mock`s, which have no default text handling. + */ + public forSpecialEmulation(Lkc: KeyEvent): EmulationKeystrokes { + let code = this.codeForEvent(Lkc); + + switch(code) { + case Codes.keyCodes['K_BKSP']: + return EmulationKeystrokes.Backspace; + case Codes.keyCodes['K_ENTER']: + return EmulationKeystrokes.Enter; + // case Codes.keyCodes['K_DEL']: + // return '\u007f'; // 127, ASCII / Unicode control code for DEL. + default: + return null; + } + } + + // Should not be used for mnenomic keyboards. forAny()'s use of this method checks first. + public forNumpadKeys(Lkc: KeyEvent) { + // Translate numpad keystrokes into their non-numpad equivalents + if(Lkc.Lcode >= Codes.keyCodes["K_NP0"] && Lkc.Lcode <= Codes.keyCodes["K_NPSLASH"]) { + // Number pad, numlock on + if(Lkc.Lcode < 106) { + var Lch = Lkc.Lcode-48; + } else { + Lch = Lkc.Lcode-64; + } + let ch = String._kmwFromCharCode(Lch); //I3319 + return ch; + } else { + return null; + } + } + + // Test for fall back to U_xxxxxx key id + // For this first test, we ignore the keyCode and use the keyName + public forUnicodeKeynames(Lkc: KeyEvent, ruleBehavior?: RuleBehavior) { + const keyName = Lkc.kName; + + // Test for fall back to U_xxxxxx key id + // For this first test, we ignore the keyCode and use the keyName + if(!keyName || keyName.substr(0,2) != 'U_') { + return null; + } + + let result = ''; + const codePoints = keyName.substr(2).split('_'); + for(let codePoint of codePoints) { + const codePointValue = parseInt(codePoint, 16); + if (((0x0 <= codePointValue) && (codePointValue <= 0x1F)) || ((0x80 <= codePointValue) && (codePointValue <= 0x9F)) || isNaN(codePointValue)) { + // Code points [U_0000 - U_001F] and [U_0080 - U_009F] refer to Unicode C0 and C1 control codes. + // Check the codePoint number and do not allow output of these codes via U_xxxxxx shortcuts. + // Also handles invalid identifiers (e.g. `U_ghij`) for which parseInt returns NaN + if(ruleBehavior) { + ruleBehavior.errorLog = ("Suppressing Unicode control code in " + keyName); + } + // We'll attempt to add valid chars + continue; + } else { + // String.fromCharCode() is inadequate to handle the entire range of Unicode + // Someday after upgrading to ES2015, can use String.fromCodePoint() + result += String.kmwFromCharCode(codePointValue); + } + } + return result ? result : null; + } + + // Test for otherwise unimplemented keys on the the base default & shift layers. + // Those keys must be blocked by keyboard rules if intentionally unimplemented; otherwise, this function will trigger. + public forBaseKeys(Lkc: KeyEvent, ruleBehavior?: RuleBehavior) { + let n = Lkc.Lcode; + let keyShiftState = Lkc.Lmodifiers; + + // check if exact match to SHIFT's code. Only the 'default' and 'shift' layers should have default key outputs. + // TODO: Extend to allow AltGr as well - better mnemonic support. + if(keyShiftState == Codes.modifierCodes['SHIFT']) { + keyShiftState = 1; + } else if(keyShiftState != 0) { + if(ruleBehavior) { + ruleBehavior.warningLog = "KMW only defines default key output for the 'default' and 'shift' layers!"; + } + return null; + } + + // Now that keyShiftState is either 0 or 1, we can use the following structure to determine the default output. + try { + if(n == Codes.keyCodes['K_SPACE']) { + return ' '; + } else if(n >= Codes.keyCodes['K_0'] && n <= Codes.keyCodes['K_9']) { // The number keys. + return Codes.codesUS[keyShiftState][0][n-Codes.keyCodes['K_0']]; + } else if(n >= Codes.keyCodes['K_A'] && n <= Codes.keyCodes['K_Z']) { // The base letter keys + return String.fromCharCode(n+(keyShiftState?0:32)); // 32 is the offset from uppercase to lowercase. + } else if(n >= Codes.keyCodes['K_COLON'] && n <= Codes.keyCodes['K_BKQUOTE']) { + return Codes.codesUS[keyShiftState][1][n-Codes.keyCodes['K_COLON']]; + } else if(n >= Codes.keyCodes['K_LBRKT'] && n <= Codes.keyCodes['K_QUOTE']) { + return Codes.codesUS[keyShiftState][2][n-Codes.keyCodes['K_LBRKT']]; + } else if(n == Codes.keyCodes['K_oE2']) { + return keyShiftState ? '|' : '\\'; + } + } catch (e) { + if(ruleBehavior) { + ruleBehavior.errorLog = "Error detected with default mapping for key: code = " + n + ", shift state = " + (keyShiftState == 1 ? 'shift' : 'default'); + } + } + + return null; + } +} diff --git a/common/web/keyboard-processor/src/text/kbdInterface.ts b/common/web/keyboard-processor/src/text/kbdInterface.ts index 672ecd4ded..311a3a75e8 100644 --- a/common/web/keyboard-processor/src/text/kbdInterface.ts +++ b/common/web/keyboard-processor/src/text/kbdInterface.ts @@ -1,1164 +1,1178 @@ -/// -/// - -// Defines classes for handling system stores -/// - /*** KeymanWeb 11.0 Copyright 2019 SIL International ***/ -namespace com.keyman.text { - //#region Helper type definitions +//#region Imports - export class KeyInformation { - vk: boolean; - code: number; - modifiers: number; - } +import { type DeviceSpec } from "@keymanapp/web-utils"; - /* - * Type alias definitions to reflect the parameters of the fullContextMatch() callback (KMW 10+). - * No constructors or methods since keyboards will not utilize the same backing prototype, and - * property names are shorthanded to promote minification. - */ - type PlainKeyboardStore = string; +import Codes from "./codes.js"; +import type KeyEvent from "./keyEvent.js"; +import type { Deadkey } from "./deadkeys.js"; +import KeyMapping from "./keyMapping.js"; +import { SystemStore, MutableSystemStore, PlatformSystemStore } from "./systemStores.js"; +import type { VariableStoreSerializer } from "./keyboardProcessor.js"; +import type OutputTarget from "./outputTarget.js"; +import { Mock } from "./outputTarget.js"; +import RuleBehavior from "./ruleBehavior.js"; +import Keyboard, { VariableStoreDictionary } from "../keyboards/keyboard.js"; +import { KeyboardHarness, KeyboardKeymanGlobal } from "../keyboards/keyboardHarness.js"; - export type KeyboardStoreElement = (string|StoreNonCharEntry); - export type ComplexKeyboardStore = KeyboardStoreElement[]; +//#endregion - type KeyboardStore = PlainKeyboardStore | ComplexKeyboardStore; +//#region Helper type definitions - export type VariableStore = {[name: string]: string}; +export class KeyInformation { + vk: boolean; + code: number; + modifiers: number; +} - type RuleChar = string; +/* +* Type alias definitions to reflect the parameters of the fullContextMatch() callback (KMW 10+). +* No constructors or methods since keyboards will not utilize the same backing prototype, and +* property names are shorthanded to promote minification. +*/ +type PlainKeyboardStore = string; - class RuleDeadkey { - /** Discriminant field - 'd' for Deadkey. - */ - ['t']: 'd'; +export type KeyboardStoreElement = (string|StoreNonCharEntry); +export type ComplexKeyboardStore = KeyboardStoreElement[]; - /** - * Value: the deadkey's ID. - */ - ['d']: number; // For 'd'eadkey; also reflects the Deadkey class's 'd' property. - } +type KeyboardStore = PlainKeyboardStore | ComplexKeyboardStore; - class ContextAny { - /** Discriminant field - 'a' for `any()`. - */ - ['t']: 'a'; +export type VariableStore = {[name: string]: string}; - /** - * Value: the store to search. - */ - ['a']: KeyboardStore; // For 'a'ny statement. +type RuleChar = string; - /** - * If set to true, negates the 'any'. - */ - ['n']: boolean|0|1; - } - - class RuleIndex { - /** Discriminant field - 'i' for `index()`. - */ - ['t']: 'i'; +class RuleDeadkey { + /** Discriminant field - 'd' for Deadkey. + */ + ['t']: 'd'; - /** - * Value: the Store from which to output - */ - ['i']: KeyboardStore; + /** + * Value: the deadkey's ID. + */ + ['d']: number; // For 'd'eadkey; also reflects the Deadkey class's 'd' property. +} - /** - * Offset: the offset in context for the corresponding `any()`. - */ - ['o']: number; - } +class ContextAny { + /** Discriminant field - 'a' for `any()`. + */ + ['t']: 'a'; - class ContextEx { - /** Discriminant field - 'c' for `context()`. - */ - ['t']: 'c'; + /** + * Value: the store to search. + */ + ['a']: KeyboardStore; // For 'a'ny statement. - /** - * Value: The offset into the current rule's context to be matched. - */ - ['c']: number; // For 'c'ontext statement. - } + /** + * If set to true, negates the 'any'. + */ + ['n']: boolean|0|1; +} - class ContextNul { - /** Discriminant field - 'n' for `nul` - */ - ['t']: 'n'; - } +class RuleIndex { + /** Discriminant field - 'i' for `index()`. + */ + ['t']: 'i'; - class StoreBeep { - /** Discriminant field - 'b' for `beep` - */ - ['t']: 'b'; - } + /** + * Value: the Store from which to output + */ + ['i']: KeyboardStore; - type ContextNonCharEntry = RuleDeadkey | ContextAny | RuleIndex | ContextEx | ContextNul; - type ContextEntry = RuleChar | ContextNonCharEntry; + /** + * Offset: the offset in context for the corresponding `any()`. + */ + ['o']: number; +} - type StoreNonCharEntry = RuleDeadkey | StoreBeep; +class ContextEx { + /** Discriminant field - 'c' for `context()`. + */ + ['t']: 'c'; /** - * Cache of context storing and retrieving return values from KC - * Must be reset prior to each keystroke and after any text changes - * MCD 3/1/14 - **/ - class CachedContext { - _cache: string[][]; - - reset(): void { - this._cache = []; - } - - get(n: number, ln: number): string { - // return null; // uncomment this line to disable context caching - if(typeof this._cache[n] == 'undefined') { - return null; - } else if(typeof this._cache[n][ln] == 'undefined') { - return null; - } - return this._cache[n][ln]; - } + * Value: The offset into the current rule's context to be matched. + */ + ['c']: number; // For 'c'ontext statement. +} - set(n: number, ln: number, val: string): void { - if(typeof this._cache[n] == 'undefined') { - this._cache[n] = []; - } - this._cache[n][ln] = val; - } - }; +class ContextNul { + /** Discriminant field - 'n' for `nul` + */ + ['t']: 'n'; +} - type CachedExEntry = {valContext: (string|number)[], deadContext: text.Deadkey[]}; - /** - * An extended version of cached context storing designed to work with - * `fullContextMatch` and its helper functions. +class StoreBeep { + /** Discriminant field - 'b' for `beep` */ - class CachedContextEx { - _cache: CachedExEntry[][]; + ['t']: 'b'; +} + +type ContextNonCharEntry = RuleDeadkey | ContextAny | RuleIndex | ContextEx | ContextNul; +type ContextEntry = RuleChar | ContextNonCharEntry; + +type StoreNonCharEntry = RuleDeadkey | StoreBeep; - reset(): void { - this._cache = []; +/** + * Cache of context storing and retrieving return values from KC + * Must be reset prior to each keystroke and after any text changes + * MCD 3/1/14 + **/ +class CachedContext { + _cache: string[][]; + + reset(): void { + this._cache = []; + } + + get(n: number, ln: number): string { + // return null; // uncomment this line to disable context caching + if(typeof this._cache[n] == 'undefined') { + return null; + } else if(typeof this._cache[n][ln] == 'undefined') { + return null; } + return this._cache[n][ln]; + } - get(n: number, ln: number): CachedExEntry { - // return null; // uncomment this line to disable context caching - if(typeof this._cache[n] == 'undefined') { - return null; - } else if(typeof this._cache[n][ln] == 'undefined') { - return null; - } - return this._cache[n][ln]; + set(n: number, ln: number, val: string): void { + if(typeof this._cache[n] == 'undefined') { + this._cache[n] = []; } + this._cache[n][ln] = val; + } +}; + +type CachedExEntry = {valContext: (string|number)[], deadContext: Deadkey[]}; +/** + * An extended version of cached context storing designed to work with + * `fullContextMatch` and its helper functions. + */ +class CachedContextEx { + _cache: CachedExEntry[][]; + + reset(): void { + this._cache = []; + } - set(n: number, ln: number, val: CachedExEntry): void { - if(typeof this._cache[n] == 'undefined') { - this._cache[n] = []; - } - this._cache[n][ln] = val; + get(n: number, ln: number): CachedExEntry { + // return null; // uncomment this line to disable context caching + if(typeof this._cache[n] == 'undefined') { + return null; + } else if(typeof this._cache[n][ln] == 'undefined') { + return null; } + return this._cache[n][ln]; + } - clone(): CachedContextEx { - let r = new CachedContextEx(); - r._cache = this._cache; - return r; + set(n: number, ln: number, val: CachedExEntry): void { + if(typeof this._cache[n] == 'undefined') { + this._cache[n] = []; } - }; + this._cache[n][ln] = val; + } - //#endregion + clone(): CachedContextEx { + let r = new CachedContextEx(); + r._cache = this._cache; + return r; + } +}; - export class KeyboardInterface { - static readonly GLOBAL_NAME = 'KeymanWeb'; +export enum SystemStoreIDs { + TSS_LAYER = 33, + TSS_PLATFORM = 31, + TSS_NEWLAYER = 42, + TSS_OLDLAYER = 43 +} - cachedContext: CachedContext = new CachedContext(); - cachedContextEx: CachedContextEx = new CachedContextEx(); - ruleContextEx: CachedContextEx; +//#endregion - activeTargetOutput: OutputTarget; - ruleBehavior: RuleBehavior; +export default class KeyboardInterface extends KeyboardHarness { + static readonly GLOBAL_NAME = 'KeymanWeb'; - static readonly TSS_LAYER: number = 33; - static readonly TSS_PLATFORM: number = 31; - static readonly TSS_NEWLAYER: number = 42; - static readonly TSS_OLDLAYER: number = 43; + cachedContext: CachedContext = new CachedContext(); + cachedContextEx: CachedContextEx = new CachedContextEx(); + ruleContextEx: CachedContextEx; - systemStores: {[storeID: number]: SystemStore}; + activeTargetOutput: OutputTarget; + ruleBehavior: RuleBehavior; - _AnyIndices: number[] = []; // AnyIndex - array of any/index match indices + systemStores: {[storeID: number]: SystemStore}; - // Must be accessible to some of the keyboard API methods. - activeKeyboard: keyboards.Keyboard; - activeDevice: utils.DeviceSpec; + _AnyIndices: number[] = []; // AnyIndex - array of any/index match indices - variableStoreSerializer?: VariableStoreSerializer; + // Must be accessible to some of the keyboard API methods. + activeKeyboard: Keyboard; + activeDevice: DeviceSpec; - constructor(variableStoreSerializer: VariableStoreSerializer = null) { - this.systemStores = {}; + variableStoreSerializer?: VariableStoreSerializer; - this.systemStores[KeyboardInterface.TSS_PLATFORM] = new PlatformSystemStore(this); - this.systemStores[KeyboardInterface.TSS_LAYER] = new MutableSystemStore(KeyboardInterface.TSS_LAYER, 'default'); - this.systemStores[KeyboardInterface.TSS_NEWLAYER] = new MutableSystemStore(KeyboardInterface.TSS_NEWLAYER, ''); - this.systemStores[KeyboardInterface.TSS_OLDLAYER] = new MutableSystemStore(KeyboardInterface.TSS_OLDLAYER, ''); + // A 'reference point' that debug keyboards may use to access KMW's code constants. + public get Codes(): typeof Codes { + return Codes; + } - this.variableStoreSerializer = variableStoreSerializer; - } + constructor(_jsGlobal: any, keymanGlobal: KeyboardKeymanGlobal, variableStoreSerializer: VariableStoreSerializer = null) { + super(_jsGlobal, keymanGlobal); - /** - * Function KSF - * Scope Public - * - * Saves the document's current focus settings on behalf of the keyboard. Often paired with insertText. - */ - saveFocus(): void { } - - /** - * A text-insertion method used by custom OSKs for helpHTML interaction, like with sil_euro_latin. - * - * This function currently bypasses web-core's standard text handling control path and all predictive text processing. - * It also has DOM-dependencies that help ensure KMW's active OutputTarget retains focus during use. - */ - insertText?: (Ptext: string, PdeadKey: number) => boolean; - - /** - * Function registerKeyboard KR - * Scope Public - * @param {Object} Pk Keyboard object - * Description Registers a keyboard with KeymanWeb once its script has fully loaded. - * - * In web-core, this also activates the keyboard; in other modules, this method - * may be replaced with other implementations. - */ - registerKeyboard(Pk): void { - // NOTE: This implementation is web-core specific and is intentionally replaced, whole-sale, - // by DOM-aware code. - let keyboard = new keyboards.Keyboard(Pk); - this.activeKeyboard = keyboard; - } + this.systemStores = {}; - /** - * Used by DOM-aware KeymanWeb to add keyboard stubs, used by the `KeyboardManager` type - * to optimize resource use. - */ - registerStub?: (Pstub) => number; - - /** - * Get *cached or uncached* keyboard context for a specified range, relative to caret - * - * @param {number} n Number of characters to move back from caret - * @param {number} ln Number of characters to return - * @param {Object} Pelem Element to work with (must be currently focused element) - * @return {string} Context string - * - * Example [abcdef|ghi] as INPUT, with the caret position marked by |: - * KC(2,1,Pelem) == "e" - * KC(3,3,Pelem) == "def" - * KC(10,10,Pelem) == "abcdef" i.e. return as much as possible of the requested string - */ - - context(n: number, ln: number, outputTarget: OutputTarget): string { - var v = this.cachedContext.get(n, ln); - if(v !== null) { - return v; - } + this.systemStores[SystemStoreIDs.TSS_PLATFORM] = new PlatformSystemStore(this); + this.systemStores[SystemStoreIDs.TSS_LAYER] = new MutableSystemStore(SystemStoreIDs.TSS_LAYER, 'default'); + this.systemStores[SystemStoreIDs.TSS_NEWLAYER] = new MutableSystemStore(SystemStoreIDs.TSS_NEWLAYER, ''); + this.systemStores[SystemStoreIDs.TSS_OLDLAYER] = new MutableSystemStore(SystemStoreIDs.TSS_OLDLAYER, ''); - var r = this.KC_(n, ln, outputTarget); - this.cachedContext.set(n, ln, r); - return r; - } + this.variableStoreSerializer = variableStoreSerializer; + } - /** - * Get (uncached) keyboard context for a specified range, relative to caret - * - * @param {number} n Number of characters to move back from caret - * @param {number} ln Number of characters to return - * @param {Object} Pelem Element to work with (must be currently focused element) - * @return {string} Context string - * - * Example [abcdef|ghi] as INPUT, with the caret position marked by |: - * KC(2,1,Pelem) == "e" - * KC(3,3,Pelem) == "def" - * KC(10,10,Pelem) == "XXXXabcdef" i.e. return as much as possible of the requested string, where X = \uFFFE - */ - private KC_(n: number, ln: number, outputTarget: OutputTarget): string { - var tempContext = ''; - - // If we have a selection, we have an empty context - tempContext = outputTarget.isSelectionEmpty() ? outputTarget.getTextBeforeCaret() : ""; - - if(tempContext._kmwLength() < n) { - tempContext = Array(n-tempContext._kmwLength()+1).join("\uFFFE") + tempContext; - } + /** + * Function KSF + * Scope Public + * + * Saves the document's current focus settings on behalf of the keyboard. Often paired with insertText. + */ + saveFocus(): void { } + + /** + * A text-insertion method used by custom OSKs for helpHTML interaction, like with sil_euro_latin. + * + * This function currently bypasses web-core's standard text handling control path and all predictive text processing. + * It also has DOM-dependencies that help ensure KMW's active OutputTarget retains focus during use. + */ + insertText?: (Ptext: string, PdeadKey: number) => void; + + /** + * Function registerKeyboard KR + * Scope Public + * @param {Object} Pk Keyboard object + * Description Registers a keyboard with KeymanWeb once its script has fully loaded. + * + * In web-core, this also activates the keyboard; in other modules, this method + * may be replaced with other implementations. + */ + registerKeyboard(Pk): void { + // NOTE: This implementation is web-core specific and is intentionally replaced, whole-sale, + // by DOM-aware code. + let keyboard = new Keyboard(Pk); + this.loadedKeyboard = keyboard; + } - return tempContext._kmwSubstr(-n)._kmwSubstr(0,ln); + /** + * Get *cached or uncached* keyboard context for a specified range, relative to caret + * + * @param {number} n Number of characters to move back from caret + * @param {number} ln Number of characters to return + * @param {Object} Pelem Element to work with (must be currently focused element) + * @return {string} Context string + * + * Example [abcdef|ghi] as INPUT, with the caret position marked by |: + * KC(2,1,Pelem) == "e" + * KC(3,3,Pelem) == "def" + * KC(10,10,Pelem) == "abcdef" i.e. return as much as possible of the requested string + */ + + context(n: number, ln: number, outputTarget: OutputTarget): string { + var v = this.cachedContext.get(n, ln); + if(v !== null) { + return v; } - /** - * Function nul KN - * Scope Public - * @param {number} n Length of context to check - * @param {Object} Ptarg Element to work with (must be currently focused element) - * @return {boolean} True if length of context is less than or equal to n - * Description Test length of context, return true if the length of the context is less than or equal to n - * - * Example [abc|def] as INPUT, with the caret position marked by |: - * KN(3,Pelem) == TRUE - * KN(2,Pelem) == FALSE - * KN(4,Pelem) == TRUE - */ - nul(n: number, outputTarget: OutputTarget): boolean { - var cx=this.context(n+1, 1, outputTarget); - - // With #31, the result will be a replacement character if context is empty. - return cx === "\uFFFE"; + var r = this.KC_(n, ln, outputTarget); + this.cachedContext.set(n, ln, r); + return r; + } + + /** + * Get (uncached) keyboard context for a specified range, relative to caret + * + * @param {number} n Number of characters to move back from caret + * @param {number} ln Number of characters to return + * @param {Object} Pelem Element to work with (must be currently focused element) + * @return {string} Context string + * + * Example [abcdef|ghi] as INPUT, with the caret position marked by |: + * KC(2,1,Pelem) == "e" + * KC(3,3,Pelem) == "def" + * KC(10,10,Pelem) == "XXXXabcdef" i.e. return as much as possible of the requested string, where X = \uFFFE + */ + private KC_(n: number, ln: number, outputTarget: OutputTarget): string { + var tempContext = ''; + + // If we have a selection, we have an empty context + tempContext = outputTarget.isSelectionEmpty() ? outputTarget.getTextBeforeCaret() : ""; + + if(tempContext._kmwLength() < n) { + tempContext = Array(n-tempContext._kmwLength()+1).join("\uFFFE") + tempContext; } - /** - * Function contextMatch KCM - * Scope Public - * @param {number} n Number of characters to move back from caret - * @param {Object} Ptarg Focused element - * @param {string} val String to match - * @param {number} ln Number of characters to return - * @return {boolean} True if selected context matches val - * Description Test keyboard context for match - */ - contextMatch(n: number, outputTarget: OutputTarget, val: string, ln: number): boolean { - var cx=this.context(n, ln, outputTarget); - if(cx === val) { - return true; // I3318 - } - outputTarget.deadkeys().resetMatched(); // I3318 - return false; + return tempContext._kmwSubstr(-n)._kmwSubstr(0,ln); + } + + /** + * Function nul KN + * Scope Public + * @param {number} n Length of context to check + * @param {Object} Ptarg Element to work with (must be currently focused element) + * @return {boolean} True if length of context is less than or equal to n + * Description Test length of context, return true if the length of the context is less than or equal to n + * + * Example [abc|def] as INPUT, with the caret position marked by |: + * KN(3,Pelem) == TRUE + * KN(2,Pelem) == FALSE + * KN(4,Pelem) == TRUE + */ + nul(n: number, outputTarget: OutputTarget): boolean { + var cx=this.context(n+1, 1, outputTarget); + + // With #31, the result will be a replacement character if context is empty. + return cx === "\uFFFE"; + } + + /** + * Function contextMatch KCM + * Scope Public + * @param {number} n Number of characters to move back from caret + * @param {Object} Ptarg Focused element + * @param {string} val String to match + * @param {number} ln Number of characters to return + * @return {boolean} True if selected context matches val + * Description Test keyboard context for match + */ + contextMatch(n: number, outputTarget: OutputTarget, val: string, ln: number): boolean { + var cx=this.context(n, ln, outputTarget); + if(cx === val) { + return true; // I3318 } + outputTarget.deadkeys().resetMatched(); // I3318 + return false; + } - /** - * Builds the *cached or uncached* keyboard context for a specified range, relative to caret - * - * @param {number} n Number of characters to move back from caret - * @param {number} ln Number of characters to return - * @param {Object} Pelem Element to work with (must be currently focused element) - * @return {Array} Context array (of strings and numbers) - */ - private _BuildExtendedContext(n: number, ln: number, outputTarget: OutputTarget): CachedExEntry { - var cache: CachedExEntry = this.cachedContextEx.get(n, ln); - if(cache !== null) { - return cache; - } else { - // By far the easiest way to correctly build what we want is to start from the right and work to what we need. - // We may have done it for a similar cursor position before. - cache = this.cachedContextEx.get(n, n); - if(cache === null) { - // First, let's make sure we have a cloned, sorted copy of the deadkey array. - let unmatchedDeadkeys = outputTarget.deadkeys().toSortedArray(); // Is reverse-order sorted for us already. - - // Time to build from scratch! - var index = 0; - cache = { valContext: [], deadContext: []}; - while(cache.valContext.length < n) { - // As adapted from `deadkeyMatch`. - var sp = outputTarget.getDeadkeyCaret(); - var deadPos = sp - index; - if(unmatchedDeadkeys.length > 0 && unmatchedDeadkeys[0].p > deadPos) { - // We have deadkeys at the right-hand side of the caret! They don't belong in the context, so pop 'em off. - unmatchedDeadkeys.splice(0, 1); - continue; - } else if(unmatchedDeadkeys.length > 0 && unmatchedDeadkeys[0].p == deadPos) { - // Take the deadkey. - cache.deadContext[n-cache.valContext.length-1] = unmatchedDeadkeys[0]; - cache.valContext = ([unmatchedDeadkeys[0].d] as (string|number)[]).concat(cache.valContext); - unmatchedDeadkeys.splice(0, 1); - } else { - // Take the character. We get "\ufffe" if it doesn't exist. - var kc = this.context(++index, 1, outputTarget); - cache.valContext = ([kc] as (string|number)[]).concat(cache.valContext); - } + /** + * Builds the *cached or uncached* keyboard context for a specified range, relative to caret + * + * @param {number} n Number of characters to move back from caret + * @param {number} ln Number of characters to return + * @param {Object} Pelem Element to work with (must be currently focused element) + * @return {Array} Context array (of strings and numbers) + */ + private _BuildExtendedContext(n: number, ln: number, outputTarget: OutputTarget): CachedExEntry { + var cache: CachedExEntry = this.cachedContextEx.get(n, ln); + if(cache !== null) { + return cache; + } else { + // By far the easiest way to correctly build what we want is to start from the right and work to what we need. + // We may have done it for a similar cursor position before. + cache = this.cachedContextEx.get(n, n); + if(cache === null) { + // First, let's make sure we have a cloned, sorted copy of the deadkey array. + let unmatchedDeadkeys = outputTarget.deadkeys().toSortedArray(); // Is reverse-order sorted for us already. + + // Time to build from scratch! + var index = 0; + cache = { valContext: [], deadContext: []}; + while(cache.valContext.length < n) { + // As adapted from `deadkeyMatch`. + var sp = outputTarget.getDeadkeyCaret(); + var deadPos = sp - index; + if(unmatchedDeadkeys.length > 0 && unmatchedDeadkeys[0].p > deadPos) { + // We have deadkeys at the right-hand side of the caret! They don't belong in the context, so pop 'em off. + unmatchedDeadkeys.splice(0, 1); + continue; + } else if(unmatchedDeadkeys.length > 0 && unmatchedDeadkeys[0].p == deadPos) { + // Take the deadkey. + cache.deadContext[n-cache.valContext.length-1] = unmatchedDeadkeys[0]; + cache.valContext = ([unmatchedDeadkeys[0].d] as (string|number)[]).concat(cache.valContext); + unmatchedDeadkeys.splice(0, 1); + } else { + // Take the character. We get "\ufffe" if it doesn't exist. + var kc = this.context(++index, 1, outputTarget); + cache.valContext = ([kc] as (string|number)[]).concat(cache.valContext); } - this.cachedContextEx.set(n, n, cache); } + this.cachedContextEx.set(n, n, cache); + } - // Now that we have the cache... - var subCache = cache; - subCache.valContext = subCache.valContext.slice(0, ln); - for(var i=0; i < subCache.valContext.length; i++) { - if(subCache[i] == '\ufffe') { - subCache.valContext.splice(0, 1); - subCache.deadContext.splice(0, 1); - } + // Now that we have the cache... + var subCache = cache; + subCache.valContext = subCache.valContext.slice(0, ln); + for(var i=0; i < subCache.valContext.length; i++) { + if(subCache[i] == '\ufffe') { + subCache.valContext.splice(0, 1); + subCache.deadContext.splice(0, 1); } + } - if(subCache.valContext.length == 0) { - subCache.valContext = ['\ufffe']; - subCache.deadContext = []; - } + if(subCache.valContext.length == 0) { + subCache.valContext = ['\ufffe']; + subCache.deadContext = []; + } - this.cachedContextEx.set(n, ln, subCache); + this.cachedContextEx.set(n, ln, subCache); - return subCache; - } + return subCache; } + } - /** - * Function fullContextMatch KFCM - * Scope Private - * @param {number} n Number of characters to move back from caret - * @param {Object} Ptarg Focused element - * @param {Array} rule An array of ContextEntries to match. - * @return {boolean} True if the fully-specified rule context matches the current KMW state. - * - * A KMW 10+ function designed to bring KMW closer to Keyman Desktop functionality, - * near-directly modeling (externally) the compiled form of Desktop rules' context section. - */ - fullContextMatch(n: number, outputTarget: OutputTarget, rule: ContextEntry[]): boolean { - // Stage one: build the context index map. - var fullContext = this._BuildExtendedContext(n, rule.length, outputTarget); - this.ruleContextEx = this.cachedContextEx.clone(); - var context = fullContext.valContext; - var deadContext = fullContext.deadContext; - - var mismatch = false; - - // This symbol internally indicates lack of context in a position. (See KC_) - const NUL_CONTEXT = "\uFFFE"; - - var assertNever = function(x: never): never { - // Could be accessed by improperly handwritten calls to `fullContextMatch`. - throw new Error("Unexpected object in fullContextMatch specification: " + x); - } + /** + * Function fullContextMatch KFCM + * Scope Private + * @param {number} n Number of characters to move back from caret + * @param {Object} Ptarg Focused element + * @param {Array} rule An array of ContextEntries to match. + * @return {boolean} True if the fully-specified rule context matches the current KMW state. + * + * A KMW 10+ function designed to bring KMW closer to Keyman Desktop functionality, + * near-directly modeling (externally) the compiled form of Desktop rules' context section. + */ + fullContextMatch(n: number, outputTarget: OutputTarget, rule: ContextEntry[]): boolean { + // Stage one: build the context index map. + var fullContext = this._BuildExtendedContext(n, rule.length, outputTarget); + this.ruleContextEx = this.cachedContextEx.clone(); + var context = fullContext.valContext; + var deadContext = fullContext.deadContext; + + var mismatch = false; + + // This symbol internally indicates lack of context in a position. (See KC_) + const NUL_CONTEXT = "\uFFFE"; + + var assertNever = function(x: never): never { + // Could be accessed by improperly handwritten calls to `fullContextMatch`. + throw new Error("Unexpected object in fullContextMatch specification: " + x); + } - // Stage two: time to match against the rule specified. - for(var i=0; i < rule.length; i++) { - if(typeof rule[i] == 'string') { - var str = rule[i] as string; - if(str !== context[i]) { - mismatch = true; + // Stage two: time to match against the rule specified. + for(var i=0; i < rule.length; i++) { + if(typeof rule[i] == 'string') { + var str = rule[i] as string; + if(str !== context[i]) { + mismatch = true; + break; + } + } else { + // TypeScript needs a cast to this intermediate type to do its discriminated union magic. + var r = rule[i] as ContextNonCharEntry; + switch(r.t) { + case 'd': + // We still need to set a flag here; + if(r['d'] !== context[i]) { + mismatch = true; + } else { + deadContext[i].set(); + } break; - } - } else { - // TypeScript needs a cast to this intermediate type to do its discriminated union magic. - var r = rule[i] as ContextNonCharEntry; - switch(r.t) { - case 'd': - // We still need to set a flag here; - if(r['d'] !== context[i]) { - mismatch = true; - } else { - deadContext[i].set(); - } - break; - case 'a': - var lookup: KeyboardStoreElement; - - if(typeof context[i] == 'string') { - lookup = context[i] as string; - } else { - lookup = {'t': 'd', 'd': context[i] as number}; - } + case 'a': + var lookup: KeyboardStoreElement; - var result = this.any(i, lookup, r.a); - - if(!r.n) { // If it's a standard 'any'... - if(!result) { - mismatch = true; - } else if(deadContext[i] !== undefined) { - // It's a deadkey match, so indicate that. - deadContext[i].set(); - } - // 'n' for 'notany'. - // - if `result === true`, `any` would match: this should thus fail. - // - if `context[i] === NUL_CONTEXT`, `notany` should not match. - } else if(r.n && (result || context[i] === NUL_CONTEXT)) { - mismatch = true; - } - break; - case 'i': - // The context will never hold a 'beep.' - var ch = this._Index(r.i, r.o) as string | RuleDeadkey; + if(typeof context[i] == 'string') { + lookup = context[i] as string; + } else { + lookup = {'t': 'd', 'd': context[i] as number}; + } - if(ch !== undefined && (typeof(ch) == 'string' ? ch : ch.d) !== context[i]) { - mismatch = true; - } else if(deadContext[i] !== undefined) { - deadContext[i].set(); - } - break; - case 'c': - if(context[r.c - 1] !== context[i]) { + var result = this.any(i, lookup, r.a); + + if(!r.n) { // If it's a standard 'any'... + if(!result) { mismatch = true; } else if(deadContext[i] !== undefined) { + // It's a deadkey match, so indicate that. deadContext[i].set(); } - break; - case 'n': - // \uFFFE is the internal 'no context here sentinel'. - if(context[i] != NUL_CONTEXT) { - mismatch = true; - } - break; - default: - assertNever(r); - } + // 'n' for 'notany'. + // - if `result === true`, `any` would match: this should thus fail. + // - if `context[i] === NUL_CONTEXT`, `notany` should not match. + } else if(r.n && (result || context[i] === NUL_CONTEXT)) { + mismatch = true; + } + break; + case 'i': + // The context will never hold a 'beep.' + var ch = this._Index(r.i, r.o) as string | RuleDeadkey; + + if(ch !== undefined && (typeof(ch) == 'string' ? ch : ch.d) !== context[i]) { + mismatch = true; + } else if(deadContext[i] !== undefined) { + deadContext[i].set(); + } + break; + case 'c': + if(context[r.c - 1] !== context[i]) { + mismatch = true; + } else if(deadContext[i] !== undefined) { + deadContext[i].set(); + } + break; + case 'n': + // \uFFFE is the internal 'no context here sentinel'. + if(context[i] != NUL_CONTEXT) { + mismatch = true; + } + break; + default: + assertNever(r); } } + } - if(mismatch) { - // Reset the matched 'any' indices, if any. - outputTarget.deadkeys().resetMatched(); - this._AnyIndices = []; - } - - return !mismatch; + if(mismatch) { + // Reset the matched 'any' indices, if any. + outputTarget.deadkeys().resetMatched(); + this._AnyIndices = []; } - /** - * Function KIK - * Scope Public - * @param {Object} e keystroke event - * @return {boolean} true if keypress event - * Description Test if event as a keypress event - */ - isKeypress(e: KeyEvent): boolean { - if(this.activeKeyboard.isMnemonic) { // I1380 - support KIK for positional layouts - return !e.LisVirtualKey; // will now return true for U_xxxx keys, but not for T_xxxx keys - } else { - return KeyMapping._USKeyCodeToCharCode(e) ? true : false; // I1380 - support KIK for positional layouts - } + return !mismatch; + } + + /** + * Function KIK + * Scope Public + * @param {Object} e keystroke event + * @return {boolean} true if keypress event + * Description Test if event as a keypress event + */ + isKeypress(e: KeyEvent): boolean { + if(this.activeKeyboard.isMnemonic) { // I1380 - support KIK for positional layouts + return !e.LisVirtualKey; // will now return true for U_xxxx keys, but not for T_xxxx keys + } else { + return KeyMapping._USKeyCodeToCharCode(e) ? true : false; // I1380 - support KIK for positional layouts } + } - /** - * Maps a KeyEvent's modifiers to their appropriate value for key-rule evaluation - * based on the rule's specified target modifier set. - * - * Mostly used to correct chiral OSK-keys targeting non-chiral rules. - * @param e The source KeyEvent - * @returns - */ - private static matchModifiersToRuleChirality(eventModifiers: number, targetModifierMask: number): number { - const CHIRAL_ALT = Codes.modifierCodes["LALT"] | Codes.modifierCodes["RALT"]; - const CHIRAL_CTRL = Codes.modifierCodes["LCTRL"] | Codes.modifierCodes["RCTRL"]; - - let modifiers = eventModifiers; - - // If the target rule does not use chiral alt... - if(!(targetModifierMask & CHIRAL_ALT)) { - const altIntersection = modifiers & CHIRAL_ALT; - - if(altIntersection) { - // Undo the chiral part and replace with non-chiral. - modifiers ^= altIntersection | Codes.modifierCodes["ALT"]; - } - } + /** + * Maps a KeyEvent's modifiers to their appropriate value for key-rule evaluation + * based on the rule's specified target modifier set. + * + * Mostly used to correct chiral OSK-keys targeting non-chiral rules. + * @param e The source KeyEvent + * @returns + */ + private static matchModifiersToRuleChirality(eventModifiers: number, targetModifierMask: number): number { + const CHIRAL_ALT = Codes.modifierCodes["LALT"] | Codes.modifierCodes["RALT"]; + const CHIRAL_CTRL = Codes.modifierCodes["LCTRL"] | Codes.modifierCodes["RCTRL"]; - // If the target rule does not use chiral ctrl... - if(!(targetModifierMask & CHIRAL_CTRL)) { - const ctrlIntersection = modifiers & CHIRAL_CTRL; + let modifiers = eventModifiers; - if(ctrlIntersection) { - // Undo the chiral part and replace with non-chiral. - modifiers ^= ctrlIntersection | Codes.modifierCodes["CTRL"]; - } - } + // If the target rule does not use chiral alt... + if(!(targetModifierMask & CHIRAL_ALT)) { + const altIntersection = modifiers & CHIRAL_ALT; - return modifiers; + if(altIntersection) { + // Undo the chiral part and replace with non-chiral. + modifiers ^= altIntersection | Codes.modifierCodes["ALT"]; + } } - /** - * Function keyMatch KKM - * Scope Public - * @param {Object} e keystroke event - * @param {number} Lruleshift - * @param {number} Lrulekey - * @return {boolean} True if key matches rule - * Description Test keystroke with modifiers against rule - */ - keyMatch(e: KeyEvent, Lruleshift:number, Lrulekey:number): boolean { - var retVal = false; // I3318 - var keyCode = (e.Lcode == 173 ? 189 : e.Lcode); //I3555 (Firefox hyphen issue) - - let bitmask = this.activeKeyboard.modifierBitmask; - let Codes = com.keyman.text.Codes; - var modifierBitmask = bitmask & Codes.modifierBitmasks["ALL"]; - var stateBitmask = bitmask & Codes.stateBitmasks["ALL"]; - - const eventModifiers = KeyboardInterface.matchModifiersToRuleChirality(e.Lmodifiers, Lruleshift); - - if(e.vkCode > 255) { - keyCode = e.vkCode; // added to support extended (touch-hold) keys for mnemonic layouts - } + // If the target rule does not use chiral ctrl... + if(!(targetModifierMask & CHIRAL_CTRL)) { + const ctrlIntersection = modifiers & CHIRAL_CTRL; - if(e.LisVirtualKey || keyCode > 255) { - if((Lruleshift & 0x4000) == 0x4000 || (keyCode > 255)) { // added keyCode test to support extended keys - retVal = ((Lrulekey == keyCode) && ((Lruleshift & modifierBitmask) == eventModifiers)); //I3318, I3555 - retVal = retVal && this.stateMatch(e, Lruleshift & stateBitmask); - } - } else if((Lruleshift & 0x4000) == 0) { - retVal = (keyCode == Lrulekey); // I3318, I3555 - } - if(!retVal) { - this.activeTargetOutput.deadkeys().resetMatched(); // I3318 + if(ctrlIntersection) { + // Undo the chiral part and replace with non-chiral. + modifiers ^= ctrlIntersection | Codes.modifierCodes["CTRL"]; } - return retVal; // I3318 - }; - - /** - * Function stateMatch KSM - * Scope Public - * @param {Object} e keystroke event - * @param {number} Lstate - * Description Test keystroke against state key rules - */ - stateMatch(e: KeyEvent, Lstate: number) { - return ((Lstate & e.Lstates) == Lstate); } - /** - * Function keyInformation KKI - * Scope Public - * @param {Object} e - * @return {Object} Object with event's virtual key flag, key code, and modifiers - * Description Get object with extended key event information - */ - keyInformation(e: KeyEvent): KeyInformation { - var ei = new KeyInformation(); - ei['vk'] = e.LisVirtualKey; - ei['code'] = e.Lcode; - ei['modifiers'] = e.Lmodifiers; - return ei; - }; - - /** - * Function deadkeyMatch KDM - * Scope Public - * @param {number} n offset from current cursor position - * @param {Object} Ptarg target element - * @param {number} d deadkey - * @return {boolean} True if deadkey found selected context matches val - * Description Match deadkey at current cursor position - */ - deadkeyMatch(n: number, outputTarget: OutputTarget, d: number): boolean { - return outputTarget.hasDeadkeyMatch(n, d); + return modifiers; + } + + /** + * Function keyMatch KKM + * Scope Public + * @param {Object} e keystroke event + * @param {number} Lruleshift + * @param {number} Lrulekey + * @return {boolean} True if key matches rule + * Description Test keystroke with modifiers against rule + */ + keyMatch(e: KeyEvent, Lruleshift:number, Lrulekey:number): boolean { + var retVal = false; // I3318 + var keyCode = (e.Lcode == 173 ? 189 : e.Lcode); //I3555 (Firefox hyphen issue) + + let bitmask = this.activeKeyboard.modifierBitmask; + var modifierBitmask = bitmask & Codes.modifierBitmasks["ALL"]; + var stateBitmask = bitmask & Codes.stateBitmasks["ALL"]; + + const eventModifiers = KeyboardInterface.matchModifiersToRuleChirality(e.Lmodifiers, Lruleshift); + + if(e.vkCode > 255) { + keyCode = e.vkCode; // added to support extended (touch-hold) keys for mnemonic layouts } - /** - * Function beep KB - * Scope Public - * @param {Object} Pelem element to flash - * Description Flash body as substitute for audible beep; notify embedded device to vibrate - */ - beep(outputTarget: OutputTarget): void { - this.resetContextCache(); - - // Denote as part of the matched rule's behavior. - this.ruleBehavior.beep = true; + if(e.LisVirtualKey || keyCode > 255) { + if((Lruleshift & 0x4000) == 0x4000 || (keyCode > 255)) { // added keyCode test to support extended keys + retVal = ((Lrulekey == keyCode) && ((Lruleshift & modifierBitmask) == eventModifiers)); //I3318, I3555 + retVal = retVal && this.stateMatch(e, Lruleshift & stateBitmask); + } + } else if((Lruleshift & 0x4000) == 0) { + retVal = (keyCode == Lrulekey); // I3318, I3555 + } + if(!retVal) { + this.activeTargetOutput.deadkeys().resetMatched(); // I3318 } + return retVal; // I3318 + }; - _ExplodeStore(store: KeyboardStore): ComplexKeyboardStore { - if(typeof(store) == 'string') { - let cachedStores = this.activeKeyboard.explodedStores; + /** + * Function stateMatch KSM + * Scope Public + * @param {Object} e keystroke event + * @param {number} Lstate + * Description Test keystroke against state key rules + */ + stateMatch(e: KeyEvent, Lstate: number) { + return ((Lstate & e.Lstates) == Lstate); + } - // Is the result cached? - if(cachedStores[store]) { - return cachedStores[store]; - } + /** + * Function keyInformation KKI + * Scope Public + * @param {Object} e + * @return {Object} Object with event's virtual key flag, key code, and modifiers + * Description Get object with extended key event information + */ + keyInformation(e: KeyEvent): KeyInformation { + var ei = new KeyInformation(); + ei['vk'] = e.LisVirtualKey; + ei['code'] = e.Lcode; + ei['modifiers'] = e.Lmodifiers; + return ei; + }; - // Nope, so let's build its cache. - var result: ComplexKeyboardStore = []; - for(var i=0; i < store._kmwLength(); i++) { - result.push(store._kmwCharAt(i)); - } + /** + * Function deadkeyMatch KDM + * Scope Public + * @param {number} n offset from current cursor position + * @param {Object} Ptarg target element + * @param {number} d deadkey + * @return {boolean} True if deadkey found selected context matches val + * Description Match deadkey at current cursor position + */ + deadkeyMatch(n: number, outputTarget: OutputTarget, d: number): boolean { + return outputTarget.hasDeadkeyMatch(n, d); + } - // Cache the result for later! - cachedStores[store] = result; - return result; - } else { - return store; + /** + * Function beep KB + * Scope Public + * @param {Object} Pelem element to flash + * Description Flash body as substitute for audible beep; notify embedded device to vibrate + */ + beep(outputTarget: OutputTarget): void { + this.resetContextCache(); + + // Denote as part of the matched rule's behavior. + this.ruleBehavior.beep = true; + } + + _ExplodeStore(store: KeyboardStore): ComplexKeyboardStore { + if(typeof(store) == 'string') { + let cachedStores = this.activeKeyboard.explodedStores; + + // Is the result cached? + if(cachedStores[store]) { + return cachedStores[store]; } - } - /** - * Function any KA - * Scope Public - * @param {number} n character position (index) - * @param {string} ch character to find in string - * @param {string} s 'any' string - * @return {boolean} True if character found in 'any' string, sets index accordingly - * Description Test for character matching - */ - any(n: number, ch: KeyboardStoreElement, s: KeyboardStore): boolean { - if(ch == '') { - return false; + // Nope, so let's build its cache. + var result: ComplexKeyboardStore = []; + for(var i=0; i < store._kmwLength(); i++) { + result.push(store._kmwCharAt(i)); } - s = this._ExplodeStore(s); - var Lix = -1; - for(var i=0; i < s.length; i++) { - if(typeof(s[i]) == 'string') { - if(s[i] == ch) { - Lix = i; - break; - } - } else if(s[i]['d'] === ch['d']) { + // Cache the result for later! + cachedStores[store] = result; + return result; + } else { + return store; + } + } + + /** + * Function any KA + * Scope Public + * @param {number} n character position (index) + * @param {string} ch character to find in string + * @param {string} s 'any' string + * @return {boolean} True if character found in 'any' string, sets index accordingly + * Description Test for character matching + */ + any(n: number, ch: KeyboardStoreElement, s: KeyboardStore): boolean { + if(ch == '') { + return false; + } + + s = this._ExplodeStore(s); + var Lix = -1; + for(var i=0; i < s.length; i++) { + if(typeof(s[i]) == 'string') { + if(s[i] == ch) { Lix = i; break; } + } else if(s[i]['d'] === ch['d']) { + Lix = i; + break; } - this._AnyIndices[n] = Lix; - return Lix >= 0; } + this._AnyIndices[n] = Lix; + return Lix >= 0; + } - /** - * Function _Index - * Scope Public - * @param {string} Ps string - * @param {number} Pn index - * Description Returns the character from a store string according to the offset in the index array - */ - _Index(Ps: KeyboardStore, Pn: number): KeyboardStoreElement { - Ps = this._ExplodeStore(Ps); - - if(this._AnyIndices[Pn-1] < Ps.length) { //I3319 - return Ps[this._AnyIndices[Pn-1]]; - } else { - /* Should not be possible for a compiled keyboard, but may arise - * during the development of handwritten keyboards. - */ - console.warn("Unmatched contextual index() statement detected in rule with index " + Pn + "!"); - return ""; - } + /** + * Function _Index + * Scope Public + * @param {string} Ps string + * @param {number} Pn index + * Description Returns the character from a store string according to the offset in the index array + */ + _Index(Ps: KeyboardStore, Pn: number): KeyboardStoreElement { + Ps = this._ExplodeStore(Ps); + + if(this._AnyIndices[Pn-1] < Ps.length) { //I3319 + return Ps[this._AnyIndices[Pn-1]]; + } else { + /* Should not be possible for a compiled keyboard, but may arise + * during the development of handwritten keyboards. + */ + console.warn("Unmatched contextual index() statement detected in rule with index " + Pn + "!"); + return ""; } + } - /** - * Function indexOutput KIO - * Scope Public - * @param {number} Pdn no of character to overwrite (delete) - * @param {string} Ps string - * @param {number} Pn index - * @param {Object} Pelem element to output to - * Description Output a character selected from the string according to the offset in the index array - */ - indexOutput(Pdn: number, Ps: KeyboardStore, Pn: number, outputTarget: OutputTarget): void { - this.resetContextCache(); - - var assertNever = function(x: never): never { - // Could be accessed by improperly handwritten calls to `fullContextMatch`. - throw new Error("Unexpected object in fullContextMatch specification: " + x); - } + /** + * Function indexOutput KIO + * Scope Public + * @param {number} Pdn no of character to overwrite (delete) + * @param {string} Ps string + * @param {number} Pn index + * @param {Object} Pelem element to output to + * Description Output a character selected from the string according to the offset in the index array + */ + indexOutput(Pdn: number, Ps: KeyboardStore, Pn: number, outputTarget: OutputTarget): void { + this.resetContextCache(); - var indexChar = this._Index(Ps, Pn); - if(indexChar !== "") { - if(typeof indexChar == 'string' ) { - this.output(Pdn, outputTarget, indexChar); //I3319 - } else if(indexChar['t']) { - var storeEntry = indexChar as StoreNonCharEntry; - - switch(storeEntry.t) { - case 'b': // Beep commands may appear within stores. - this.beep(outputTarget); - break; - case 'd': - this.deadkeyOutput(Pdn, outputTarget, indexChar['d']); - break; - default: - assertNever(storeEntry); - } - } else { // For keyboards developed during 10.0's alpha phase - t:'d' was assumed. - this.deadkeyOutput(Pdn, outputTarget, indexChar['d']); + var assertNever = function(x: never): never { + // Could be accessed by improperly handwritten calls to `fullContextMatch`. + throw new Error("Unexpected object in fullContextMatch specification: " + x); + } + + var indexChar = this._Index(Ps, Pn); + if(indexChar !== "") { + if(typeof indexChar == 'string' ) { + this.output(Pdn, outputTarget, indexChar); //I3319 + } else if(indexChar['t']) { + var storeEntry = indexChar as StoreNonCharEntry; + + switch(storeEntry.t) { + case 'b': // Beep commands may appear within stores. + this.beep(outputTarget); + break; + case 'd': + this.deadkeyOutput(Pdn, outputTarget, indexChar['d']); + break; + default: + assertNever(storeEntry); } + } else { // For keyboards developed during 10.0's alpha phase - t:'d' was assumed. + this.deadkeyOutput(Pdn, outputTarget, indexChar['d']); } } + } - /** - * Function deleteContext KDC - * Scope Public - * @param {number} dn number of context entries to overwrite - * @param {Object} Pelem element to output to - * @param {string} s string to output - * Description Keyboard output - */ - deleteContext(dn: number, outputTarget: OutputTarget): void { - var context: CachedExEntry; - - // We want to control exactly which deadkeys get removed. - if(dn > 0) { - context = this._BuildExtendedContext(dn, dn, outputTarget); - let nulCount = 0; - - for(var i=0; i < context.valContext.length; i++) { - var dk = context.deadContext[i]; - - if(dk) { - // Remove deadkey in context. - outputTarget.deadkeys().remove(dk); - - // Reduce our reported context size. - dn--; - } else if(context.valContext[i] == "\uFFFE") { - // Count any `nul` sentinels that would contribute to our deletion count. - nulCount++; - } + /** + * Function deleteContext KDC + * Scope Public + * @param {number} dn number of context entries to overwrite + * @param {Object} Pelem element to output to + * @param {string} s string to output + * Description Keyboard output + */ + deleteContext(dn: number, outputTarget: OutputTarget): void { + var context: CachedExEntry; + + // We want to control exactly which deadkeys get removed. + if(dn > 0) { + context = this._BuildExtendedContext(dn, dn, outputTarget); + let nulCount = 0; + + for(var i=0; i < context.valContext.length; i++) { + var dk = context.deadContext[i]; + + if(dk) { + // Remove deadkey in context. + outputTarget.deadkeys().remove(dk); + + // Reduce our reported context size. + dn--; + } else if(context.valContext[i] == "\uFFFE") { + // Count any `nul` sentinels that would contribute to our deletion count. + nulCount++; } + } - // Prevent attempts to delete nul sentinels, as they don't exist in the actual context. - // (Addresses regression from KMW v 12.0 paired with Developer bug through same version) - let contextLength = context.valContext.length - nulCount; - if(dn > contextLength) { - dn = contextLength; - } + // Prevent attempts to delete nul sentinels, as they don't exist in the actual context. + // (Addresses regression from KMW v 12.0 paired with Developer bug through same version) + let contextLength = context.valContext.length - nulCount; + if(dn > contextLength) { + dn = contextLength; } + } - // If a matched deadkey hasn't been deleted, we don't WANT to delete it. - outputTarget.deadkeys().resetMatched(); + // If a matched deadkey hasn't been deleted, we don't WANT to delete it. + outputTarget.deadkeys().resetMatched(); - // Why reinvent the wheel? Delete the remaining characters by 'inserting a blank string'. - this.output(dn, outputTarget, ''); - } + // Why reinvent the wheel? Delete the remaining characters by 'inserting a blank string'. + this.output(dn, outputTarget, ''); + } - /** - * Function output KO - * Scope Public - * @param {number} dn number of characters to overwrite - * @param {Object} Pelem element to output to - * @param {string} s string to output - * Description Keyboard output - */ - output(dn: number, outputTarget: OutputTarget, s:string): void { - this.resetContextCache(); - - outputTarget.saveProperties(); - outputTarget.clearSelection(); - outputTarget.deadkeys().deleteMatched(); // I3318 - if(dn >= 0) { - // Automatically manages affected deadkey positions. Does not delete deadkeys b/c legacy behavior support. - outputTarget.deleteCharsBeforeCaret(dn); - } - // Automatically manages affected deadkey positions. - outputTarget.insertTextBeforeCaret(s); - outputTarget.restoreProperties(); + /** + * Function output KO + * Scope Public + * @param {number} dn number of characters to overwrite + * @param {Object} Pelem element to output to + * @param {string} s string to output + * Description Keyboard output + */ + output(dn: number, outputTarget: OutputTarget, s:string): void { + this.resetContextCache(); + + outputTarget.saveProperties(); + outputTarget.clearSelection(); + outputTarget.deadkeys().deleteMatched(); // I3318 + if(dn >= 0) { + // Automatically manages affected deadkey positions. Does not delete deadkeys b/c legacy behavior support. + outputTarget.deleteCharsBeforeCaret(dn); } + // Automatically manages affected deadkey positions. + outputTarget.insertTextBeforeCaret(s); + outputTarget.restoreProperties(); + } - /** - * `contextExOutput` function emits the character or object at `contextOffset` from the - * current matched rule's context. Introduced in Keyman 14.0, in order to resolve a - * gap between desktop and web core functionality for context(n) matching on notany(). - * See #917 for additional detail. - * @alias KCXO - * @public - * @param {number} Pdn number of characters to delete left of cursor - * @param {OutputTarget} outputTarget target to output to - * @param {number} contextLength length of current rule context to retrieve - * @param {number} contextOffset offset from start of current rule context, 1-based - */ - contextExOutput(Pdn: number, outputTarget: OutputTarget, contextLength: number, contextOffset: number): void { - this.resetContextCache(); - - if(Pdn >= 0) { - this.output(Pdn, outputTarget, ""); - } + /** + * `contextExOutput` function emits the character or object at `contextOffset` from the + * current matched rule's context. Introduced in Keyman 14.0, in order to resolve a + * gap between desktop and web core functionality for context(n) matching on notany(). + * See #917 for additional detail. + * @alias KCXO + * @public + * @param {number} Pdn number of characters to delete left of cursor + * @param {OutputTarget} outputTarget target to output to + * @param {number} contextLength length of current rule context to retrieve + * @param {number} contextOffset offset from start of current rule context, 1-based + */ + contextExOutput(Pdn: number, outputTarget: OutputTarget, contextLength: number, contextOffset: number): void { + this.resetContextCache(); - const context = this.ruleContextEx.get(contextLength, contextLength); - const dk = context.deadContext[contextOffset-1], vc = context.valContext[contextOffset-1]; - if(dk) { - outputTarget.insertDeadkeyBeforeCaret(dk.d); - } else if(typeof vc == 'string') { - this.output(-1, outputTarget, vc); - } else { - throw new Error("contextExOutput: should never be a numeric valContext with no corresponding deadContext"); - } + if(Pdn >= 0) { + this.output(Pdn, outputTarget, ""); } - /** - * Function deadkeyOutput KDO - * Scope Public - * @param {number} Pdn no of character to overwrite (delete) - * @param {Object} Pelem element to output to - * @param {number} Pd deadkey id - * Description Record a deadkey at current cursor position, deleting Pdn characters first - */ - deadkeyOutput(Pdn: number, outputTarget: OutputTarget, Pd: number): void { - this.resetContextCache(); - - if(Pdn >= 0) { - this.output(Pdn, outputTarget,""); //I3318 corrected to >= - } - - outputTarget.insertDeadkeyBeforeCaret(Pd); - // _DebugDeadKeys(Pelem, 'KDeadKeyOutput: dn='+Pdn+'; deadKey='+Pd); + const context = this.ruleContextEx.get(contextLength, contextLength); + const dk = context.deadContext[contextOffset-1], vc = context.valContext[contextOffset-1]; + if(dk) { + outputTarget.insertDeadkeyBeforeCaret(dk.d); + } else if(typeof vc == 'string') { + this.output(-1, outputTarget, vc); + } else { + throw new Error("contextExOutput: should never be a numeric valContext with no corresponding deadContext"); } + } - /** - * KIFS compares the content of a system store with a string value - * - * @param {number} systemId ID of the system store to test (only TSS_LAYER currently supported) - * @param {string} strValue String value to compare to - * @param {Object} Pelem Currently active element (may be needed by future tests) - * @return {boolean} True if the test succeeds - */ - ifStore(systemId: number, strValue: string, outputTarget: OutputTarget): boolean { - var result=true; - let store = this.systemStores[systemId]; - if(store) { - result = store.matches(strValue); - } - return result; //Moved from previous line, now supports layer selection, Build 350 + /** + * Function deadkeyOutput KDO + * Scope Public + * @param {number} Pdn no of character to overwrite (delete) + * @param {Object} Pelem element to output to + * @param {number} Pd deadkey id + * Description Record a deadkey at current cursor position, deleting Pdn characters first + */ + deadkeyOutput(Pdn: number, outputTarget: OutputTarget, Pd: number): void { + this.resetContextCache(); + + if(Pdn >= 0) { + this.output(Pdn, outputTarget,""); //I3318 corrected to >= } - /** - * KSETS sets the value of a system store to a string - * - * @param {number} systemId ID of the system store to set (only TSS_LAYER currently supported) - * @param {string} strValue String to set as the system store content - * @param {Object} Pelem Currently active element (may be needed in future tests) - * @return {boolean} True if command succeeds - * (i.e. for TSS_LAYER, if the layer is successfully selected) - * - * Note that option/variable stores are instead set within keyboard script code, as they only - * affect keyboard behavior. - */ - setStore(systemId: number, strValue: string, outputTarget: OutputTarget): boolean { - this.resetContextCache(); - // Unique case: we only allow set(&layer) ops from keyboard rules triggered by touch OSKs. - if(systemId == KeyboardInterface.TSS_LAYER && this.activeDevice.touchable) { - // Denote the changed store as part of the matched rule's behavior. - this.ruleBehavior.setStore[systemId] = strValue; - } else { - return false; - } + outputTarget.insertDeadkeyBeforeCaret(Pd); + // _DebugDeadKeys(Pelem, 'KDeadKeyOutput: dn='+Pdn+'; deadKey='+Pd); + } + + /** + * KIFS compares the content of a system store with a string value + * + * @param {number} systemId ID of the system store to test (only TSS_LAYER currently supported) + * @param {string} strValue String value to compare to + * @param {Object} Pelem Currently active element (may be needed by future tests) + * @return {boolean} True if the test succeeds + */ + ifStore(systemId: number, strValue: string, outputTarget: OutputTarget): boolean { + var result=true; + let store = this.systemStores[systemId]; + if(store) { + result = store.matches(strValue); } + return result; //Moved from previous line, now supports layer selection, Build 350 + } - /** - * Load an option store value from a cookie or default value - * - * @param {string} kbdName keyboard internal name - * @param {string} storeName store (option) name, embedded in cookie name - * @param {string} dfltValue default value - * @return {string} current or default option value - * - * This will only ever be called when the keyboard is loaded, as it is used by keyboards - * to initialize a store value on the keyboard's script object. - */ - loadStore(kbdName: string, storeName:string, dfltValue:string): string { - this.resetContextCache(); - if(this.variableStoreSerializer) { - let cValue = this.variableStoreSerializer.loadStore(kbdName, storeName); - return cValue[storeName] || dfltValue; - } else { - return dfltValue; - } + /** + * KSETS sets the value of a system store to a string + * + * @param {number} systemId ID of the system store to set (only TSS_LAYER currently supported) + * @param {string} strValue String to set as the system store content + * @param {Object} Pelem Currently active element (may be needed in future tests) + * @return {boolean} True if command succeeds + * (i.e. for TSS_LAYER, if the layer is successfully selected) + * + * Note that option/variable stores are instead set within keyboard script code, as they only + * affect keyboard behavior. + */ + setStore(systemId: number, strValue: string, outputTarget: OutputTarget): boolean { + this.resetContextCache(); + // Unique case: we only allow set(&layer) ops from keyboard rules triggered by touch OSKs. + if(systemId == SystemStoreIDs.TSS_LAYER && this.activeDevice.touchable) { + // Denote the changed store as part of the matched rule's behavior. + this.ruleBehavior.setStore[systemId] = strValue; + } else { + return false; } + } - /** - * Save an option store value to a cookie - * - * @param {string} storeName store (option) name, embedded in cookie name - * @param {string} optValue option value to save - * @return {boolean} true if save successful - * - * Note that a keyboard will freely manipulate the value of its variable stores on the - * script object within its own code. This function's use is merely to _persist_ that - * value across sessions, providing a custom user default for later uses of the keyboard. - */ - saveStore(storeName:string, optValue:string): boolean { - this.resetContextCache(); - var kbd=this.activeKeyboard; - if(!kbd || typeof kbd.id == 'undefined' || kbd.id == '') { - return false; - } + /** + * Load an option store value from a cookie or default value + * + * @param {string} kbdName keyboard internal name + * @param {string} storeName store (option) name, embedded in cookie name + * @param {string} dfltValue default value + * @return {string} current or default option value + * + * This will only ever be called when the keyboard is loaded, as it is used by keyboards + * to initialize a store value on the keyboard's script object. + */ + loadStore(kbdName: string, storeName:string, dfltValue:string): string { + this.resetContextCache(); + if(this.variableStoreSerializer) { + let cValue = this.variableStoreSerializer.loadStore(kbdName, storeName); + return cValue[storeName] || dfltValue; + } else { + return dfltValue; + } + } - // And the lookup under that entry looks for the value under the store name, again. - let valueObj: VariableStore = {}; - valueObj[storeName] = optValue; + /** + * Save an option store value to a cookie + * + * @param {string} storeName store (option) name, embedded in cookie name + * @param {string} optValue option value to save + * @return {boolean} true if save successful + * + * Note that a keyboard will freely manipulate the value of its variable stores on the + * script object within its own code. This function's use is merely to _persist_ that + * value across sessions, providing a custom user default for later uses of the keyboard. + */ + saveStore(storeName:string, optValue:string): boolean { + this.resetContextCache(); + var kbd=this.activeKeyboard; + if(!kbd || typeof kbd.id == 'undefined' || kbd.id == '') { + return false; + } - // Null-check in case of invocation during unit-test - if(this.ruleBehavior) { - this.ruleBehavior.saveStore[storeName] = valueObj; - } else { - // We're in a unit-test environment, directly invoking this method from outside of a keyboard. - // In this case, we should immediately commit the change. - this.variableStoreSerializer.saveStore(this.activeKeyboard.id, storeName, valueObj); - } - return true; + // And the lookup under that entry looks for the value under the store name, again. + let valueObj: VariableStore = {}; + valueObj[storeName] = optValue; + + // Null-check in case of invocation during unit-test + if(this.ruleBehavior) { + this.ruleBehavior.saveStore[storeName] = valueObj; + } else { + // We're in a unit-test environment, directly invoking this method from outside of a keyboard. + // In this case, we should immediately commit the change. + this.variableStoreSerializer.saveStore(this.activeKeyboard.id, storeName, valueObj); } + return true; + } + + resetContextCache(): void { + this.cachedContext.reset(); + this.cachedContextEx.reset(); + } - resetContextCache(): void { - this.cachedContext.reset(); - this.cachedContextEx.reset(); + defaultBackspace(outputTarget: OutputTarget) { + if(outputTarget.isSelectionEmpty()) { + // Delete the character left of the caret + this.output(1, outputTarget, ""); + } else { + // Delete just the selection + this.output(0, outputTarget, ""); } + } - defaultBackspace(outputTarget: OutputTarget) { - if(outputTarget.isSelectionEmpty()) { - // Delete the character left of the caret - this.output(1, outputTarget, ""); - } else { - // Delete just the selection - this.output(0, outputTarget, ""); - } + /** + * Function processNewContextEvent + * Scope Private + * @param {Object} outputTarget The target receiving input + * @param {Object} keystroke The input keystroke (with its properties) to be mapped by the keyboard. + * Description Calls the keyboard's `begin newContext` group + * @returns {RuleBehavior} Record of commands and state changes that result from executing `begin NewContext` + */ + processNewContextEvent(outputTarget: OutputTarget, keystroke: KeyEvent): RuleBehavior { + if(!this.activeKeyboard) { + throw "No active keyboard for keystroke processing!"; } + return this.process(this.activeKeyboard.processNewContextEvent.bind(this.activeKeyboard), outputTarget, keystroke, true); + } - /** - * Function processNewContextEvent - * Scope Private - * @param {Object} outputTarget The target receiving input - * @param {Object} keystroke The input keystroke (with its properties) to be mapped by the keyboard. - * Description Calls the keyboard's `begin newContext` group - * @returns {RuleBehavior} Record of commands and state changes that result from executing `begin NewContext` - */ - processNewContextEvent(outputTarget: OutputTarget, keystroke: KeyEvent): RuleBehavior { - if(!this.activeKeyboard) { - throw "No active keyboard for keystroke processing!"; - } - return this.process(this.activeKeyboard.processNewContextEvent.bind(this.activeKeyboard), outputTarget, keystroke, true); + /** + * Function processPostKeystroke + * Scope Private + * @param {Object} outputTarget The target receiving input + * @param {Object} keystroke The input keystroke with relevant properties to be mapped by the keyboard. + * Description Calls the keyboard's `begin postKeystroke` group + * @returns {RuleBehavior} Record of commands and state changes that result from executing `begin PostKeystroke` + */ + processPostKeystroke(outputTarget: OutputTarget, keystroke: KeyEvent): RuleBehavior { + if(!this.activeKeyboard) { + throw "No active keyboard for keystroke processing!"; } + return this.process(this.activeKeyboard.processPostKeystroke.bind(this.activeKeyboard), outputTarget, keystroke, true); + } - /** - * Function processPostKeystroke - * Scope Private - * @param {Object} outputTarget The target receiving input - * @param {Object} keystroke The input keystroke with relevant properties to be mapped by the keyboard. - * Description Calls the keyboard's `begin postKeystroke` group - * @returns {RuleBehavior} Record of commands and state changes that result from executing `begin PostKeystroke` - */ - processPostKeystroke(outputTarget: OutputTarget, keystroke: KeyEvent): RuleBehavior { - if(!this.activeKeyboard) { - throw "No active keyboard for keystroke processing!"; - } - return this.process(this.activeKeyboard.processPostKeystroke.bind(this.activeKeyboard), outputTarget, keystroke, true); + /** + * Function processKeystroke + * Scope Private + * @param {Object} outputTarget The target receiving input + * @param {Object} keystroke The input keystroke (with its properties) to be mapped by the keyboard. + * Description Encapsulates calls to keyboard input processing. + * @returns {RuleBehavior} Record of commands and state changes that result from executing `begin Unicode` + */ + processKeystroke(outputTarget: OutputTarget, keystroke: KeyEvent): RuleBehavior { + if(!this.activeKeyboard) { + throw "No active keyboard for keystroke processing!"; } + return this.process(this.activeKeyboard.process.bind(this.activeKeyboard), outputTarget, keystroke, false); + } - /** - * Function processKeystroke - * Scope Private - * @param {Object} outputTarget The target receiving input - * @param {Object} keystroke The input keystroke (with its properties) to be mapped by the keyboard. - * Description Encapsulates calls to keyboard input processing. - * @returns {RuleBehavior} Record of commands and state changes that result from executing `begin Unicode` - */ - processKeystroke(outputTarget: OutputTarget, keystroke: KeyEvent): RuleBehavior { - if(!this.activeKeyboard) { - throw "No active keyboard for keystroke processing!"; - } - return this.process(this.activeKeyboard.process.bind(this.activeKeyboard), outputTarget, keystroke, false); + private process(callee, outputTarget: OutputTarget, keystroke: KeyEvent, readonly: boolean): RuleBehavior { + // Clear internal state tracking data from prior keystrokes. + if(!outputTarget) { + throw "No target specified for keyboard output!"; + } else if(!this.activeKeyboard) { + throw "No active keyboard for keystroke processing!"; + } else if(!callee) { + throw "No callee for keystroke processing!"; } - private process(callee, outputTarget: OutputTarget, keystroke: KeyEvent, readonly: boolean): RuleBehavior { - // Clear internal state tracking data from prior keystrokes. - if(!outputTarget) { - throw "No target specified for keyboard output!"; - } else if(!this.activeKeyboard) { - throw "No active keyboard for keystroke processing!"; - } else if(!callee) { - throw "No callee for keystroke processing!"; - } + outputTarget.invalidateSelection(); - outputTarget.invalidateSelection(); + outputTarget.deadkeys().resetMatched(); // I3318 + this.resetContextCache(); - outputTarget.deadkeys().resetMatched(); // I3318 - this.resetContextCache(); + // Capture the initial state of the OutputTarget before any rules are matched. + let preInput = Mock.from(outputTarget, true); - // Capture the initial state of the OutputTarget before any rules are matched. - let preInput = Mock.from(outputTarget, true); + // Capture the initial state of any variable stores + const cachedVariableStores = this.activeKeyboard.variableStores; - // Capture the initial state of any variable stores - const cachedVariableStores = this.activeKeyboard.variableStores; + // Establishes the results object, allowing corresponding commands to set values here as appropriate. + this.ruleBehavior = new RuleBehavior(); - // Establishes the results object, allowing corresponding commands to set values here as appropriate. - this.ruleBehavior = new RuleBehavior(); + // Ensure the settings are in place so that KIFS/ifState activates and deactivates + // the appropriate rule(s) for the modeled device. + this.activeDevice = keystroke.device; - // Ensure the settings are in place so that KIFS/ifState activates and deactivates - // the appropriate rule(s) for the modeled device. - this.activeDevice = keystroke.device; + // Calls the start-group of the active keyboard. + this.activeTargetOutput = outputTarget; + var matched = callee(outputTarget, keystroke); + this.activeTargetOutput = null; - // Calls the start-group of the active keyboard. - this.activeTargetOutput = outputTarget; - var matched = callee(outputTarget, keystroke); - this.activeTargetOutput = null; + // Finalize the rule's results. + this.ruleBehavior.transcription = outputTarget.buildTranscriptionFrom(preInput, keystroke, readonly); - // Finalize the rule's results. - this.ruleBehavior.transcription = outputTarget.buildTranscriptionFrom(preInput, keystroke, readonly); + // We always backup the changes to variable stores to the RuleBehavior, to + // be applied during finalization, then restore them to the cached initial + // values to avoid side-effects with predictive text mocks. + this.ruleBehavior.variableStores = this.activeKeyboard.variableStores; + this.activeKeyboard.variableStores = cachedVariableStores; - // We always backup the changes to variable stores to the RuleBehavior, to - // be applied during finalization, then restore them to the cached initial - // values to avoid side-effects with predictive text mocks. - this.ruleBehavior.variableStores = this.activeKeyboard.variableStores; - this.activeKeyboard.variableStores = cachedVariableStores; + // `matched` refers to whether or not the FINAL rule (from any group) matched, rather than + // whether or not ANY rule matched. If the final rule doesn't match, we trigger the key's + // default behavior (if appropriate). + // + // See https://github.com/keymanapp/keyman/pull/4350#issuecomment-768753852 + this.ruleBehavior.triggerKeyDefault = !matched; - // `matched` refers to whether or not the FINAL rule (from any group) matched, rather than - // whether or not ANY rule matched. If the final rule doesn't match, we trigger the key's - // default behavior (if appropriate). - // - // See https://github.com/keymanapp/keyman/pull/4350#issuecomment-768753852 - this.ruleBehavior.triggerKeyDefault = !matched; + // Clear our result-tracking variable to prevent any possible pollution for future processing. + let behavior = this.ruleBehavior; + this.ruleBehavior = null; - // Clear our result-tracking variable to prevent any possible pollution for future processing. - let behavior = this.ruleBehavior; - this.ruleBehavior = null; + return behavior; + } - return behavior; - } + /** + * Applies the dictionary of variable store values to the active keyboard + * + * Has no effect on keyboards compiled with 14.0 or earlier; system store + * names are not exposed unless compiled with Developer 15.0 or later. + * + * @param stores A dictionary of stores which should be found in the + * keyboard + */ + applyVariableStores(stores: VariableStoreDictionary): void { + this.activeKeyboard.variableStores = stores; + } - /** - * Applies the dictionary of variable store values to the active keyboard - * - * Has no effect on keyboards compiled with 14.0 or earlier; system store - * names are not exposed unless compiled with Developer 15.0 or later. - * - * @param stores A dictionary of stores which should be found in the - * keyboard - */ - applyVariableStores(stores: com.keyman.keyboards.VariableStoreDictionary): void { - this.activeKeyboard.variableStores = stores; - } + /** + * Publishes the KeyboardInterface's shorthand API names. As this assigns the current functions + * held by the longform versions, note that this should be called after replacing any of them via + * JS method extension. + * + * DOM-aware KeymanWeb should call this after its domKbdInterface.ts code is loaded, as it replaces + * a few. (This is currently done within its kmwapi.ts.) + */ + static __publishShorthandAPI() { + // Keyboard callbacks + let prototype = this.prototype; - /** - * Publishes the KeyboardInterface's shorthand API names. As this assigns the current functions - * held by the longform versions, note that this should be called after replacing any of them via - * JS method extension. - * - * DOM-aware KeymanWeb should call this after its domKbdInterface.ts code is loaded, as it replaces - * a few. (This is currently done within its kmwapi.ts.) - */ - static __publishShorthandAPI() { - // Keyboard callbacks - let prototype = this.prototype; - - var exportKBCallback = function(miniName: string, longName: string) { + var exportKBCallback = function(miniName: string, longName: string) { + if(prototype[longName]) { prototype[miniName] = prototype[longName]; } - - exportKBCallback('KSF', 'saveFocus'); - exportKBCallback('KBR', 'beepReset'); - exportKBCallback('KT', 'insertText'); - exportKBCallback('KR', 'registerKeyboard'); - exportKBCallback('KRS', 'registerStub'); - exportKBCallback('KC', 'context'); - exportKBCallback('KN', 'nul'); - exportKBCallback('KCM', 'contextMatch'); - exportKBCallback('KFCM', 'fullContextMatch'); - exportKBCallback('KIK', 'isKeypress'); - exportKBCallback('KKM', 'keyMatch'); - exportKBCallback('KSM', 'stateMatch'); - exportKBCallback('KKI', 'keyInformation'); - exportKBCallback('KDM', 'deadkeyMatch'); - exportKBCallback('KB', 'beep'); - exportKBCallback('KA', 'any'); - exportKBCallback('KDC', 'deleteContext'); - exportKBCallback('KO', 'output'); - exportKBCallback('KDO', 'deadkeyOutput'); - exportKBCallback('KCXO', 'contextExOutput'); - exportKBCallback('KIO', 'indexOutput'); - exportKBCallback('KIFS', 'ifStore'); - exportKBCallback('KSETS', 'setStore'); - exportKBCallback('KLOAD', 'loadStore'); - exportKBCallback('KSAVE', 'saveStore'); } + + exportKBCallback('KSF', 'saveFocus'); + exportKBCallback('KBR', 'beepReset'); + exportKBCallback('KT', 'insertText'); + exportKBCallback('KR', 'registerKeyboard'); + exportKBCallback('KRS', 'registerStub'); + exportKBCallback('KC', 'context'); + exportKBCallback('KN', 'nul'); + exportKBCallback('KCM', 'contextMatch'); + exportKBCallback('KFCM', 'fullContextMatch'); + exportKBCallback('KIK', 'isKeypress'); + exportKBCallback('KKM', 'keyMatch'); + exportKBCallback('KSM', 'stateMatch'); + exportKBCallback('KKI', 'keyInformation'); + exportKBCallback('KDM', 'deadkeyMatch'); + exportKBCallback('KB', 'beep'); + exportKBCallback('KA', 'any'); + exportKBCallback('KDC', 'deleteContext'); + exportKBCallback('KO', 'output'); + exportKBCallback('KDO', 'deadkeyOutput'); + exportKBCallback('KCXO', 'contextExOutput'); + exportKBCallback('KIO', 'indexOutput'); + exportKBCallback('KIFS', 'ifStore'); + exportKBCallback('KSETS', 'setStore'); + exportKBCallback('KLOAD', 'loadStore'); + exportKBCallback('KSAVE', 'saveStore'); } +} - (function() { - // This will be the only call within the keyboard-processor module. - KeyboardInterface.__publishShorthandAPI(); - }()); -} \ No newline at end of file +(function() { + // This will be the only call within the keyboard-processor module. + KeyboardInterface.__publishShorthandAPI(); +}()); \ No newline at end of file diff --git a/common/web/keyboard-processor/src/text/keyEvent.ts b/common/web/keyboard-processor/src/text/keyEvent.ts index 1a16dc94bc..4f72e3aa8a 100644 --- a/common/web/keyboard-processor/src/text/keyEvent.ts +++ b/common/web/keyboard-processor/src/text/keyEvent.ts @@ -1,57 +1,188 @@ -/// +// TODO: Move to separate folder: 'codes' +// We should start splitting off code needed by keyboards even without a KeyboardProcessor active. +// There's an upcoming `/common/web/types` package that 'codes' and 'keyboards' may fit well within. -namespace com.keyman.text { - // Represents a probability distribution over a keyboard's keys. - // Defined here to avoid compilation issues. - export type KeyDistribution = {keyId: string, p: number}[]; +// KeyEvent may be a _little_ bit of pollution, but this IS what the Web OSK currently generates to signal +// a key event. The most straightforward way to integrate Web OSK events on other platforms is to have +// other platforms recognize and utilize this type. + +import type Keyboard from "../keyboards/keyboard.js"; +import { type DeviceSpec } from "@keymanapp/web-utils"; + +import Codes from './codes.js'; +import DefaultRules from './defaultRules.js'; + +// Represents a probability distribution over a keyboard's keys. +// Defined here to avoid compilation issues. +export type KeyDistribution = {keyId: string, p: number}[]; + +/** + * A simple instance of the standard 'default rules' for keystroke processing from the + * DefaultRules base class. + */ +const BASE_DEFAULT_RULES = new DefaultRules(); + +export interface KeyEventSpec { + + Lcode: number; + Lstates: number; + LmodifierChange?: boolean; + Lmodifiers: number; + LisVirtualKey: boolean; + vkCode: number; + kName: string; + kLayer?: string; // The key's layer property + kbdLayer?: string; // The virtual keyboard's active layer + kNextLayer?: string; + + /** + * Marks the active keyboard at the time that this KeyEvent was generated by the user. + * + * Note: this is NOT equivalent to the active keyboard at the time that the event handler begins + * processing! It should be set via closure (or similar) on the event handler that can 100% + * guarantee that the keyboard instance known to the handler has not changed during JS execution + * since the user's interaction that raised the event. + */ + srcKeyboard?: Keyboard; + + // Holds relevant event properties leading to construction of this KeyEvent. + source?: any; // Technically, KeyEvent|MouseEvent|Touch - but those are DOM types that must be kept out of headless mode. + // Holds a generated fat-finger distribution (when appropriate) + keyDistribution?: KeyDistribution; + + /** + * The device model for web-core to follow when processing the keystroke. + */ + device: DeviceSpec; + + /** + * `true` if this event was produced by sources other than a DOM-based KeyboardEvent. + */ + isSynthetic?: boolean; +} + +/** + * This class is defined within its own file so that it can be loaded by code outside of KMW without + * having to actually load the entirety of KMW. + */ +export default class KeyEvent implements KeyEventSpec { + Lcode: number; + Lstates: number; + LmodifierChange?: boolean; + Lmodifiers: number; + LisVirtualKey: boolean; + vkCode: number; + kName: string; + kLayer?: string; // The key's layer property + kbdLayer?: string; // The virtual keyboard's active layer + kNextLayer?: string; /** - * This class is defined within its own file so that it can be loaded by code outside of KMW without - * having to actually load the entirety of KMW. + * Marks the active keyboard at the time that this KeyEvent was generated by the user. + * + * Note: this is NOT equivalent to the active keyboard at the time that the event handler begins + * processing! It should be set via closure (or similar) on the event handler that can 100% + * guarantee that the keyboard instance known to the handler has not changed during JS execution + * since the user's interaction that raised the event. */ - export class KeyEvent { - Lcode: number; - Lstates: number; - LmodifierChange?: boolean; - Lmodifiers: number; - LisVirtualKey: boolean; - vkCode: number; - kName: string; - kLayer?: string; // The key's layer property - kbdLayer?: string; // The virtual keyboard's active layer - kNextLayer?: string; - - /** - * Marks the active keyboard at the time that this KeyEvent was generated by the user. - * - * Note: this is NOT equivalent to the active keyboard at the time that the event handler begins - * processing! It should be set via closure (or similar) on the event handler that can 100% - * guarantee that the keyboard instance known to the handler has not changed during JS execution - * since the user's interaction that raised the event. - */ - srcKeyboard?: keyboards.Keyboard; - - // Holds relevant event properties leading to construction of this KeyEvent. - source?: any; // Technically, KeyEvent|MouseEvent|Touch - but those are DOM types that must be kept out of headless mode. - // Holds a generated fat-finger distribution (when appropriate) - keyDistribution?: KeyDistribution; - - /** - * The device model for web-core to follow when processing the keystroke. - */ - device: utils.DeviceSpec; - - /** - * `true` if this event was produced by sources other than a DOM-based KeyboardEvent. - */ - isSynthetic: boolean = true; - - public static constructNullKeyEvent(device: utils.DeviceSpec): KeyEvent { - const keyEvent = new KeyEvent(); - keyEvent.Lcode = 0; - keyEvent.kName = ''; - keyEvent.device = device; - return keyEvent; + srcKeyboard?: Keyboard; + + // Holds relevant event properties leading to construction of this KeyEvent. + source?: any; // Technically, KeyEvent|MouseEvent|Touch - but those are DOM types that must be kept out of headless mode. + // Holds a generated fat-finger distribution (when appropriate) + keyDistribution?: KeyDistribution; + + /** + * The device model for web-core to follow when processing the keystroke. + */ + device: DeviceSpec; + + /** + * `true` if this event was produced by sources other than a DOM-based KeyboardEvent. + */ + isSynthetic: boolean = true; + + public constructor(keyEventSpec: KeyEventSpec) { + for(let key in keyEventSpec) { + if(keyEventSpec[key] !== undefined) { + this[key] = keyEventSpec[key]; + } + } + } + + public static constructNullKeyEvent(device: DeviceSpec): KeyEvent { + const keyEvent = new KeyEvent({ + Lcode: 0, + kName: '', + device: device, + Lstates: undefined, + Lmodifiers: undefined, + vkCode: undefined, + LisVirtualKey: undefined + }); + return keyEvent; + } + + get isModifier(): boolean { + switch(this.Lcode) { + case 16: //"K_SHIFT":16,"K_CONTROL":17,"K_ALT":18 + case 17: + case 18: + case 20: //"K_CAPS":20, "K_NUMLOCK":144,"K_SCROLL":145 + case 144: + case 145: + return true; + default: + return false; + } + } + + // FIXME: makes some bad assumptions. + setMnemonicCode(shifted: boolean, capsActive: boolean) { + // K_SPACE is not handled by defaultKeyOutput for physical keystrokes unless using touch-aliased elements. + // It's also a "exception required, March 2013" for clickKey, so at least they both have this requirement. + if(this.Lcode != Codes.keyCodes['K_SPACE']) { + // So long as the key name isn't prefixed with 'U_', we'll get a default mapping based on the Lcode value. + // We need to determine the mnemonic base character - for example, SHIFT + K_PERIOD needs to map to '>'. + let mappingEvent: KeyEvent = new KeyEvent(this); + for(let key in (this as KeyEvent)) { + mappingEvent[key] = this[key]; + } + + // To facilitate storing relevant commands, we should probably reverse-lookup + // the actual keyname instead. + mappingEvent.kName = 'K_xxxx'; + mappingEvent.Lmodifiers = (shifted ? 0x10 : 0); // mnemonic lookups only exist for default & shift layers. + var mappedChar: string = BASE_DEFAULT_RULES.forAny(mappingEvent, true); + + /* First, save a backup of the original code. This one won't needlessly trigger keyboard + * rules, but allows us to replicate/emulate commands after rule processing if needed. + * (Like backspaces) + */ + this.vkCode = this.Lcode; + if(mappedChar) { + // Will return 96 for 'a', which is a keycode corresponding to Codes.keyCodes('K_NP1') - a numpad key. + // That stated, we're in mnemonic mode - this keyboard's rules are based on the char codes. + this.Lcode = mappedChar.charCodeAt(0); + } else { + // Don't let command-type keys (like K_DEL, which will output '.' otherwise!) + // trigger keyboard rules. + // + // However, DO make sure modifier keys pass through safely. + // (https://github.com/keymanapp/keyman/issues/3744) + if(!this.isModifier) { + delete this.Lcode; + } + } + } + + if(capsActive) { + // TODO: Needs fixing - does not properly mirror physical keystrokes, as Lcode range 96-111 corresponds + // to numpad keys! (Physical keyboard section has its own issues here.) + if((this.Lcode >= 65 && this.Lcode <= 90) /* 'A' - 'Z' */ || (this.Lcode >= 97 && this.Lcode <= 122) /* 'a' - 'z' */) { + this.Lmodifiers ^= 0x10; // Flip the 'shifted' bit, so it'll act as the opposite key. + this.Lcode ^= 0x20; // Flips the 'upper' vs 'lower' bit for the base 'a'-'z' ASCII alphabetics. + } } - }; -} \ No newline at end of file + } +}; \ No newline at end of file diff --git a/common/web/keyboard-processor/src/text/keyMapping.ts b/common/web/keyboard-processor/src/text/keyMapping.ts index ba7cc09868..771abe7bd2 100644 --- a/common/web/keyboard-processor/src/text/keyMapping.ts +++ b/common/web/keyboard-processor/src/text/keyMapping.ts @@ -2,185 +2,185 @@ KeymanWeb 11.0 Copyright 2019 SIL International ***/ -namespace com.keyman { - class KeyMap { - [keycode: string]: number; - } +import type KeyEvent from "./keyEvent.js"; - class BrowserKeyMaps { - FF: KeyMap = new KeyMap(); - Safari: KeyMap = new KeyMap(); - Opera: KeyMap = new KeyMap(); - - constructor() { - // All three have been around since at least May 2014 / FF 29. - // It'd hard to find precise history, but at least that much has been confirmed. - // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode, on Feb 26 2021. - this.FF['k61'] = 187; // = // FF 2.0 - this.FF['k59'] = 186; // ; - this.FF['k173'] = 189; // -/_ - } - } +class KeyMap { + [keycode: string]: number; +} - class LanguageKeyMaps { - [languageCode: string]: KeyMap; - - // // Here are some old legacy definitions that were no longer referenced but are likely related: - // static _BaseLayoutEuro: {[code: string]: string} = { - // 'se': '\u00a71234567890+´~~~QWERTYUIOP\u00c5\u00a8\'~~~ASDFGHJKL\u00d6\u00c4~~~~~ ` ~ - this['uk']['k192'] = 222; // ' @ => ' " - this['uk']['k222'] = 226; // # ~ => K_oE2 // I1504 - UK keyboard mixup #, \ - this['uk']['k220'] = 220; // \ | => \ | // I1504 - UK keyboard mixup #, \ - } +class BrowserKeyMaps { + FF: KeyMap = new KeyMap(); + Safari: KeyMap = new KeyMap(); + Opera: KeyMap = new KeyMap(); + + constructor() { + // All three have been around since at least May 2014 / FF 29. + // It'd hard to find precise history, but at least that much has been confirmed. + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode, on Feb 26 2021. + this.FF['k61'] = 187; // = // FF 2.0 + this.FF['k59'] = 186; // ; + this.FF['k173'] = 189; // -/_ + } +} + +class LanguageKeyMaps { + [languageCode: string]: KeyMap; + + // // Here are some old legacy definitions that were no longer referenced but are likely related: + // static _BaseLayoutEuro: {[code: string]: string} = { + // 'se': '\u00a71234567890+´~~~QWERTYUIOP\u00c5\u00a8\'~~~ASDFGHJKL\u00d6\u00c4~~~~~ ` ~ + this['uk']['k192'] = 222; // ' @ => ' " + this['uk']['k222'] = 226; // # ~ => K_oE2 // I1504 - UK keyboard mixup #, \ + this['uk']['k220'] = 220; // \ | => \ | // I1504 - UK keyboard mixup #, \ } +} - export class KeyMapping { - static readonly browserMap: BrowserKeyMaps = new BrowserKeyMaps(); - static readonly languageMap: LanguageKeyMaps = new LanguageKeyMaps(); +export default class KeyMapping { + static readonly browserMap: BrowserKeyMaps = new BrowserKeyMaps(); + static readonly languageMap: LanguageKeyMaps = new LanguageKeyMaps(); - private static _usCharCodes: KeyMap[]; + private static _usCharCodes: KeyMap[]; - private constructor() { - // Do not construct this class. - } + private constructor() { + // Do not construct this class. + } - private static _usCodeInit() { - var s0=new KeyMap(),s1=new KeyMap(); - - s0['k192'] = 96; - s0['k49'] = 49; - s0['k50'] = 50; - s0['k51'] = 51; - s0['k52'] = 52; - s0['k53'] = 53; - s0['k54'] = 54; - s0['k55'] = 55; - s0['k56'] = 56; - s0['k57'] = 57; - s0['k48'] = 48; - s0['k189'] = 45; - s0['k187'] = 61; - s0['k81'] = 113; - s0['k87'] = 119; - s0['k69'] = 101; - s0['k82'] = 114; - s0['k84'] = 116; - s0['k89'] = 121; - s0['k85'] = 117; - s0['k73'] = 105; - s0['k79'] = 111; - s0['k80'] = 112; - s0['k219'] = 91; - s0['k221'] = 93; - s0['k220'] = 92; - s0['k65'] = 97; - s0['k83'] = 115; - s0['k68'] = 100; - s0['k70'] = 102; - s0['k71'] = 103; - s0['k72'] = 104; - s0['k74'] = 106; - s0['k75'] = 107; - s0['k76'] = 108; - s0['k186'] = 59; - s0['k222'] = 39; - s0['k90'] = 122; - s0['k88'] = 120; - s0['k67'] = 99; - s0['k86'] = 118; - s0['k66'] = 98; - s0['k78'] = 110; - s0['k77'] = 109; - s0['k188'] = 44; - s0['k190'] = 46; - s0['k191'] = 47; - - s1['k192'] = 126; - s1['k49'] = 33; - s1['k50'] = 64; - s1['k51'] = 35; - s1['k52'] = 36; - s1['k53'] = 37; - s1['k54'] = 94; - s1['k55'] = 38; - s1['k56'] = 42; - s1['k57'] = 40; - s1['k48'] = 41; - s1['k189'] = 95; - s1['k187'] = 43; - s1['k81'] = 81; - s1['k87'] = 87; - s1['k69'] = 69; - s1['k82'] = 82; - s1['k84'] = 84; - s1['k89'] = 89; - s1['k85'] = 85; - s1['k73'] = 73; - s1['k79'] = 79; - s1['k80'] = 80; - s1['k219'] = 123; - s1['k221'] = 125; - s1['k220'] = 124; - s1['k65'] = 65; - s1['k83'] = 83; - s1['k68'] = 68; - s1['k70'] = 70; - s1['k71'] = 71; - s1['k72'] = 72; - s1['k74'] = 74; - s1['k75'] = 75; - s1['k76'] = 76; - s1['k186'] = 58; - s1['k222'] = 34; - s1['k90'] = 90; - s1['k88'] = 88; - s1['k67'] = 67; - s1['k86'] = 86; - s1['k66'] = 66; - s1['k78'] = 78; - s1['k77'] = 77; - s1['k188'] = 60; - s1['k190'] = 62; - s1['k191'] = 63; - - KeyMapping._usCharCodes = [s0,s1]; - } + private static _usCodeInit() { + var s0=new KeyMap(),s1=new KeyMap(); + + s0['k192'] = 96; + s0['k49'] = 49; + s0['k50'] = 50; + s0['k51'] = 51; + s0['k52'] = 52; + s0['k53'] = 53; + s0['k54'] = 54; + s0['k55'] = 55; + s0['k56'] = 56; + s0['k57'] = 57; + s0['k48'] = 48; + s0['k189'] = 45; + s0['k187'] = 61; + s0['k81'] = 113; + s0['k87'] = 119; + s0['k69'] = 101; + s0['k82'] = 114; + s0['k84'] = 116; + s0['k89'] = 121; + s0['k85'] = 117; + s0['k73'] = 105; + s0['k79'] = 111; + s0['k80'] = 112; + s0['k219'] = 91; + s0['k221'] = 93; + s0['k220'] = 92; + s0['k65'] = 97; + s0['k83'] = 115; + s0['k68'] = 100; + s0['k70'] = 102; + s0['k71'] = 103; + s0['k72'] = 104; + s0['k74'] = 106; + s0['k75'] = 107; + s0['k76'] = 108; + s0['k186'] = 59; + s0['k222'] = 39; + s0['k90'] = 122; + s0['k88'] = 120; + s0['k67'] = 99; + s0['k86'] = 118; + s0['k66'] = 98; + s0['k78'] = 110; + s0['k77'] = 109; + s0['k188'] = 44; + s0['k190'] = 46; + s0['k191'] = 47; + + s1['k192'] = 126; + s1['k49'] = 33; + s1['k50'] = 64; + s1['k51'] = 35; + s1['k52'] = 36; + s1['k53'] = 37; + s1['k54'] = 94; + s1['k55'] = 38; + s1['k56'] = 42; + s1['k57'] = 40; + s1['k48'] = 41; + s1['k189'] = 95; + s1['k187'] = 43; + s1['k81'] = 81; + s1['k87'] = 87; + s1['k69'] = 69; + s1['k82'] = 82; + s1['k84'] = 84; + s1['k89'] = 89; + s1['k85'] = 85; + s1['k73'] = 73; + s1['k79'] = 79; + s1['k80'] = 80; + s1['k219'] = 123; + s1['k221'] = 125; + s1['k220'] = 124; + s1['k65'] = 65; + s1['k83'] = 83; + s1['k68'] = 68; + s1['k70'] = 70; + s1['k71'] = 71; + s1['k72'] = 72; + s1['k74'] = 74; + s1['k75'] = 75; + s1['k76'] = 76; + s1['k186'] = 58; + s1['k222'] = 34; + s1['k90'] = 90; + s1['k88'] = 88; + s1['k67'] = 67; + s1['k86'] = 86; + s1['k66'] = 66; + s1['k78'] = 78; + s1['k77'] = 77; + s1['k188'] = 60; + s1['k190'] = 62; + s1['k191'] = 63; + + KeyMapping._usCharCodes = [s0,s1]; + } - /** - * Function _USKeyCodeToCharCode - * Scope Private - * @param {Event} Levent KMW event object - * @return {number} Character code - * Description Translate keyboard codes to standard US layout codes - */ - static _USKeyCodeToCharCode(Levent: com.keyman.text.KeyEvent) { - return KeyMapping.usCharCodes[Levent.Lmodifiers & 0x10 ? 1 : 0]['k'+Levent.Lcode]; - }; - - public static get usCharCodes() { - if(!KeyMapping._usCharCodes) { - KeyMapping._usCodeInit(); - } - - return KeyMapping._usCharCodes; + /** + * Function _USKeyCodeToCharCode + * Scope Private + * @param {Event} Levent KMW event object + * @return {number} Character code + * Description Translate keyboard codes to standard US layout codes + */ + static _USKeyCodeToCharCode(Levent: KeyEvent) { + return KeyMapping.usCharCodes[Levent.Lmodifiers & 0x10 ? 1 : 0]['k'+Levent.Lcode]; + }; + + public static get usCharCodes() { + if(!KeyMapping._usCharCodes) { + KeyMapping._usCodeInit(); } + + return KeyMapping._usCharCodes; } } \ No newline at end of file diff --git a/common/web/keyboard-processor/src/text/keyboardProcessor.ts b/common/web/keyboard-processor/src/text/keyboardProcessor.ts index a1ad0699f0..9c43c241bc 100644 --- a/common/web/keyboard-processor/src/text/keyboardProcessor.ts +++ b/common/web/keyboard-processor/src/text/keyboardProcessor.ts @@ -1,780 +1,579 @@ -// Establishes key-code definitions. -/// -// Defines our generalized "KeyEvent" class. -/// -// Defines the RuleBehavior keyboard-processing return object. -/// -// Defines default key handling behaviors. -/// -// Defines the keyboard wrapper object. -/// -// Defines built-in keymapping. -/// +// #region Big ol' list of imports -// Also relies on @keymanapp/web-utils, which is included via tsconfig.json. - -namespace com.keyman.text { - export type BeepHandler = (outputTarget: OutputTarget) => void; - export type LogMessageHandler = (str: string) => void; +import EventEmitter from 'eventemitter3'; - export interface VariableStoreSerializer { - loadStore(keyboardID: string, storeName: string): VariableStore; - saveStore(keyboardID: string, storeName: string, storeMap: VariableStore); - } +import Codes from "./codes.js"; +import type Keyboard from "../keyboards/keyboard.js"; +import { MinimalKeymanGlobal } from '../keyboards/keyboardHarness.js'; +import KeyEvent from "./keyEvent.js"; +import { Layouts } from "../keyboards/defaultLayouts.js"; +import type { MutableSystemStore } from "./systemStores.js"; - export interface ProcessorInitOptions { - baseLayout?: string; - variableStoreSerializer?: VariableStoreSerializer; - } +import DefaultRules, { EmulationKeystrokes } from "./defaultRules.js"; +import type OutputTarget from "./outputTarget.js"; +import { Mock } from "./outputTarget.js"; - export class KeyboardProcessor { - public static readonly DEFAULT_OPTIONS: ProcessorInitOptions = { - baseLayout: 'us' - } +import KeyboardInterface, { SystemStoreIDs, VariableStore } from "./kbdInterface.js"; +import RuleBehavior from "./ruleBehavior.js"; - // Tracks the simulated value for supported state keys, allowing the OSK to mirror a physical keyboard for them. - // Using the exact keyCode name from the Codes definitions will allow for certain optimizations elsewhere in the code. - stateKeys = { - "K_CAPS":false, - "K_NUMLOCK":false, - "K_SCROLL":false - }; - - // Tracks the most recent modifier state information in order to quickly detect changes - // in keyboard state not otherwise captured by the hosting page in the browser. - // Needed for AltGr simulation. - modStateFlags: number = 0; - - keyboardInterface: KeyboardInterface; - - /** - * Indicates the device (platform) to be used for non-keystroke events, - * such as those sent to `begin postkeystroke` and `begin newcontext` - * entry points. - */ - contextDevice: utils.DeviceSpec; - - baseLayout: string; - - // Callbacks for various feedback types - beepHandler?: BeepHandler; - warningLogger?: LogMessageHandler; - errorLogger?: LogMessageHandler; - - constructor(device: utils.DeviceSpec, options?: ProcessorInitOptions) { - if(!options) { - options = KeyboardProcessor.DEFAULT_OPTIONS; - } +import { DeviceSpec, globalObject } from "@keymanapp/web-utils"; - this.contextDevice = device; +// #endregion - this.baseLayout = options.baseLayout || KeyboardProcessor.DEFAULT_OPTIONS.baseLayout; - this.keyboardInterface = new KeyboardInterface(options.variableStoreSerializer); - this.installInterface(); - } - - private installInterface() { - // We must ensure that the keyboard can find the API functions at the expected place. - let globalThis = utils.getGlobalObject(); - globalThis[KeyboardInterface.GLOBAL_NAME] = this.keyboardInterface; - - // Ensure that the active keyboard is set on the keyboard interface object. - if(this.activeKeyboard) { - this.keyboardInterface.activeKeyboard = this.activeKeyboard; - } - } +// Also relies on @keymanapp/web-utils, which is included via tsconfig.json. - public get activeKeyboard(): keyboards.Keyboard { - return this.keyboardInterface.activeKeyboard; - } +export type BeepHandler = (outputTarget: OutputTarget) => void; +export type LogMessageHandler = (str: string) => void; - public set activeKeyboard(keyboard: keyboards.Keyboard) { - this.keyboardInterface.activeKeyboard = keyboard; +export interface VariableStoreSerializer { + loadStore(keyboardID: string, storeName: string): VariableStore; + saveStore(keyboardID: string, storeName: string, storeMap: VariableStore); +} - // All old deadkeys and keyboard-specific cache should immediately be invalidated - // on a keyboard change. - this.resetContext(); - } +export interface ProcessorInitOptions { + baseLayout?: string; + keyboardInterface?: KeyboardInterface; + defaultOutputRules?: DefaultRules; // Takes the class def object, not an instance thereof. +} - get layerStore(): MutableSystemStore { - return this.keyboardInterface.systemStores[KeyboardInterface.TSS_LAYER] as MutableSystemStore; - } +interface EventMap { + statekeyChange: (stateKeys: typeof KeyboardProcessor.prototype.stateKeys) => void; +} - public get newLayerStore(): MutableSystemStore { - return this.keyboardInterface.systemStores[KeyboardInterface.TSS_NEWLAYER] as MutableSystemStore; - } +export default class KeyboardProcessor extends EventEmitter { + public static readonly DEFAULT_OPTIONS: ProcessorInitOptions = { + baseLayout: 'us', + defaultOutputRules: new DefaultRules() + }; - public get oldLayerStore(): MutableSystemStore { - return this.keyboardInterface.systemStores[KeyboardInterface.TSS_OLDLAYER] as MutableSystemStore; - } + // Tracks the simulated value for supported state keys, allowing the OSK to mirror a physical keyboard for them. + // Using the exact keyCode name from the Codes definitions will allow for certain optimizations elsewhere in the code. + stateKeys = { + "K_CAPS":false, + "K_NUMLOCK":false, + "K_SCROLL":false + }; - public get layerId(): string { - return this.layerStore.value; - } + // Tracks the most recent modifier state information in order to quickly detect changes + // in keyboard state not otherwise captured by the hosting page in the browser. + // Needed for AltGr simulation. + modStateFlags: number = 0; - // Note: will trigger an 'event' callback designed to notify the OSK of layer changes. - public set layerId(value: string) { - this.layerStore.set(value); - } + keyboardInterface: KeyboardInterface; - /** - * Get the default RuleBehavior for the specified key, attempting to mimic standard browser defaults - * where and when appropriate. - * - * @param {object} Lkc The pre-analyzed KeyEvent object - * @param {boolean} outputTarget The OutputTarget receiving the KeyEvent - * @return {string} - */ - defaultRuleBehavior(Lkc: KeyEvent, outputTarget: OutputTarget, readonly: boolean): RuleBehavior { - let preInput = Mock.from(outputTarget, readonly); - let ruleBehavior = new RuleBehavior(); - - let matched = false; - var char = ''; - var special: EmulationKeystrokes; - if(Lkc.isSynthetic || outputTarget.isSynthetic) { - matched = true; // All the conditions below result in matches until the final else, which restores the expected default - // if no match occurs. - - if(DefaultOutput.isCommand(Lkc)) { - // Note this in the rule behavior, return successfully. We'll consider applying it later. - ruleBehavior.triggersDefaultCommand = true; - - // We'd rather let the browser handle these keys, but we're using emulated keystrokes, forcing KMW - // to emulate default behavior here. - } else if((special = DefaultOutput.forSpecialEmulation(Lkc)) != null) { - switch(special) { - case EmulationKeystrokes.Backspace: - this.keyboardInterface.defaultBackspace(outputTarget); - break; - case EmulationKeystrokes.Enter: - outputTarget.handleNewlineAtCaret(); - break; - // case '\u007f': // K_DEL - // // For (possible) future implementation. - // // Would recommend (conceptually) equaling K_RIGHT + K_BKSP, the former of which would technically be a 'command'. - default: - // In case we extend the allowed set, but forget to implement its handling case above. - ruleBehavior.errorLog = "Unexpected 'special emulation' character (\\u" + (special as String).kmwCharCodeAt(0).toString(16) + ") went unhandled!"; - } - } else { - // Back to the standard default, pending normal matching. - matched = false; - } - } + /** + * Indicates the device (platform) to be used for non-keystroke events, + * such as those sent to `begin postkeystroke` and `begin newcontext` + * entry points. + */ + contextDevice: DeviceSpec; - let isMnemonic = this.activeKeyboard && this.activeKeyboard.isMnemonic; + baseLayout: string; - if(!matched) { - if((char = DefaultOutput.forAny(Lkc, isMnemonic)) != null) { - special = DefaultOutput.forSpecialEmulation(Lkc) - if(special == EmulationKeystrokes.Backspace) { - // A browser's default backspace may fail to delete both parts of an SMP character. - this.keyboardInterface.defaultBackspace(outputTarget); - } else if(special || DefaultOutput.isCommand(Lkc)) { // Filters out 'commands' like TAB. - // We only do the "for special emulation" cases under the condition above... aside from backspace - // Let the browser handle those. - return null; - } else { - this.keyboardInterface.output(0, outputTarget, char); - } - } else { - // No match, no default RuleBehavior. - return null; - } - } + defaultRules: DefaultRules; - // Shortcut things immediately if there were issues generating this rule behavior. - if(ruleBehavior.errorLog) { - return ruleBehavior; - } + // Callbacks for various feedback types + beepHandler?: BeepHandler; + warningLogger?: LogMessageHandler; + errorLogger?: LogMessageHandler; - let transcription = outputTarget.buildTranscriptionFrom(preInput, Lkc, readonly); - ruleBehavior.transcription = transcription; + constructor(device: DeviceSpec, options?: ProcessorInitOptions) { + super(); - return ruleBehavior; + if(!options) { + options = KeyboardProcessor.DEFAULT_OPTIONS; } - setSyntheticEventDefaults(Lkc: text.KeyEvent) { - // Set the flags for the state keys - for desktop devices. For touch - // devices, the only state key in use currently is Caps Lock, which is set - // when the 'caps' layer is active in ActiveKey::constructBaseKeyEvent. - if(!Lkc.device.touchable) { - Lkc.Lstates |= this.stateKeys['K_CAPS'] ? Codes.modifierCodes['CAPS'] : Codes.modifierCodes['NO_CAPS']; - Lkc.Lstates |= this.stateKeys['K_NUMLOCK'] ? Codes.modifierCodes['NUM_LOCK'] : Codes.modifierCodes['NO_NUM_LOCK']; - Lkc.Lstates |= this.stateKeys['K_SCROLL'] ? Codes.modifierCodes['SCROLL_LOCK'] : Codes.modifierCodes['NO_SCROLL_LOCK']; - } + this.contextDevice = device; - // Set LisVirtualKey to false to ensure that nomatch rule does fire for U_xxxx keys - if(Lkc.kName && Lkc.kName.substr(0,2) == 'U_') { - Lkc.LisVirtualKey=false; - } + this.baseLayout = options.baseLayout || KeyboardProcessor.DEFAULT_OPTIONS.baseLayout; + this.keyboardInterface = options.keyboardInterface || new KeyboardInterface(globalObject(), MinimalKeymanGlobal); + this.defaultRules = options.defaultOutputRules || KeyboardProcessor.DEFAULT_OPTIONS.defaultOutputRules; + } - // Get code for non-physical keys (T_KOKAI, U_05AB etc) - if(typeof Lkc.Lcode == 'undefined') { - Lkc.Lcode = this.getVKDictionaryCode(Lkc.kName);// Updated for Build 347 - if(!Lkc.Lcode) { - // Special case for U_xxxx keys. This vk code will never be used - // in a keyboard, so we use this to ensure that keystroke processing - // occurs for the key. - Lkc.Lcode = 1; - } - } + public get activeKeyboard(): Keyboard { + return this.keyboardInterface.activeKeyboard; + } - // Handles modifier states when the OSK is emulating rightalt through the leftctrl-leftalt layer. - if((Lkc.Lmodifiers & Codes.modifierBitmasks['ALT_GR_SIM']) == Codes.modifierBitmasks['ALT_GR_SIM'] && this.activeKeyboard.emulatesAltGr) { - Lkc.Lmodifiers &= ~Codes.modifierBitmasks['ALT_GR_SIM']; - Lkc.Lmodifiers |= Codes.modifierCodes['RALT']; - } - } + public set activeKeyboard(keyboard: Keyboard) { + this.keyboardInterface.activeKeyboard = keyboard; - constructNullKeyEvent(device: utils.DeviceSpec): KeyEvent { - const keyEvent = KeyEvent.constructNullKeyEvent(device); - this.setSyntheticEventDefaults(keyEvent); - return keyEvent; - } - - processNewContextEvent(device: utils.DeviceSpec, outputTarget: OutputTarget): RuleBehavior { - return this.activeKeyboard ? - this.keyboardInterface.processNewContextEvent(outputTarget, this.constructNullKeyEvent(device)) : - null; - } + // All old deadkeys and keyboard-specific cache should immediately be invalidated + // on a keyboard change. + this.resetContext(); + } - processPostKeystroke(device: utils.DeviceSpec, outputTarget: OutputTarget): RuleBehavior { - return this.activeKeyboard ? - this.keyboardInterface.processPostKeystroke(outputTarget, this.constructNullKeyEvent(device)) : - null; - } + get layerStore(): MutableSystemStore { + return this.keyboardInterface.systemStores[SystemStoreIDs.TSS_LAYER] as MutableSystemStore; + } - processKeystroke(keyEvent: KeyEvent, outputTarget: OutputTarget): RuleBehavior { - var matchBehavior: RuleBehavior; - - // Pass this key code and state to the keyboard program - if(this.activeKeyboard && keyEvent.Lcode != 0) { - /* - * The `this.installInterface()` call is insurance against something I've seen in unit tests when things break a bit. - * - * Currently, when a KMW shutdown doesn't go through properly or completely, sometimes we end up with parallel - * versions of KMW running, and an old, partially-shutdown one will "snipe" a command meant for the most-recent - * one's test. So, installing here ensures that the active Processor has its matching KeyboardInterface ready, - * even should that occur. - */ - this.installInterface(); - matchBehavior = this.keyboardInterface.processKeystroke(outputTarget, keyEvent); - } + public get newLayerStore(): MutableSystemStore { + return this.keyboardInterface.systemStores[SystemStoreIDs.TSS_NEWLAYER] as MutableSystemStore; + } - if(!matchBehavior || matchBehavior.triggerKeyDefault) { - // Restore the virtual key code if a mnemonic keyboard is being used - // If no vkCode value was stored, maintain the original Lcode value. - keyEvent.Lcode=keyEvent.vkCode || keyEvent.Lcode; - - // Handle unmapped keys, including special keys - // The following is physical layout dependent, so should be avoided if possible. All keys should be mapped. - this.keyboardInterface.activeTargetOutput = outputTarget; - - // Match against the 'default keyboard' - rules to mimic the default string output when typing in a browser. - // Many keyboards rely upon these 'implied rules'. - let defaultBehavior = this.defaultRuleBehavior(keyEvent, outputTarget, false); - if(defaultBehavior) { - if(!matchBehavior) { - matchBehavior = defaultBehavior; - } else { - matchBehavior.mergeInDefaults(defaultBehavior); - } - matchBehavior.triggerKeyDefault = false; // We've triggered it successfully. - } // If null, we must rely on something else (like the browser, in DOM-aware code) to fulfill the default. + public get oldLayerStore(): MutableSystemStore { + return this.keyboardInterface.systemStores[SystemStoreIDs.TSS_OLDLAYER] as MutableSystemStore; + } - this.keyboardInterface.activeTargetOutput = null; - } + public get layerId(): string { + return this.layerStore.value; + } - return matchBehavior; - } + // Note: will trigger an 'event' callback designed to notify the OSK of layer changes. + public set layerId(value: string) { + this.layerStore.set(value); + } - // FIXME: makes some bad assumptions. - static setMnemonicCode(Lkc: KeyEvent, shifted: boolean, capsActive: boolean) { - // K_SPACE is not handled by defaultKeyOutput for physical keystrokes unless using touch-aliased elements. - // It's also a "exception required, March 2013" for clickKey, so at least they both have this requirement. - if(Lkc.Lcode != Codes.keyCodes['K_SPACE']) { - // So long as the key name isn't prefixed with 'U_', we'll get a default mapping based on the Lcode value. - // We need to determine the mnemonic base character - for example, SHIFT + K_PERIOD needs to map to '>'. - let mappingEvent: KeyEvent = new KeyEvent(); - for(var key in Lkc) { - mappingEvent[key] = Lkc[key]; + /** + * Get the default RuleBehavior for the specified key, attempting to mimic standard browser defaults + * where and when appropriate. + * + * @param {object} Lkc The pre-analyzed KeyEvent object + * @param {boolean} outputTarget The OutputTarget receiving the KeyEvent + * @return {string} + */ + defaultRuleBehavior(Lkc: KeyEvent, outputTarget: OutputTarget, readonly: boolean): RuleBehavior { + let preInput = Mock.from(outputTarget, readonly); + let ruleBehavior = new RuleBehavior(); + + let matched = false; + var char = ''; + var special: EmulationKeystrokes; + if(Lkc.isSynthetic || outputTarget.isSynthetic) { + matched = true; // All the conditions below result in matches until the final else, which restores the expected default + // if no match occurs. + + if(this.defaultRules.isCommand(Lkc)) { + // Note this in the rule behavior, return successfully. We'll consider applying it later. + ruleBehavior.triggersDefaultCommand = true; + + // We'd rather let the browser handle these keys, but we're using emulated keystrokes, forcing KMW + // to emulate default behavior here. + } else if((special = this.defaultRules.forSpecialEmulation(Lkc)) != null) { + switch(special) { + case EmulationKeystrokes.Backspace: + this.keyboardInterface.defaultBackspace(outputTarget); + break; + case EmulationKeystrokes.Enter: + outputTarget.handleNewlineAtCaret(); + break; + // case '\u007f': // K_DEL + // // For (possible) future implementation. + // // Would recommend (conceptually) equaling K_RIGHT + K_BKSP, the former of which would technically be a 'command'. + default: + // In case we extend the allowed set, but forget to implement its handling case above. + ruleBehavior.errorLog = "Unexpected 'special emulation' character (\\u" + (special as String).kmwCharCodeAt(0).toString(16) + ") went unhandled!"; } + } else { + // Back to the standard default, pending normal matching. + matched = false; + } + } - // To facilitate storing relevant commands, we should probably reverse-lookup - // the actual keyname instead. - mappingEvent.kName = 'K_xxxx'; - mappingEvent.Lmodifiers = (shifted ? 0x10 : 0); // mnemonic lookups only exist for default & shift layers. - var mappedChar: string = DefaultOutput.forAny(mappingEvent, true); - - /* First, save a backup of the original code. This one won't needlessly trigger keyboard - * rules, but allows us to replicate/emulate commands after rule processing if needed. - * (Like backspaces) - */ - Lkc.vkCode = Lkc.Lcode; - if(mappedChar) { - // Will return 96 for 'a', which is a keycode corresponding to Codes.keyCodes('K_NP1') - a numpad key. - // That stated, we're in mnemonic mode - this keyboard's rules are based on the char codes. - Lkc.Lcode = mappedChar.charCodeAt(0); + let isMnemonic = this.activeKeyboard && this.activeKeyboard.isMnemonic; + + if(!matched) { + if((char = this.defaultRules.forAny(Lkc, isMnemonic)) != null) { + special = this.defaultRules.forSpecialEmulation(Lkc) + if(special == EmulationKeystrokes.Backspace) { + // A browser's default backspace may fail to delete both parts of an SMP character. + this.keyboardInterface.defaultBackspace(outputTarget); + } else if(special || this.defaultRules.isCommand(Lkc)) { // Filters out 'commands' like TAB. + // We only do the "for special emulation" cases under the condition above... aside from backspace + // Let the browser handle those. + return null; } else { - // Don't let command-type keys (like K_DEL, which will output '.' otherwise!) - // trigger keyboard rules. - // - // However, DO make sure modifier keys pass through safely. - // (https://github.com/keymanapp/keyman/issues/3744) - if(!KeyboardProcessor.isModifier(Lkc)) { - delete Lkc.Lcode; - } + this.keyboardInterface.output(0, outputTarget, char); } + } else { + // No match, no default RuleBehavior. + return null; } + } - if(capsActive) { - // TODO: Needs fixing - does not properly mirror physical keystrokes, as Lcode range 96-111 corresponds - // to numpad keys! (Physical keyboard section has its own issues here.) - if((Lkc.Lcode >= 65 && Lkc.Lcode <= 90) /* 'A' - 'Z' */ || (Lkc.Lcode >= 97 && Lkc.Lcode <= 122) /* 'a' - 'z' */) { - Lkc.Lmodifiers ^= 0x10; // Flip the 'shifted' bit, so it'll act as the opposite key. - Lkc.Lcode ^= 0x20; // Flips the 'upper' vs 'lower' bit for the base 'a'-'z' ASCII alphabetics. - } - } + // Shortcut things immediately if there were issues generating this rule behavior. + if(ruleBehavior.errorLog) { + return ruleBehavior; } - /** - * Get modifier key state from layer id - * - * @param {string} layerId layer id (e.g. ctrlshift) - * @return {number} modifier key state (desktop keyboards) - */ - static getModifierState(layerId: string): number { - var modifier=0; - if(layerId.indexOf('shift') >= 0) { - modifier |= Codes.modifierCodes['SHIFT']; - } + let transcription = outputTarget.buildTranscriptionFrom(preInput, Lkc, readonly); + ruleBehavior.transcription = transcription; - // The chiral checks must not be directly exclusive due each other to visual OSK feedback. - var ctrlMatched=false; - if(layerId.indexOf('leftctrl') >= 0) { - modifier |= Codes.modifierCodes['LCTRL']; - ctrlMatched=true; - } - if(layerId.indexOf('rightctrl') >= 0) { - modifier |= Codes.modifierCodes['RCTRL']; - ctrlMatched=true; - } - if(layerId.indexOf('ctrl') >= 0 && !ctrlMatched) { - modifier |= Codes.modifierCodes['CTRL']; - } + return ruleBehavior; + } - var altMatched=false; - if(layerId.indexOf('leftalt') >= 0) { - modifier |= Codes.modifierCodes['LALT']; - altMatched=true; - } - if(layerId.indexOf('rightalt') >= 0) { - modifier |= Codes.modifierCodes['RALT']; - altMatched=true; - } - if(layerId.indexOf('alt') >= 0 && !altMatched) { - modifier |= Codes.modifierCodes['ALT']; - } + processNewContextEvent(device: DeviceSpec, outputTarget: OutputTarget): RuleBehavior { + return this.activeKeyboard ? + this.keyboardInterface.processNewContextEvent(outputTarget, this.activeKeyboard.constructNullKeyEvent(device, this.stateKeys)) : + null; + } - return modifier; - } + processPostKeystroke(device: DeviceSpec, outputTarget: OutputTarget): RuleBehavior { + return this.activeKeyboard ? + this.keyboardInterface.processPostKeystroke(outputTarget, this.activeKeyboard.constructNullKeyEvent(device, this.stateKeys)) : + null; + } - /** - * Get state key state from layer id - * - * @param {string} layerId layer id (e.g. caps) - * @return {number} modifier key state (desktop keyboards) - */ - static getStateFromLayer(layerId: string): number { - var modifier=0; - - if(layerId.indexOf('caps') >= 0) { - modifier |= Codes.modifierCodes['CAPS']; - } else { - modifier |= Codes.modifierCodes['NO_CAPS']; - } + processKeystroke(keyEvent: KeyEvent, outputTarget: OutputTarget): RuleBehavior { + var matchBehavior: RuleBehavior; - return modifier; + // Pass this key code and state to the keyboard program + if(this.activeKeyboard && keyEvent.Lcode != 0) { + matchBehavior = this.keyboardInterface.processKeystroke(outputTarget, keyEvent); } - /** - * @summary Look up a custom virtual key code in the virtual key code dictionary KVKD. On first run, will build the dictionary. - * - * `VKDictionary` is constructed from the keyboard's `KVKD` member. This list is constructed - * at compile-time and is a list of 'additional' virtual key codes, starting at 256 (i.e. - * outside the range of standard virtual key codes). These additional codes are both - * `[T_xxx]` and `[U_xxxx]` custom key codes from the Keyman keyboard language. However, - * `[U_xxxx]` keys only generate an entry in `KVKD` if there is a corresponding rule that - * is associated with them in the keyboard rules. If the `[U_xxxx]` key code is only - * referenced as the id of a key in the touch layout, then it does not get an entry in - * the `KVKD` property. - * - * @private - * @param {string} keyName custom virtual key code to lookup in the dictionary - * @return {number} key code > 255 on success, or 0 if not found - */ - getVKDictionaryCode(keyName: string) { - var activeKeyboard = this.activeKeyboard; - if(!activeKeyboard.scriptObject['VKDictionary']) { - var a=[]; - if(typeof activeKeyboard.scriptObject['KVKD'] == 'string') { - // Build the VK dictionary - // TODO: Move the dictionary build into the compiler -- so compiler generates code such as following. - // Makes the VKDictionary member unnecessary. - // this.KVKD={"K_ABC":256,"K_DEF":257,...}; - var s=activeKeyboard.scriptObject['KVKD'].split(' '); - for(var i=0; i -// Defines the KeyEvent type. -/// /// +import { extendString } from "@keymanapp/web-utils"; + +extendString(); + +// Defines deadkey management in a manner attachable to each element interface. +import type KeyEvent from "./keyEvent.js"; +import { Deadkey, DeadkeyTracker } from "./deadkeys.js"; // Also relies on string-extensions provided by the web-utils package. -namespace com.keyman.text { - export class TextTransform implements Transform { - readonly insert: string; - readonly deleteLeft: number; - readonly deleteRight?: number; +export class TextTransform implements Transform { + readonly insert: string; + readonly deleteLeft: number; + readonly deleteRight?: number; - constructor(insert: string, deleteLeft: number, deleteRight?: number) { - this.insert = insert; - this.deleteLeft = deleteLeft; - this.deleteRight = deleteRight || 0; - } + constructor(insert: string, deleteLeft: number, deleteRight?: number) { + this.insert = insert; + this.deleteLeft = deleteLeft; + this.deleteRight = deleteRight || 0; + } - public static readonly nil = new TextTransform('', 0, 0); + public static readonly nil = new TextTransform('', 0, 0); - public isNoOp(): boolean { - return this.insert === '' && this.deleteLeft === 0 && this.deleteRight === 0; - } + public isNoOp(): boolean { + return this.insert === '' && this.deleteLeft === 0 && this.deleteRight === 0; } +} - export class Transcription { - readonly token: number; - readonly keystroke: KeyEvent; - readonly transform: Transform; - alternates: Alternate[]; // constructed after the rest of the transcription. - readonly preInput: Mock; +export class Transcription { + readonly token: number; + readonly keystroke: KeyEvent; + readonly transform: Transform; + alternates: Alternate[]; // constructed after the rest of the transcription. + readonly preInput: Mock; - private static tokenSeed: number = 0; + private static tokenSeed: number = 0; - constructor(keystroke: KeyEvent, transform: Transform, preInput: Mock, alternates?: Alternate[]/*, removedDks: Deadkey[], insertedDks: Deadkey[]*/) { - let token = this.token = Transcription.tokenSeed++; + constructor(keystroke: KeyEvent, transform: Transform, preInput: Mock, alternates?: Alternate[]/*, removedDks: Deadkey[], insertedDks: Deadkey[]*/) { + let token = this.token = Transcription.tokenSeed++; - this.keystroke = keystroke; - this.transform = transform; - this.alternates = alternates; - this.preInput = preInput; + this.keystroke = keystroke; + this.transform = transform; + this.alternates = alternates; + this.preInput = preInput; - this.transform.id = this.token; + this.transform.id = this.token; - // Assign the ID to each alternate, as well. - if(alternates) { - alternates.forEach(function(alt) { - alt.sample.id = token; - }); - } + // Assign the ID to each alternate, as well. + if(alternates) { + alternates.forEach(function(alt) { + alt.sample.id = token; + }); } } +} - export type Alternate = ProbabilityMass; +export type Alternate = ProbabilityMass; - export abstract class OutputTarget { - private _dks: text.DeadkeyTracker; +export default abstract class OutputTarget { + private _dks: DeadkeyTracker; - constructor() { - this._dks = new text.DeadkeyTracker(); - } + constructor() { + this._dks = new DeadkeyTracker(); + } - /** - * Signifies that this OutputTarget has no default key processing behaviors. This should be false - * for OutputTargets backed by web elements like HTMLInputElement or HTMLTextAreaElement. - */ - get isSynthetic(): boolean { - return true; - } + /** + * Signifies that this OutputTarget has no default key processing behaviors. This should be false + * for OutputTargets backed by web elements like HTMLInputElement or HTMLTextAreaElement. + */ + get isSynthetic(): boolean { + return true; + } - resetContext(): void { - this.deadkeys().clear(); - } + resetContext(): void { + this.deadkeys().clear(); + } - deadkeys(): text.DeadkeyTracker { - return this._dks; - } + deadkeys(): DeadkeyTracker { + return this._dks; + } - hasDeadkeyMatch(n: number, d: number): boolean { - return this.deadkeys().isMatch(this.getDeadkeyCaret(), n, d); - } + hasDeadkeyMatch(n: number, d: number): boolean { + return this.deadkeys().isMatch(this.getDeadkeyCaret(), n, d); + } - insertDeadkeyBeforeCaret(d: number) { - var dk: Deadkey = new Deadkey(this.getDeadkeyCaret(), d); - this.deadkeys().add(dk); - } + insertDeadkeyBeforeCaret(d: number) { + var dk: Deadkey = new Deadkey(this.getDeadkeyCaret(), d); + this.deadkeys().add(dk); + } - /** - * Should be called by each output target immediately before text mutation operations occur. - * - * Maintains solutions to old issues: I3318,I3319 - * @param {number} delta Use negative values if characters were deleted, positive if characters were added. - */ - protected adjustDeadkeys(delta: number) { - this.deadkeys().adjustPositions(this.getDeadkeyCaret(), delta); - } + /** + * Should be called by each output target immediately before text mutation operations occur. + * + * Maintains solutions to old issues: I3318,I3319 + * @param {number} delta Use negative values if characters were deleted, positive if characters were added. + */ + protected adjustDeadkeys(delta: number) { + this.deadkeys().adjustPositions(this.getDeadkeyCaret(), delta); + } - /** - * Needed to properly clone deadkeys for use with Mock element interfaces toward predictive text purposes. - * @param {object} dks An existing set of deadkeys to deep-copy for use by this element interface. - */ - protected setDeadkeys(dks: text.DeadkeyTracker) { - this._dks = dks.clone(); - } + /** + * Needed to properly clone deadkeys for use with Mock element interfaces toward predictive text purposes. + * @param {object} dks An existing set of deadkeys to deep-copy for use by this element interface. + */ + protected setDeadkeys(dks: DeadkeyTracker) { + this._dks = dks.clone(); + } - /** - * Determines the basic operations needed to reconstruct the current OutputTarget's text from the prior state specified - * by another OutputTarget based on their text and caret positions. - * - * This is designed for use as a "before and after" comparison to determine the effect of a single keyboard rule at a time. - * As such, it assumes that the caret is immediately after any inserted text. - * @param from An output target (preferably a Mock) representing the prior state of the input/output system. - */ - buildTransformFrom(original: OutputTarget): Transform { - let to = this.getText(); - let from = original.getText(); - - let fromCaret = original.getDeadkeyCaret(); - let toCaret = this.getDeadkeyCaret(); - - // Step 1: Determine the number of left-deletions. - let maxSMPLeftMatch = fromCaret < toCaret ? fromCaret : toCaret; - - // We need the corresponding non-SMP caret location in order to binary-search efficiently. - // (Examining code units is much more computationally efficient.) - let maxLeftMatch = to._kmwCodePointToCodeUnit(maxSMPLeftMatch); - - // 1.1: use a non-SMP-aware binary search to determine the divergence point. - let start = 0; - let end = maxLeftMatch; // the index AFTER the last possible matching char. - - // This search is O(maxLeftMatch). 1/2 + 1/4 + 1/8 + ... converges to = 1. - while(start < end) { - let mid = Math.floor((end+start+1) / 2); // round up (compare more) - let fromLeft = from.substr(start, mid-start); - let toLeft = to.substr(start, mid-start); - - if(fromLeft == toLeft) { - start = mid; - } else { - end = mid - 1; - } + /** + * Determines the basic operations needed to reconstruct the current OutputTarget's text from the prior state specified + * by another OutputTarget based on their text and caret positions. + * + * This is designed for use as a "before and after" comparison to determine the effect of a single keyboard rule at a time. + * As such, it assumes that the caret is immediately after any inserted text. + * @param from An output target (preferably a Mock) representing the prior state of the input/output system. + */ + buildTransformFrom(original: OutputTarget): Transform { + let to = this.getText(); + let from = original.getText(); + + let fromCaret = original.getDeadkeyCaret(); + let toCaret = this.getDeadkeyCaret(); + + // Step 1: Determine the number of left-deletions. + let maxSMPLeftMatch = fromCaret < toCaret ? fromCaret : toCaret; + + // We need the corresponding non-SMP caret location in order to binary-search efficiently. + // (Examining code units is much more computationally efficient.) + let maxLeftMatch = to._kmwCodePointToCodeUnit(maxSMPLeftMatch); + + // 1.1: use a non-SMP-aware binary search to determine the divergence point. + let start = 0; + let end = maxLeftMatch; // the index AFTER the last possible matching char. + + // This search is O(maxLeftMatch). 1/2 + 1/4 + 1/8 + ... converges to = 1. + while(start < end) { + let mid = Math.floor((end+start+1) / 2); // round up (compare more) + let fromLeft = from.substr(start, mid-start); + let toLeft = to.substr(start, mid-start); + + if(fromLeft == toLeft) { + start = mid; + } else { + end = mid - 1; } + } - // At the loop's end: `end` now holds the non-SMP-aware divergence point. - // The 'caret' is after the last matching code unit. - - // 1.2: detect a possible surrogate-pair split scenario, correcting for it - // (by moving the split before the high-surrogate) if detected. - - // If the split location is precisely on either end of the context, we can't - // have split a surrogate pair. - if(end > 0 && end < maxLeftMatch) { - let potentialHigh = from.charCodeAt(end-1); - let potentialFromLow = from.charCodeAt(end); - let potentialToLow = to.charCodeAt(end); - - // if potentialHigh is a possible high surrogate... - if(potentialHigh >= 0xD800 && potentialHigh <= 0xDBFF) { - // and at least one potential 'low' is a possible low surrogate... - let flag = potentialFromLow >= 0xDC00 && potentialFromLow <= 0xDFFF; - flag = flag || (potentialToLow >= 0XDC00 && potentialToLow <= 0xDFFF); - - // Correct the split location, moving it 'before' the high surrogate. - if(flag) { - end = end - 1; - } - } - } + // At the loop's end: `end` now holds the non-SMP-aware divergence point. + // The 'caret' is after the last matching code unit. - // 1.3: take substring from start to the split point; determine SMP-aware length. - // This yields the SMP-aware divergence index, which gives the number of left-deletes. - let newCaret = from._kmwCodeUnitToCodePoint(end); - let deletedLeft = fromCaret - newCaret; - - // Step 2: Determine the other properties. - // Since the 'after' OutputTarget's caret indicates the end of any inserted text, we - // can easily calculate the rest. - let insertedLength = toCaret - newCaret; - let delta = to._kmwSubstr(newCaret, insertedLength); - - let undeletedRight = to._kmwLength() - toCaret; - let originalRight = from._kmwLength() - fromCaret; - let deletedRight = originalRight - undeletedRight; - - // May occur when reverting a suggestion that had been applied mid-word. - if(deletedRight < 0) { - // Restores deleteRight characters. - delta = delta + to._kmwSubstr(toCaret, -deletedRight); - deletedRight = 0; - } + // 1.2: detect a possible surrogate-pair split scenario, correcting for it + // (by moving the split before the high-surrogate) if detected. + + // If the split location is precisely on either end of the context, we can't + // have split a surrogate pair. + if(end > 0 && end < maxLeftMatch) { + let potentialHigh = from.charCodeAt(end-1); + let potentialFromLow = from.charCodeAt(end); + let potentialToLow = to.charCodeAt(end); + + // if potentialHigh is a possible high surrogate... + if(potentialHigh >= 0xD800 && potentialHigh <= 0xDBFF) { + // and at least one potential 'low' is a possible low surrogate... + let flag = potentialFromLow >= 0xDC00 && potentialFromLow <= 0xDFFF; + flag = flag || (potentialToLow >= 0XDC00 && potentialToLow <= 0xDFFF); - return new TextTransform(delta, deletedLeft, deletedRight); + // Correct the split location, moving it 'before' the high surrogate. + if(flag) { + end = end - 1; + } + } } - buildTranscriptionFrom(original: OutputTarget, keyEvent: KeyEvent, readonly: boolean, alternates?: Alternate[]): Transcription { - let transform = this.buildTransformFrom(original); + // 1.3: take substring from start to the split point; determine SMP-aware length. + // This yields the SMP-aware divergence index, which gives the number of left-deletes. + let newCaret = from._kmwCodeUnitToCodePoint(end); + let deletedLeft = fromCaret - newCaret; - // If we ever decide to re-add deadkey tracking, this is the place for it. + // Step 2: Determine the other properties. + // Since the 'after' OutputTarget's caret indicates the end of any inserted text, we + // can easily calculate the rest. + let insertedLength = toCaret - newCaret; + let delta = to._kmwSubstr(newCaret, insertedLength); - return new Transcription(keyEvent, transform, Mock.from(original, readonly), alternates); - } + let undeletedRight = to._kmwLength() - toCaret; + let originalRight = from._kmwLength() - fromCaret; + let deletedRight = originalRight - undeletedRight; - /** - * Restores the `OutputTarget` to the indicated state. Designed for use with `Transcription.preInput`. - * @param original An `OutputTarget` (usually a `Mock`). - */ - restoreTo(original: OutputTarget) { - // - this.setTextBeforeCaret(original.getTextBeforeCaret()); - this.setTextAfterCaret(original.getTextAfterCaret()); - - // Also, restore the deadkeys! - this._dks = original._dks.clone(); + // May occur when reverting a suggestion that had been applied mid-word. + if(deletedRight < 0) { + // Restores deleteRight characters. + delta = delta + to._kmwSubstr(toCaret, -deletedRight); + deletedRight = 0; } - apply(transform: Transform) { - if(transform.deleteRight) { - this.setTextAfterCaret(this.getTextAfterCaret()._kmwSubstr(transform.deleteRight)); - } + return new TextTransform(delta, deletedLeft, deletedRight); + } - if(transform.deleteLeft) { - this.deleteCharsBeforeCaret(transform.deleteLeft); - } + buildTranscriptionFrom(original: OutputTarget, keyEvent: KeyEvent, readonly: boolean, alternates?: Alternate[]): Transcription { + let transform = this.buildTransformFrom(original); - if(transform.insert) { - this.insertTextBeforeCaret(transform.insert); - } + // If we ever decide to re-add deadkey tracking, this is the place for it. - // We assume that all deadkeys are invalidated after applying a Transform, since - // prediction implies we'll be completing a word, post-deadkeys. - this._dks.clear(); - } + return new Transcription(keyEvent, transform, Mock.from(original, readonly), alternates); + } - /** - * Helper to `restoreTo` - allows directly setting the 'before' context to that of another - * `OutputTarget`. - * @param s - */ - protected setTextBeforeCaret(s: string): void { - // This one's easy enough to provide a default implementation for. - this.deleteCharsBeforeCaret(this.getTextBeforeCaret()._kmwLength()); - this.insertTextBeforeCaret(s); + /** + * Restores the `OutputTarget` to the indicated state. Designed for use with `Transcription.preInput`. + * @param original An `OutputTarget` (usually a `Mock`). + */ + restoreTo(original: OutputTarget) { + // + this.setTextBeforeCaret(original.getTextBeforeCaret()); + this.setTextAfterCaret(original.getTextAfterCaret()); + + // Also, restore the deadkeys! + this._dks = original._dks.clone(); + } + + apply(transform: Transform) { + if(transform.deleteRight) { + this.setTextAfterCaret(this.getTextAfterCaret()._kmwSubstr(transform.deleteRight)); } - /** - * Helper to `restoreTo` - allows directly setting the 'after' context to that of another - * `OutputTarget`. - * @param s - */ - protected abstract setTextAfterCaret(s: string): void; - - /** - * Clears any selected text within the wrapper's element(s). - * Silently does nothing if no such text exists. - */ - abstract clearSelection(): void; - - /** - * Clears any cached selection-related state values. - */ - abstract invalidateSelection(): void; - - /** - * Indicates whether or not the underlying element has its own selection (input, textarea) - * or is part of (or possesses) the DOM's active selection. Don't confuse with isSelectionEmpty(). - * - * TODO: rename to supportsOwnSelection - */ - abstract hasSelection(): boolean; - - /** - * Returns true if there is no current selection -- that is, the selection range is empty - */ - abstract isSelectionEmpty(): boolean; - - /** - * Returns an index corresponding to the caret's position for use with deadkeys. - */ - abstract getDeadkeyCaret(): number; - - /** - * Relative to the caret, gets the current context within the wrapper's element. - */ - abstract getTextBeforeCaret(): string; - - /** - * Relative to the caret (and/or active selection), gets the element's text after the caret, - * excluding any actively selected text that would be immediately replaced upon text entry. - */ - abstract getTextAfterCaret(): string; - - /** - * Gets the element's full text, including any text that is actively selected. - */ - abstract getText(): string; - - /** - * Performs context deletions (from the left of the caret) as needed by the KeymanWeb engine and - * corrects the location of any affected deadkeys. - * - * Does not delete deadkeys (b/c KMW 1 & 2 behavior maintenance). - * @param dn The number of characters to delete. If negative, context will be left unchanged. - */ - abstract deleteCharsBeforeCaret(dn: number): void; - - /** - * Inserts text immediately before the caret's current position, moving the caret after the - * newly inserted text in the process along with any affected deadkeys. - * - * @param s Text to insert before the caret's current position. - */ - abstract insertTextBeforeCaret(s: string): void; - - /** - * Allows element-specific handling for ENTER key inputs. Conceptually, this should usually - * correspond to `insertTextBeforeCaret('\n'), but actual implementation will vary greatly among - * elements. - */ - abstract handleNewlineAtCaret(): void; - - /** - * Saves element-specific state properties prone to mutation, enabling restoration after - * text-output operations. - */ - saveProperties() { - // Most element interfaces won't need anything here. + if(transform.deleteLeft) { + this.deleteCharsBeforeCaret(transform.deleteLeft); } - /** - * Restores previously-saved element-specific state properties. Designed for use after text-output - * ops to facilitate more-seamless web-dev and user interactions. - */ - restoreProperties(){ - // Most element interfaces won't need anything here. + if(transform.insert) { + this.insertTextBeforeCaret(transform.insert); } - /** - * Generates a synthetic event on the underlying element, signalling that its value has changed. - */ - abstract doInputEvent(): void; + // We assume that all deadkeys are invalidated after applying a Transform, since + // prediction implies we'll be completing a word, post-deadkeys. + this._dks.clear(); } - // Due to some interesting requirements on compile ordering in TS, - // this needs to be in the same file as OutputTarget now. - export class Mock extends OutputTarget { - text: string; - caretIndex: number; + /** + * Helper to `restoreTo` - allows directly setting the 'before' context to that of another + * `OutputTarget`. + * @param s + */ + protected setTextBeforeCaret(s: string): void { + // This one's easy enough to provide a default implementation for. + this.deleteCharsBeforeCaret(this.getTextBeforeCaret()._kmwLength()); + this.insertTextBeforeCaret(s); + } - constructor(text?: string, caretPos?: number) { - super(); + /** + * Helper to `restoreTo` - allows directly setting the 'after' context to that of another + * `OutputTarget`. + * @param s + */ + protected abstract setTextAfterCaret(s: string): void; + + /** + * Clears any selected text within the wrapper's element(s). + * Silently does nothing if no such text exists. + */ + abstract clearSelection(): void; + + /** + * Clears any cached selection-related state values. + */ + abstract invalidateSelection(): void; + + /** + * Indicates whether or not the underlying element has its own selection (input, textarea) + * or is part of (or possesses) the DOM's active selection. Don't confuse with isSelectionEmpty(). + * + * TODO: rename to supportsOwnSelection + */ + abstract hasSelection(): boolean; + + /** + * Returns true if there is no current selection -- that is, the selection range is empty + */ + abstract isSelectionEmpty(): boolean; + + /** + * Returns an index corresponding to the caret's position for use with deadkeys. + */ + abstract getDeadkeyCaret(): number; + + /** + * Relative to the caret, gets the current context within the wrapper's element. + */ + abstract getTextBeforeCaret(): string; + + /** + * Gets the element's-currently selected text. + */ + abstract getSelectedText(): string; + + /** + * Relative to the caret (and/or active selection), gets the element's text after the caret, + * excluding any actively selected text that would be immediately replaced upon text entry. + */ + abstract getTextAfterCaret(): string; + + /** + * Gets the element's full text, including any text that is actively selected. + */ + abstract getText(): string; + + /** + * Performs context deletions (from the left of the caret) as needed by the KeymanWeb engine and + * corrects the location of any affected deadkeys. + * + * Does not delete deadkeys (b/c KMW 1 & 2 behavior maintenance). + * @param dn The number of characters to delete. If negative, context will be left unchanged. + */ + abstract deleteCharsBeforeCaret(dn: number): void; + + /** + * Inserts text immediately before the caret's current position, moving the caret after the + * newly inserted text in the process along with any affected deadkeys. + * + * @param s Text to insert before the caret's current position. + */ + abstract insertTextBeforeCaret(s: string): void; + + /** + * Allows element-specific handling for ENTER key inputs. Conceptually, this should usually + * correspond to `insertTextBeforeCaret('\n'), but actual implementation will vary greatly among + * elements. + */ + abstract handleNewlineAtCaret(): void; + + /** + * Saves element-specific state properties prone to mutation, enabling restoration after + * text-output operations. + */ + saveProperties() { + // Most element interfaces won't need anything here. + } - this.text = text ? text : ""; - var defaultLength = this.text._kmwLength(); - // Ensures that `caretPos == 0` is handled correctly. - this.caretIndex = typeof caretPos == "number" ? caretPos : defaultLength; - } + /** + * Restores previously-saved element-specific state properties. Designed for use after text-output + * ops to facilitate more-seamless web-dev and user interactions. + */ + restoreProperties(){ + // Most element interfaces won't need anything here. + } - // Clones the state of an existing EditableElement, creating a Mock version of its state. - static from(outputTarget: OutputTarget, readonly: boolean) { - let clone: Mock; + /** + * Generates a synthetic event on the underlying element, signalling that its value has changed. + */ + abstract doInputEvent(): void; +} - if(outputTarget instanceof Mock) { - // Avoids the need to run expensive kmwstring.ts / `_kmwLength()` - // calculations when deep-copying Mock instances. - let priorMock = outputTarget as Mock; - clone = new Mock(priorMock.text, priorMock.caretIndex); - } else { - // If we're 'cloning' a different OutputTarget type, we don't have a - // guaranteed way to more efficiently get these values; these are the - // best methods specified by the abstraction. - - if(readonly) { - // for NewContext and PostOutput, we want the whole text - let text = outputTarget.getText(); - let afterText = outputTarget.getTextAfterCaret(); - let caretIndex = text._kmwLength() - afterText._kmwLength(); - clone = new Mock(text, caretIndex); - } else { - // We choose to ignore (rather, pre-emptively remove) any actively-selected text, - // as since it's always removed instantly during any text mutation operations. - let preText = outputTarget.getTextBeforeCaret(); - let caretIndex = preText._kmwLength(); - clone = new Mock(preText + outputTarget.getTextAfterCaret(), caretIndex); - } - } +// Due to some interesting requirements on compile ordering in TS, +// this needs to be in the same file as OutputTarget now. +export class Mock extends OutputTarget { + text: string; - // Also duplicate deadkey state! (Needed for fat-finger ops.) - clone.setDeadkeys(outputTarget.deadkeys()); + selStart: number; + selEnd: number; + selForward: boolean = true; - return clone; - } + constructor(text?: string, caretPos?: number); + constructor(text?: string, selStart?: number, selEnd?: number); + constructor(text?: string, selStart?: number, selEnd?: number) { + super(); - clearSelection(): void { - return; - } + this.text = text ? text : ""; + var defaultLength = this.text._kmwLength(); - invalidateSelection(): void { - return; - } + // Ensures that `caretPos == 0` is handled correctly. + this.selStart = typeof selStart == "number" ? selStart : defaultLength; - isSelectionEmpty(): boolean { - // TODO: consider if we need to maintain selection information in Mocks - return true; - } + // If no selection-end is set, selection length is implied to be 0. + this.selEnd = typeof selEnd == "number" ? selEnd : this.selStart; - hasSelection(): boolean { - return true; - } + this.selForward = this.selEnd >= this.selStart; + } - getDeadkeyCaret(): number { - return this.caretIndex; - } + // Clones the state of an existing EditableElement, creating a Mock version of its state. + static from(outputTarget: OutputTarget, readonly?: boolean) { + let clone: Mock; - setDeadkeyCaret(index: number) { - if(index < 0 || index > this.text._kmwLength()) { - throw new Error("Provided caret index is out of range."); - } - this.caretIndex = index; - } + if(outputTarget instanceof Mock) { + // Avoids the need to run expensive kmwstring.ts / `_kmwLength()` + // calculations when deep-copying Mock instances. + let priorMock = outputTarget as Mock; + clone = new Mock(priorMock.text, priorMock.selStart, priorMock.selEnd); + } else { + let text = outputTarget.getText(); + let beforeText = outputTarget.getTextBeforeCaret(); + let afterText = outputTarget.getTextAfterCaret(); + let selectionStart = beforeText._kmwLength(); + let selectionEnd = text._kmwLength() - afterText._kmwLength(); - getTextBeforeCaret(): string { - return this.text.kmwSubstr(0, this.caretIndex); + // readonly group or not, the returned Mock remains the same. + // New-context events should act as if the caret were at the earlier-in-context + // side of the selection, same as standard keyboard rules. + clone = new Mock(text, selectionStart, selectionEnd); } - getTextAfterCaret(): string { - return this.text.kmwSubstr(this.caretIndex); - } + // Also duplicate deadkey state! (Needed for fat-finger ops.) + clone.setDeadkeys(outputTarget.deadkeys()); + + return clone; + } + + clearSelection(): void { + this.text = this.getTextBeforeCaret() + this.getTextAfterCaret(); + this.selEnd = this.selStart; + this.selForward = true; + } - getText(): string { - return this.text; + invalidateSelection(): void { + return; + } + + isSelectionEmpty(): boolean { + return this.selStart == this.selEnd; + } + + hasSelection(): boolean { + return true; + } + + getDeadkeyCaret(): number { + return this.selStart; + } + + setSelection(start: number, end?: number) { + this.selStart = start; + this.selEnd = typeof end == 'number' ? end : start; + + this.selForward = end >= start; + if(!this.selForward) { + let temp = this.selStart; + this.selStart = this.selEnd; + this.selEnd = temp; } + } - deleteCharsBeforeCaret(dn: number): void { - if(dn >= 0) { - if(dn > this.caretIndex) { - dn = this.caretIndex; - } - this.adjustDeadkeys(-dn); - this.text = this.text.kmwSubstr(0, this.caretIndex - dn) + this.getTextAfterCaret(); - this.caretIndex -= dn; + getTextBeforeCaret(): string { + return this.text.kmwSubstr(0, this.selStart); + } + + getSelectedText(): string { + return this.text.kmwSubstr(this.selStart, this.selEnd - this.selStart); + } + + getTextAfterCaret(): string { + return this.text.kmwSubstr(this.selEnd); + } + + getText(): string { + return this.text; + } + + deleteCharsBeforeCaret(dn: number): void { + if(dn >= 0) { + if(dn > this.selStart) { + dn = this.selStart; } + this.adjustDeadkeys(-dn); + this.text = this.text.kmwSubstr(0, this.selStart - dn) + this.text.kmwSubstr(this.selStart); + this.selStart -= dn; + this.selEnd -= dn; } + } - insertTextBeforeCaret(s: string): void { - this.adjustDeadkeys(s._kmwLength()); - this.text = this.getTextBeforeCaret() + s + this.getTextAfterCaret(); - this.caretIndex += s.kmwLength(); - } + insertTextBeforeCaret(s: string): void { + this.adjustDeadkeys(s._kmwLength()); + this.text = this.getTextBeforeCaret() + s + this.text.kmwSubstr(this.selStart); + this.selStart += s.kmwLength(); + this.selEnd += s.kmwLength(); + } - handleNewlineAtCaret(): void { - this.insertTextBeforeCaret('\n'); - } + handleNewlineAtCaret(): void { + this.insertTextBeforeCaret('\n'); + } - protected setTextAfterCaret(s: string): void { - this.text = this.getTextBeforeCaret() + s; - } + protected setTextAfterCaret(s: string): void { + this.text = this.getTextBeforeCaret() + s; + } - doInputEvent() { - // Mock isn't backed by an element, so it won't have any event listeners. - } + doInputEvent() { + // Mock isn't backed by an element, so it won't have any event listeners. } } \ No newline at end of file diff --git a/common/web/keyboard-processor/src/text/ruleBehavior.ts b/common/web/keyboard-processor/src/text/ruleBehavior.ts index e1bc700cf1..e5d669e909 100644 --- a/common/web/keyboard-processor/src/text/ruleBehavior.ts +++ b/common/web/keyboard-processor/src/text/ruleBehavior.ts @@ -1,133 +1,138 @@ -namespace com.keyman.text { +/// + +import KeyboardProcessor from "./keyboardProcessor.js"; +import OutputTarget, { Mock, type Transcription } from "./outputTarget.js"; +import { VariableStoreDictionary } from "../keyboards/keyboard.js"; +import type { VariableStore } from "./kbdInterface.js"; + +/** + * Represents the commands and state changes that result from a matched keyboard rule. + */ +export default class RuleBehavior { /** - * Represents the commands and state changes that result from a matched keyboard rule. + * The before-and-after Transform from matching a keyboard rule. May be `null` + * if no keyboard rules were matched for the keystroke. */ - export class RuleBehavior { - /** - * The before-and-after Transform from matching a keyboard rule. May be `null` - * if no keyboard rules were matched for the keystroke. - */ - transcription: Transcription = null; - - /** - * Indicates whether or not a BEEP command was issued by the matched keyboard rule. - */ - beep?: boolean; - - /** - * A set of changed store values triggered by the matched keyboard rule. - */ - setStore: {[id: number]: string} = {}; - - /** - * A set of variable stores with save requests triggered by the matched keyboard rule - */ - saveStore: {[name: string]: VariableStore} = {}; - - /** - * A set of variable stores with possible changes to be applied during finalization. - */ - variableStores: keyboards.VariableStoreDictionary = {}; - - /** - * Denotes a non-output default behavior; this should be evaluated later, against the true keystroke. - */ - triggersDefaultCommand: boolean = false; - - /** - * Denotes error log messages generated when attempting to generate this behavior. - */ - errorLog?: string; - - /** - * Denotes warning log messages generated when attempting to generate this behavior. - */ - warningLog?: string; - - /** - * If predictive text is active, contains a Promise returning predictive Suggestions. - */ - predictionPromise?: Promise; - - /** - * In reference to https://github.com/keymanapp/keyman/pull/4350#issuecomment-768753852: - * - * If the final group processed is a context and keystroke group (using keys), - * and there is no nomatch rule, and the keystroke is not matched in the group, - * the keystroke's default behavior should trigger, regardless of whether or not any - * rules in prior groups matched. - */ - triggerKeyDefault?: boolean; - - finalize(processor: KeyboardProcessor, outputTarget: OutputTarget, readonly: boolean) { - if(!this.transcription) { - throw "Cannot finalize a RuleBehavior with no transcription."; - } + transcription: Transcription = null; - if(processor.beepHandler && this.beep) { - processor.beepHandler(outputTarget); - } + /** + * Indicates whether or not a BEEP command was issued by the matched keyboard rule. + */ + beep?: boolean; + + /** + * A set of changed store values triggered by the matched keyboard rule. + */ + setStore: {[id: number]: string} = {}; + + /** + * A set of variable stores with save requests triggered by the matched keyboard rule + */ + saveStore: {[name: string]: VariableStore} = {}; + + /** + * A set of variable stores with possible changes to be applied during finalization. + */ + variableStores: VariableStoreDictionary = {}; + + /** + * Denotes a non-output default behavior; this should be evaluated later, against the true keystroke. + */ + triggersDefaultCommand: boolean = false; + + /** + * Denotes error log messages generated when attempting to generate this behavior. + */ + errorLog?: string; + + /** + * Denotes warning log messages generated when attempting to generate this behavior. + */ + warningLog?: string; + + /** + * If predictive text is active, contains a Promise returning predictive Suggestions. + */ + predictionPromise?: Promise; + + /** + * In reference to https://github.com/keymanapp/keyman/pull/4350#issuecomment-768753852: + * + * If the final group processed is a context and keystroke group (using keys), + * and there is no nomatch rule, and the keystroke is not matched in the group, + * the keystroke's default behavior should trigger, regardless of whether or not any + * rules in prior groups matched. + */ + triggerKeyDefault?: boolean; + + finalize(processor: KeyboardProcessor, outputTarget: OutputTarget, readonly: boolean) { + if(!this.transcription) { + throw "Cannot finalize a RuleBehavior with no transcription."; + } - for(let storeID in this.setStore) { - let sysStore = processor.keyboardInterface.systemStores[storeID]; - if(sysStore) { - try { - sysStore.set(this.setStore[storeID]); - } catch (error) { - if(processor.errorLogger) { - processor.errorLogger("Rule attempted to perform illegal operation - 'platform' may not be changed."); - } + if(processor.beepHandler && this.beep) { + processor.beepHandler(outputTarget); + } + + for(let storeID in this.setStore) { + let sysStore = processor.keyboardInterface.systemStores[storeID]; + if(sysStore) { + try { + sysStore.set(this.setStore[storeID]); + } catch (error) { + if(processor.errorLogger) { + processor.errorLogger("Rule attempted to perform illegal operation - 'platform' may not be changed."); } - } else if(processor.warningLogger) { - processor.warningLogger("Unknown store affected by keyboard rule: " + storeID); } + } else if(processor.warningLogger) { + processor.warningLogger("Unknown store affected by keyboard rule: " + storeID); } + } - processor.keyboardInterface.applyVariableStores(this.variableStores); + processor.keyboardInterface.applyVariableStores(this.variableStores); - if(processor.keyboardInterface.variableStoreSerializer) { - for(let storeID in this.saveStore) { - processor.keyboardInterface.variableStoreSerializer.saveStore(processor.activeKeyboard.id, storeID, this.saveStore[storeID]); - } + if(processor.keyboardInterface.variableStoreSerializer) { + for(let storeID in this.saveStore) { + processor.keyboardInterface.variableStoreSerializer.saveStore(processor.activeKeyboard.id, storeID, this.saveStore[storeID]); } + } - if(this.triggersDefaultCommand) { - let keyEvent = this.transcription.keystroke; - DefaultOutput.applyCommand(keyEvent, outputTarget); - } + if(this.triggersDefaultCommand) { + let keyEvent = this.transcription.keystroke; + processor.defaultRules.applyCommand(keyEvent, outputTarget); + } - if(processor.warningLogger && this.warningLog) { - processor.warningLogger(this.warningLog); - } else if(processor.errorLogger && this.errorLog) { - processor.errorLogger(this.errorLog); - } + if(processor.warningLogger && this.warningLog) { + processor.warningLogger(this.warningLog); + } else if(processor.errorLogger && this.errorLog) { + processor.errorLogger(this.errorLog); } + } - /** - * Merges default-related behaviors from another RuleBehavior into this one. Assumes that the current instance - * "came first" chronologically. Both RuleBehaviors must be sourced from the same keystroke. - * - * Intended use: merging rule-based behavior with default key behavior during scenarios like those described - * at https://github.com/keymanapp/keyman/pull/4350#issuecomment-768753852. - * - * This function does not attempt a "complete" merge for two fully-constructed RuleBehaviors! Things - * WILL break for unintended uses. - * @param other - */ - mergeInDefaults(other: RuleBehavior) { - let keystroke = this.transcription.keystroke; - let keyFromOther = other.transcription.keystroke; - if(keystroke.Lcode != keyFromOther.Lcode || keystroke.Lmodifiers != keyFromOther.Lmodifiers) { - throw "RuleBehavior default-merge not supported unless keystrokes are identical!"; - } + /** + * Merges default-related behaviors from another RuleBehavior into this one. Assumes that the current instance + * "came first" chronologically. Both RuleBehaviors must be sourced from the same keystroke. + * + * Intended use: merging rule-based behavior with default key behavior during scenarios like those described + * at https://github.com/keymanapp/keyman/pull/4350#issuecomment-768753852. + * + * This function does not attempt a "complete" merge for two fully-constructed RuleBehaviors! Things + * WILL break for unintended uses. + * @param other + */ + mergeInDefaults(other: RuleBehavior) { + let keystroke = this.transcription.keystroke; + let keyFromOther = other.transcription.keystroke; + if(keystroke.Lcode != keyFromOther.Lcode || keystroke.Lmodifiers != keyFromOther.Lmodifiers) { + throw "RuleBehavior default-merge not supported unless keystrokes are identical!"; + } - this.triggersDefaultCommand = this.triggersDefaultCommand || other.triggersDefaultCommand; + this.triggersDefaultCommand = this.triggersDefaultCommand || other.triggersDefaultCommand; - let mergingMock = Mock.from(this.transcription.preInput, false); - mergingMock.apply(this.transcription.transform); - mergingMock.apply(other.transcription.transform); + let mergingMock = Mock.from(this.transcription.preInput, false); + mergingMock.apply(this.transcription.transform); + mergingMock.apply(other.transcription.transform); - this.transcription = mergingMock.buildTranscriptionFrom(this.transcription.preInput, keystroke, false, this.transcription.alternates); - } + this.transcription = mergingMock.buildTranscriptionFrom(this.transcription.preInput, keystroke, false, this.transcription.alternates); } } \ No newline at end of file diff --git a/common/web/keyboard-processor/src/text/systemStores.ts b/common/web/keyboard-processor/src/text/systemStores.ts index 00be331b72..13184baafe 100644 --- a/common/web/keyboard-processor/src/text/systemStores.ts +++ b/common/web/keyboard-processor/src/text/systemStores.ts @@ -1,133 +1,134 @@ -namespace com.keyman.text { - /** - * Defines common behaviors associated with system stores. - */ - export abstract class SystemStore { - public readonly id: number; - - constructor(id: number) { - this.id = id; - } +import type KeyboardInterface from "./kbdInterface.js"; +import { SystemStoreIDs } from "./kbdInterface.js"; - abstract matches(value: string): boolean; +/** + * Defines common behaviors associated with system stores. + */ +export abstract class SystemStore { + public readonly id: number; - set(value: string): void { - throw new Error("System store with ID " + this.id + " may not be directly set."); - } + constructor(id: number) { + this.id = id; } - /** - * A handler designed to receive feedback whenever a system store's value is changed. - * @param source The system store being mutated, before the value change occurs. - * @param newValue The new value being set - * @returns `false` / `undefined` to allow the change, `true` to block the change. - */ - export type SystemStoreMutationHandler = (source: MutableSystemStore, newValue: string) => boolean; - - export class MutableSystemStore extends SystemStore { - private _value: string; - handler?: SystemStoreMutationHandler = null; - - constructor(id: number, defaultValue: string) { - super(id); - this._value = defaultValue; - } + abstract matches(value: string): boolean; - get value() { - return this._value; - } + set(value: string): void { + throw new Error("System store with ID " + this.id + " may not be directly set."); + } +} + +/** + * A handler designed to receive feedback whenever a system store's value is changed. + * @param source The system store being mutated, before the value change occurs. + * @param newValue The new value being set + * @returns `false` / `undefined` to allow the change, `true` to block the change. + */ +export type SystemStoreMutationHandler = (source: MutableSystemStore, newValue: string) => boolean; + +export class MutableSystemStore extends SystemStore { + private _value: string; + handler?: SystemStoreMutationHandler = null; + + constructor(id: number, defaultValue: string) { + super(id); + this._value = defaultValue; + } - matches(value: string) { - return this._value == value; - } + get value() { + return this._value; + } - set(value: string) { - // Even if things stay the same, we should still signal this. - // It's important for tracking if a rule directly set the layer - // versus if it passively remained. - if(this.handler) { - if(this.handler(this, value)) { - return; - } - } + matches(value: string) { + return this._value == value; + } - this._value = value; + set(value: string) { + // Even if things stay the same, we should still signal this. + // It's important for tracking if a rule directly set the layer + // versus if it passively remained. + if(this.handler) { + if(this.handler(this, value)) { + return; + } } + + this._value = value; } +} - /** - * Handles checks against the current platform. - */ - export class PlatformSystemStore extends SystemStore { - private readonly kbdInterface: KeyboardInterface; +/** + * Handles checks against the current platform. + */ +export class PlatformSystemStore extends SystemStore { + private readonly kbdInterface: KeyboardInterface; - constructor(keyboardInterface: KeyboardInterface) { - super(KeyboardInterface.TSS_PLATFORM); + constructor(keyboardInterface: KeyboardInterface) { + super(SystemStoreIDs.TSS_PLATFORM); - this.kbdInterface = keyboardInterface; - } + this.kbdInterface = keyboardInterface; + } - matches(value: string) { - var i,constraint,constraints=value.split(' '); - let device = this.kbdInterface.activeDevice; - - for(i=0; i { + if(window.KeymanWeb) { + window.KeymanWeb.uninstall(); + } + }) + + it('`window`, disabled rule processing', async () => { + const harness = new KeyboardHarness(window, MinimalKeymanGlobal); + let keyboardLoader = new DOMKeyboardLoader(harness); + let keyboard = await keyboardLoader.loadKeyboardFromPath('/resources/keyboards/khmer_angkor.js'); + + assert.isOk(keyboard); + assert.equal(keyboard.id, 'Keyboard_khmer_angkor'); + assert.isTrue(keyboard.isChiral); + assert.isFalse(keyboard.isCJK); + assert.isOk(window.KeymanWeb); + assert.isOk(window.keyman); + + // Should be not be modified by the keyboard load; it is not activated by default. + assert.isNotOk(harness.activeKeyboard); + + // Should be cleared post-keyboard-load. + assert.isNotOk(harness.loadedKeyboard); + }); + + it('`window`, enabled rule processing', async () => { + const harness = new KeyboardInterface(window, MinimalKeymanGlobal); + const keyboardLoader = new DOMKeyboardLoader(harness); + const keyboard = await keyboardLoader.loadKeyboardFromPath('/resources/keyboards/khmer_angkor.js'); + harness.activeKeyboard = keyboard; + + assert.isOk(keyboard); + assert.equal(keyboard.id, 'Keyboard_khmer_angkor'); + assert.isTrue(keyboard.isChiral); + assert.isFalse(keyboard.isCJK); + assert.isOk(window.KeymanWeb); + assert.isOk(window.keyman); + + // TODO: verify actual rule processing. + const nullKeyEvent = keyboard.constructNullKeyEvent(device); + const mock = new Mock(); + const result = harness.processKeystroke(mock, nullKeyEvent); + + assert.isOk(result); + assert.isOk(window.KeymanWeb); + assert.isOk(window.keyman); + + // Should be cleared post-keyboard-load. + assert.isNotOk(harness.loadedKeyboard); + }); + + it('load keyboards successfully in parallel without side effects', async () => { + let harness = new KeyboardInterface(window, MinimalKeymanGlobal); + let keyboardLoader = new DOMKeyboardLoader(harness); + + // Preload a keyboard and make it active. + const test_kbd = await keyboardLoader.loadKeyboardFromPath('/resources/keyboards/test_917.js'); + harness.activeKeyboard = test_kbd; + assert.isNotOk(harness.loadedKeyboard); + + // With an active keyboard, load three keyboards but activate none of them. + const lao_keyboard_promise = keyboardLoader.loadKeyboardFromPath('/resources/keyboards/lao_2008_basic.js'); + const khmer_keyboard_promise = keyboardLoader.loadKeyboardFromPath('/resources/keyboards/khmer_angkor.js'); + const chiral_keyboard_promise = keyboardLoader.loadKeyboardFromPath('/resources/keyboards/test_chirality.js'); + + // Sure, why not `await` out of order? + const chiral_keyboard = await chiral_keyboard_promise; + const lao_keyboard = await lao_keyboard_promise; + const khmer_keyboard = await khmer_keyboard_promise; + + assert.strictEqual(test_kbd, harness.activeKeyboard); + assert.isNotOk(harness.loadedKeyboard); + + assert.isOk(lao_keyboard); + assert.isOk(chiral_keyboard); + assert.isOk(khmer_keyboard); + + // This part provides assurance that the keyboard properly loaded. + assert.equal(lao_keyboard.id, "Keyboard_lao_2008_basic"); + assert.equal(khmer_keyboard.id, "Keyboard_khmer_angkor"); + assert.equal(chiral_keyboard.id, "Keyboard_test_chirality"); + + harness.activeKeyboard = lao_keyboard; + }); +}); \ No newline at end of file diff --git a/web/src/test/auto/manual.conf.js b/common/web/keyboard-processor/tests/dom/manual.conf.cjs similarity index 86% rename from web/src/test/auto/manual.conf.js rename to common/web/keyboard-processor/tests/dom/manual.conf.cjs index e9ba5c97a9..88e20d283c 100644 --- a/web/src/test/auto/manual.conf.js +++ b/common/web/keyboard-processor/tests/dom/manual.conf.cjs @@ -1,5 +1,5 @@ module.exports = function(config) { - var base = require("./base.conf.js"); + var base = require("./base.conf.cjs"); var specifics = { // test results reporter to use @@ -9,7 +9,7 @@ module.exports = function(config) { // start these browsers // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher - browsers: ['Firefox', 'Chrome', 'Edge'], // Can be specified at run-time instead! + browsers: ['Chrome'], // Can be specified at run-time instead! // Future note for us: https://www.npmjs.com/package/karma-browserstack-launcher // Concurrency level diff --git a/common/web/keyboard-processor/tests/dom/readme.md b/common/web/keyboard-processor/tests/dom/readme.md new file mode 100644 index 0000000000..153b7f4a1e --- /dev/null +++ b/common/web/keyboard-processor/tests/dom/readme.md @@ -0,0 +1,5 @@ +Automated tests in this subfolder and its children are designed to facilitate simple, browser-independent +unit tests that are DOM-reliant. + +Tests for anything that may reasonably vary depending upon the browser used to run the code should go under +the "integrated" folder instead. \ No newline at end of file diff --git a/common/web/keyboard-processor/tests/node/basic-engine.js b/common/web/keyboard-processor/tests/node/basic-engine.js new file mode 100644 index 0000000000..15fd1efc5d --- /dev/null +++ b/common/web/keyboard-processor/tests/node/basic-engine.js @@ -0,0 +1,52 @@ +import { assert } from 'chai'; +import fs from 'fs'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { KeyboardInterface, MinimalKeymanGlobal } from '@keymanapp/keyboard-processor'; +import { NodeKeyboardLoader } from '@keymanapp/keyboard-processor/node-keyboard-loader'; +import { KeyboardTest, NodeProctor } from '@keymanapp/recorder-core'; + +describe('Engine - Basic Simulation', function() { + let testJSONtext = fs.readFileSync(require.resolve('@keymanapp/common-test-resources/json/engine_tests/basic_lao_simulation.json')); + // Common test suite setup. + let testSuite = new KeyboardTest(JSON.parse(testJSONtext)); + + let keyboardWithHarness; + + let device = { + formFactor: 'desktop', + OS: 'windows', + browser: 'native' + } + + before(async function() { + // -- START: Standard Recorder-based unit test loading boilerplate -- + let keyboardLoader = new NodeKeyboardLoader(new KeyboardInterface({}, MinimalKeymanGlobal)); + let keyboard = await keyboardLoader.loadKeyboardFromPath('../../test/' + testSuite.keyboard.filename); + keyboardWithHarness = keyboardLoader.harness; + keyboardWithHarness.activeKeyboard = keyboard; + + assert.equal(keyboard.id, "Keyboard_" + testSuite.keyboard.id); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // This part provides extra assurance that the keyboard properly loaded. + assert.equal(keyboard.id, "Keyboard_lao_2008_basic"); + }); + + // Converts each test set into its own Mocha-level test. + for(let set of testSuite.inputTestSets) { + let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal); + + if(!proctor.compatibleWithSuite(testSuite)) { + it.skip(set.toTestName() + " - Cannot run this test suite on Node."); + } else { + it(set.toTestName(), function() { + // Refresh the proctor instance at runtime. + let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal); + set.test(proctor); + }); + } + } +}); \ No newline at end of file diff --git a/common/web/keyboard-processor/tests/cases/basic-init.js b/common/web/keyboard-processor/tests/node/basic-init.js similarity index 63% rename from common/web/keyboard-processor/tests/cases/basic-init.js rename to common/web/keyboard-processor/tests/node/basic-init.js index 9fb576cd90..8f04a6a6e8 100644 --- a/common/web/keyboard-processor/tests/cases/basic-init.js +++ b/common/web/keyboard-processor/tests/node/basic-init.js @@ -1,11 +1,11 @@ -var assert = require('chai').assert; -var fs = require("fs"); -var vm = require("vm"); +import { assert } from 'chai'; -let KeyboardProcessor = require('../../build/index.bundled.js'); +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { KeyboardProcessor } from '@keymanapp/keyboard-processor'; +import { NodeKeyboardLoader } from '@keymanapp/keyboard-processor/node-keyboard-loader'; -// Required initialization setup. -global.com = KeyboardProcessor.com; // exports all keyboard-processor namespacing. global.keyman = {}; // So that keyboard-based checks against the global `keyman` succeed. // 10.0+ dependent keyboards, like khmer_angkor, will otherwise fail to load. @@ -30,16 +30,16 @@ describe('KeyboardProcessor', function() { }); describe('activeKeyboard', function() { - it('is automatically set (in headless) on keyboard load', function () { + it('is automatically set (in headless) on keyboard load', async function () { let kp = new KeyboardProcessor(); // These two lines will load a keyboard from its file; headless-mode `registerKeyboard` will // automatically set the keyboard as active. - var script = new vm.Script(fs.readFileSync('../../test/resources/keyboards/khmer_angkor.js')); - script.runInThisContext(); + let keyboardLoader = new NodeKeyboardLoader(); + let keyboard = await keyboardLoader.loadKeyboardFromPath(require.resolve('@keymanapp/common-test-resources/keyboards/khmer_angkor.js')); - assert.isDefined(kp.activeKeyboard, 'Keyboard failed to register on script load'); - assert.equal('Keyboard_khmer_angkor', kp.activeKeyboard.id, 'Unexpected keyboard id found after script load'); + assert.isDefined(keyboard, 'Keyboard failed to register on script load'); + assert.equal('Keyboard_khmer_angkor', keyboard.id, 'Unexpected keyboard id found after script load'); }); }); }); diff --git a/common/web/keyboard-processor/tests/node/bundled-module.js b/common/web/keyboard-processor/tests/node/bundled-module.js new file mode 100644 index 0000000000..bfff937b62 --- /dev/null +++ b/common/web/keyboard-processor/tests/node/bundled-module.js @@ -0,0 +1,50 @@ +import { assert } from "chai"; +import * as Package from "../../build/lib/index.mjs"; + +// A few small tests to ensure that the ES Module bundle was successfully constructed and is usable. + +var toSupplementaryPairString = function(code){ + var H = Math.floor((code - 0x10000) / 0x400) + 0xD800; + var L = (code - 0x10000) % 0x400 + 0xDC00; + + return String.fromCharCode(H, L); +} + +let u = toSupplementaryPairString; + +describe('Bundled ES Module', function() { + describe('KeyboardProcessor', function () { + it('should initialize without errors', function () { + let kp = new Package.KeyboardProcessor(); + assert.isNotNull(kp); + }); + }); + + describe('Mock', () => { + it('basic functionality test', () => { + let target = new Package.Mock("aple", 2); // ap | le + target.insertTextBeforeCaret('p'); + assert.equal(target.getText(), "apple"); + }); + + it('smp test', () => { + // Is installed as a _side effect_ from importing the module. + // We could disable that and require a call of `extendString()` instead. + String.kmwEnableSupplementaryPlane(true); // Declared & defined in web-utils. + try { + let target = new Package.Mock(u(0x1d5ba)+u(0x1d5c9)+u(0x1d5c5)+u(0x1d5be), 2); // ap | le + target.insertTextBeforeCaret(u(0x1d5c9)); + assert.equal(target.getText(), u(0x1d5ba)+u(0x1d5c9)+u(0x1d5c9)+u(0x1d5c5)+u(0x1d5be)); + } finally { + String.kmwEnableSupplementaryPlane(false); + } + }); + }); + + describe("Imported `utils`", function() { + it("should include `utils` package's Version class", () => { + let v16 = new Package.Version([16, 1]); + assert.equal(v16.toString(), "16.1"); + }); + }) +}); \ No newline at end of file diff --git a/common/web/keyboard-processor/tests/cases/chirality.js b/common/web/keyboard-processor/tests/node/chirality.js similarity index 87% rename from common/web/keyboard-processor/tests/cases/chirality.js rename to common/web/keyboard-processor/tests/node/chirality.js index 7d7436cf2c..b7f13214d4 100644 --- a/common/web/keyboard-processor/tests/cases/chirality.js +++ b/common/web/keyboard-processor/tests/node/chirality.js @@ -1,38 +1,33 @@ -var assert = require('chai').assert; -let fs = require('fs'); -let vm = require('vm'); +import { assert } from 'chai'; +import fs from 'fs'; -let KeyboardProcessor = require('../../build/index.bundled.js'); -let KMWRecorder = require('../../../recorder/build/nodeProctor'); +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); -// Required initialization setup. -global.com = KeyboardProcessor.com; // exports all keyboard-processor namespacing. -let KeyboardInterface = com.keyman.text.KeyboardInterface; -let Codes = com.keyman.text.Codes; +import { Codes, KeyboardInterface, MinimalKeymanGlobal } from '@keymanapp/keyboard-processor'; +import { NodeKeyboardLoader } from '@keymanapp/keyboard-processor/node-keyboard-loader'; +import { KeyboardTest, NodeProctor } from '@keymanapp/recorder-core'; describe('Engine - Chirality', function() { - let testJSONtext = fs.readFileSync('../../test/resources/json/engine_tests/chirality.json'); + let testJSONtext = fs.readFileSync(require.resolve('@keymanapp/common-test-resources/json/engine_tests/chirality.json')); // Common test suite setup. - let testSuite = new KMWRecorder.KeyboardTest(JSON.parse(testJSONtext)); + let testSuite = new KeyboardTest(JSON.parse(testJSONtext)); + + let keyboardWithHarness; - var keyboard; let device = { formFactor: 'desktop', OS: 'windows', browser: 'native' } - before(function() { + before(async function() { // -- START: Standard Recorder-based unit test loading boilerplate -- - // Load the keyboard. We'll need a KeyboardProcessor instance as an intermediary. - let kp = new KeyboardProcessor(); - - // These two lines will load a keyboard from its file; headless-mode `registerKeyboard` will - // automatically set the keyboard as active. - var script = new vm.Script(fs.readFileSync('../../test/' + testSuite.keyboard.filename)); - script.runInThisContext(); + let keyboardLoader = new NodeKeyboardLoader(new KeyboardInterface({}, MinimalKeymanGlobal)); + let keyboard = await keyboardLoader.loadKeyboardFromPath('../../test/' + testSuite.keyboard.filename); + keyboardWithHarness = keyboardLoader.harness; + keyboardWithHarness.activeKeyboard = keyboard; - keyboard = kp.activeKeyboard; assert.equal(keyboard.id, "Keyboard_" + testSuite.keyboard.id); // -- END: Standard Recorder-based unit test loading boilerplate -- @@ -42,14 +37,14 @@ describe('Engine - Chirality', function() { // Converts each test set into its own Mocha-level test. for(let set of testSuite.inputTestSets) { - let proctor = new KMWRecorder.NodeProctor(keyboard, device, assert.equal); + let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal); if(!proctor.compatibleWithSuite(testSuite)) { it.skip(set.toTestName() + " - Cannot run this test suite on Node."); } else if(set.constraint.target == 'hardware') { it(set.toTestName(), function() { // Refresh the proctor instance at runtime. - let proctor = new KMWRecorder.NodeProctor(keyboard, device, assert.equal); + let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal); set.test(proctor); }); } else { @@ -92,7 +87,7 @@ describe('Engine - Chirality', function() { // We should get the same results whether or not there actually is a corresponding modifier // expected by the rule we're examining. - mappedModifiers = KeyboardInterface.matchModifiersToRuleChirality(initialModifiers, VIRTUAL_KEY_CODE); + let mappedModifiers = KeyboardInterface.matchModifiersToRuleChirality(initialModifiers, VIRTUAL_KEY_CODE); assert.equal(targetModifiers, mappedModifiers); mappedModifiers = KeyboardInterface.matchModifiersToRuleChirality(initialModifiers, VIRTUAL_KEY_CODE | ALT_CODE); @@ -111,7 +106,7 @@ describe('Engine - Chirality', function() { // We should get the same results whether or not there actually is a corresponding modifier // expected by the rule we're examining. - mappedModifiers = KeyboardInterface.matchModifiersToRuleChirality(initialModifiers, VIRTUAL_KEY_CODE); + let mappedModifiers = KeyboardInterface.matchModifiersToRuleChirality(initialModifiers, VIRTUAL_KEY_CODE); assert.equal(targetModifiers, mappedModifiers); let ctrlPlusAlt = ALT_CODE | CTRL_CODE; @@ -250,7 +245,7 @@ describe('Engine - Chirality', function() { let modifierTarget = VIRTUAL_KEY_CODE | ALT_CODE | LCTRL_CODE | SHIFT_CODE; - mappedModifiers = KeyboardInterface.matchModifiersToRuleChirality(initialModifiers, VIRTUAL_KEY_CODE | LALT_CODE | RCTRL_CODE); + let mappedModifiers = KeyboardInterface.matchModifiersToRuleChirality(initialModifiers, VIRTUAL_KEY_CODE | LALT_CODE | RCTRL_CODE); assert.equal(modifierTarget, mappedModifiers); }); }); diff --git a/common/web/keyboard-processor/tests/node/deadkeys.js b/common/web/keyboard-processor/tests/node/deadkeys.js new file mode 100644 index 0000000000..c7e195bc2f --- /dev/null +++ b/common/web/keyboard-processor/tests/node/deadkeys.js @@ -0,0 +1,52 @@ +import { assert } from 'chai'; +import fs from 'fs'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { KeyboardInterface, MinimalKeymanGlobal } from '@keymanapp/keyboard-processor'; +import { NodeKeyboardLoader } from '@keymanapp/keyboard-processor/node-keyboard-loader'; +import { KeyboardTest, NodeProctor } from '@keymanapp/recorder-core'; + +describe('Engine - Deadkeys', function() { + let testJSONtext = fs.readFileSync(require.resolve('@keymanapp/common-test-resources/json/engine_tests/deadkeys.json')); + // Common test suite setup. + let testSuite = new KeyboardTest(JSON.parse(testJSONtext)); + + let keyboardWithHarness; + + let device = { + formFactor: 'desktop', + OS: 'windows', + browser: 'native' + } + + before(async function() { + // -- START: Standard Recorder-based unit test loading boilerplate -- + let keyboardLoader = new NodeKeyboardLoader(new KeyboardInterface({}, MinimalKeymanGlobal)); + let keyboard = await keyboardLoader.loadKeyboardFromPath('../../test/' + testSuite.keyboard.filename); + keyboardWithHarness = keyboardLoader.harness; + keyboardWithHarness.activeKeyboard = keyboard; + + assert.equal(keyboard.id, "Keyboard_" + testSuite.keyboard.id); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // This part provides extra assurance that the keyboard properly loaded. + assert.equal(keyboard.id, "Keyboard_test_deadkeys"); + }); + + // Converts each test set into its own Mocha-level test. + for(let set of testSuite.inputTestSets) { + let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal); + + if(!proctor.compatibleWithSuite(testSuite)) { + it.skip(set.toTestName() + " - Cannot run this test suite on Node."); + } else { + it(set.toTestName(), function() { + // Refresh the proctor instance at runtime. + let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal); + set.test(proctor); + }); + } + } +}); \ No newline at end of file diff --git a/common/web/keyboard-processor/tests/cases/engine/context.js b/common/web/keyboard-processor/tests/node/engine/context.js similarity index 97% rename from common/web/keyboard-processor/tests/cases/engine/context.js rename to common/web/keyboard-processor/tests/node/engine/context.js index 8b8fb34e9e..a1fe5863df 100644 --- a/common/web/keyboard-processor/tests/cases/engine/context.js +++ b/common/web/keyboard-processor/tests/node/engine/context.js @@ -1,13 +1,12 @@ -var assert = require('chai').assert; -let fs = require('fs'); -let vm = require('vm'); +import { assert } from 'chai'; -let KeyboardProcessor = require('../../../build/index.bundled.js'); +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); -// Required initialization setup. -global.com = KeyboardProcessor.com; // exports all keyboard-processor namespacing. +import { KeyboardInterface, KeyboardProcessor, MinimalKeymanGlobal, Mock } from '@keymanapp/keyboard-processor'; +import { NodeKeyboardLoader } from '@keymanapp/keyboard-processor/node-keyboard-loader'; -let KMWRecorder = require('../../../../recorder/build/nodeProctor'); +import { NodeProctor, RecordedKeystrokeSequence } from '@keymanapp/recorder-core'; /* * ABOUT THIS TEST SUITE @@ -36,7 +35,7 @@ let device = { browser: 'native' } -var keyboard; +let keyboardWithHarness; var toSupplementaryPairString = function(code){ var H = Math.floor((code - 0x10000) / 0x400) + 0xD800; @@ -60,16 +59,16 @@ function runEngineRuleSet(ruleSet, defaultNoun) { for(var j = 0; j < matchDefs.length; j++) { // Prepare the context! var matchTest = matchDefs[j]; - var ruleSeq = new KMWRecorder.RecordedKeystrokeSequence(matchTest.sequence); - let proctor = new KMWRecorder.NodeProctor(keyboard, device, assert.equal); + var ruleSeq = new RecordedKeystrokeSequence(matchTest.sequence); + let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal); // We want to specify the OutputTarget for this test; our actual concern is the resulting context. - var target = new com.keyman.text.Mock(); + var target = new Mock(); ruleSeq.test(proctor, target); // Now for the real test! let processor = new KeyboardProcessor(device); - processor.activeKeyboard = keyboard; + processor.keyboardInterface = keyboardWithHarness; var res = processor.keyboardInterface.fullContextMatch(ruleDef.n, target, ruleDef.rule); var msg = matchTest.msg; @@ -81,6 +80,8 @@ function runEngineRuleSet(ruleSet, defaultNoun) { } } +//#region Test Spec Definitions + // Unfortunately, at present, this is all handwritten stuff crafted from partial Recorder use. Might should make // a page that writes this format instead. Note that I've omitted the eventSpec part here, since these tests are // Node-only. @@ -980,16 +981,14 @@ var NOTANY_NUL_RULE_SET = [ NOTANY_NUL_TEST_1, NOTANY_NUL_TEST_2, NOTANY_NUL_TES // ----------- -describe('Engine - Context Matching', function() { - before(function() { - let kp = new KeyboardProcessor(device); - - // These two lines will load a keyboard from its file; headless-mode `registerKeyboard` will - // automatically set the keyboard as active. - var script = new vm.Script(fs.readFileSync('../../test/resources/keyboards/test_simple_deadkeys.js')); - script.runInThisContext(); +//#endregion - keyboard = kp.activeKeyboard; +describe('Engine - Context Matching', function() { + before(async function() { + let keyboardLoader = new NodeKeyboardLoader(new KeyboardInterface({}, MinimalKeymanGlobal)); + const keyboard = await keyboardLoader.loadKeyboardFromPath(require.resolve('@keymanapp/common-test-resources/keyboards/test_simple_deadkeys.js')); + keyboardWithHarness = keyboardLoader.harness; + keyboardWithHarness.activeKeyboard = keyboard; }); // Tests "stage 1" of fullContextMatch - ensuring that a proper context index map is built. @@ -999,16 +998,16 @@ describe('Engine - Context Matching', function() { for(var j = 0; j < matchDefs.length; j++) { // Prepare the context! var ruleDef = matchDefs[j]; - var ruleSeq = new KMWRecorder.RecordedKeystrokeSequence(ruleDef.baseSequence); - let proctor = new KMWRecorder.NodeProctor(keyboard, device, assert.equal); + var ruleSeq = new RecordedKeystrokeSequence(ruleDef.baseSequence); + let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal); // We want to specify the OutputTarget for this test; our actual concern is the resulting context. - var target = new com.keyman.text.Mock(); + var target = new Mock(); ruleSeq.test(proctor, target); // Now for the real test! let processor = new KeyboardProcessor(device); - processor.activeKeyboard = keyboard; + processor.keyboardInterface = keyboardWithHarness; var res = processor.keyboardInterface._BuildExtendedContext(ruleDef.n, ruleDef.ln, target); assert.sameOrderedMembers(res.valContext, ruleDef.contextCache); diff --git a/common/web/keyboard-processor/tests/cases/engine/notany_context.js b/common/web/keyboard-processor/tests/node/engine/notany_context.js similarity index 70% rename from common/web/keyboard-processor/tests/cases/engine/notany_context.js rename to common/web/keyboard-processor/tests/node/engine/notany_context.js index 2c0ba3585d..2329463d46 100644 --- a/common/web/keyboard-processor/tests/cases/engine/notany_context.js +++ b/common/web/keyboard-processor/tests/node/engine/notany_context.js @@ -1,15 +1,14 @@ -const assert = require('chai').assert; -const fs = require('fs'); -const vm = require('vm'); +import { assert } from 'chai'; -let KeyboardProcessor = require('../../../build/index.bundled.js'); +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); -// Required initialization setup. -global.com = KeyboardProcessor.com; // exports all keyboard-processor namespacing. -global.keyman = {}; // So that keyboard-based checks against the global `keyman` succeed. - // 10.0+ dependent keyboards, like khmer_angkor, will otherwise fail to load. +import { KeyboardInterface, MinimalKeymanGlobal, Mock } from '@keymanapp/keyboard-processor'; +import { NodeKeyboardLoader } from '@keymanapp/keyboard-processor/node-keyboard-loader'; +import { NodeProctor, RecordedKeystrokeSequence } from '@keymanapp/recorder-core'; +import { extendString } from '@keymanapp/web-utils'; -let KMWRecorder = require('../../../../recorder/build/nodeProctor'); +extendString(); // Ensure KMW's string-extension functionality is available. // Initialize supplementary plane string extensions String.kmwEnableSupplementaryPlane(false); @@ -20,14 +19,14 @@ const device = { browser: 'native' } -let keyboard; +let keyboardWithHarness; function runEngineRuleSet(ruleSet) { for(let ruleDef of ruleSet) { // Prepare the context! - const ruleSeq = new KMWRecorder.RecordedKeystrokeSequence(ruleDef); - const proctor = new KMWRecorder.NodeProctor(keyboard, device, assert.equal); - const target = new com.keyman.text.Mock(); + const ruleSeq = new RecordedKeystrokeSequence(ruleDef); + const proctor = new NodeProctor(keyboardWithHarness, device, assert.equal); + const target = new Mock(); ruleSeq.test(proctor, target); } } @@ -55,15 +54,11 @@ function runStringRuleSet(input, output) { // ----------- describe('Engine - notany() and context()', function() { - before(function() { - const kp = new KeyboardProcessor(); - - // These two lines will load a keyboard from its file; headless-mode `registerKeyboard` will - // automatically set the keyboard as active. - const script = new vm.Script(fs.readFileSync('../../test/resources/keyboards/test_917.js')); - script.runInThisContext(); - - keyboard = kp.activeKeyboard; + before(async function() { + let keyboardLoader = new NodeKeyboardLoader(new KeyboardInterface({}, MinimalKeymanGlobal)); + const keyboard = await keyboardLoader.loadKeyboardFromPath(require.resolve('@keymanapp/common-test-resources/keyboards/test_917.js')); + keyboardWithHarness = keyboardLoader.harness; + keyboardWithHarness.activeKeyboard = keyboard; }); /* diff --git a/common/web/keyboard-processor/tests/cases/engine/stores.js b/common/web/keyboard-processor/tests/node/engine/stores.js similarity index 84% rename from common/web/keyboard-processor/tests/cases/engine/stores.js rename to common/web/keyboard-processor/tests/node/engine/stores.js index 530ed0c931..ade1c2bd47 100644 --- a/common/web/keyboard-processor/tests/cases/engine/stores.js +++ b/common/web/keyboard-processor/tests/node/engine/stores.js @@ -1,11 +1,9 @@ -var assert = require('chai').assert; -let fs = require('fs'); -let vm = require('vm'); +import { assert } from 'chai'; -let KeyboardProcessor = require('../../../build/index.bundled.js'); +import { Keyboard, KeyboardProcessor } from '@keymanapp/keyboard-processor'; +import { extendString } from '@keymanapp/web-utils'; -// Required initialization setup. -global.com = KeyboardProcessor.com; // exports all keyboard-processor namespacing. +extendString(); let device = { formFactor: 'desktop', @@ -25,7 +23,7 @@ describe('Engine - Stores', function() { let processor = new KeyboardProcessor(device); // A 'hollow' Keyboard that only follows default rules. That said, we need a Keyboard // instance to host cache data for our exploded store tests. - processor.activeKeyboard = new com.keyman.keyboards.Keyboard(); + processor.activeKeyboard = new Keyboard(); // Function defined at top of file; creates supplementary pairs for extended Unicode codepoints. var u = toSupplementaryPairString; diff --git a/common/web/keyboard-processor/tests/node/engine/unmatched_final_group.js b/common/web/keyboard-processor/tests/node/engine/unmatched_final_group.js new file mode 100644 index 0000000000..81f9765da4 --- /dev/null +++ b/common/web/keyboard-processor/tests/node/engine/unmatched_final_group.js @@ -0,0 +1,41 @@ +import { assert } from 'chai'; +import fs from 'fs'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { KeyboardInterface, MinimalKeymanGlobal } from '@keymanapp/keyboard-processor'; +import { NodeKeyboardLoader } from '@keymanapp/keyboard-processor/node-keyboard-loader'; +import { KeyboardTest, NodeProctor } from '@keymanapp/recorder-core'; + +describe('Engine - Unmatched Final Groups', function() { + let testJSONtext = fs.readFileSync(require.resolve('@keymanapp/common-test-resources/json/engine_tests/ghp_enter.json')); + // Common test suite setup. + let testSuite = new KeyboardTest(JSON.parse(testJSONtext)); + + var keyboardWithHarness; + let device = { + formFactor: 'desktop', + OS: 'windows', + browser: 'native' + } + + before(async function() { + // -- START: Standard Recorder-based unit test loading boilerplate -- + let keyboardLoader = new NodeKeyboardLoader(new KeyboardInterface({}, MinimalKeymanGlobal)); + const keyboard = await keyboardLoader.loadKeyboardFromPath('../../test/' + testSuite.keyboard.filename); + keyboardWithHarness = keyboardLoader.harness; + keyboardWithHarness.activeKeyboard = keyboard; + + assert.equal(keyboard.id, "Keyboard_" + testSuite.keyboard.id); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // This part provides extra assurance that the keyboard properly loaded. + assert.equal(keyboard.id, "Keyboard_galaxie_hebrew_positional"); + }); + + it('Emits default enter AND matches rule from early group', function() { + let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal); + testSuite.test(proctor); + }); +}); \ No newline at end of file diff --git a/common/web/keyboard-processor/tests/node/keyboard-loading.js b/common/web/keyboard-processor/tests/node/keyboard-loading.js new file mode 100644 index 0000000000..fd29f08a27 --- /dev/null +++ b/common/web/keyboard-processor/tests/node/keyboard-loading.js @@ -0,0 +1,105 @@ +import { assert } from 'chai'; +import fs from 'fs'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { KeyboardHarness, KeyboardInterface, MinimalKeymanGlobal, Mock } from '@keymanapp/keyboard-processor'; +import { NodeKeyboardLoader } from '@keymanapp/keyboard-processor/node-keyboard-loader'; + +describe('Headless keyboard loading', function() { + const laoPath = require.resolve('@keymanapp/common-test-resources/keyboards/lao_2008_basic.js'); + const khmerPath = require.resolve('@keymanapp/common-test-resources/keyboards/khmer_angkor.js'); + // Common test suite setup. + + let device = { + formFactor: 'desktop', + OS: 'windows', + browser: 'native' + } + + describe('Minimal harness loading', () => { + it('successfully loads', async function() { + // -- START: Standard Recorder-based unit test loading boilerplate -- + let harness = new KeyboardHarness({}, MinimalKeymanGlobal); + let keyboardLoader = new NodeKeyboardLoader(harness); + let keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // Asserts that the harness's loading field is cleared once the load is complete. + assert.isNotOk(harness.loadedKeyboard); + + // Asserts that the `activeKeyboard` field was not set by the operation. + assert.isNotOk(harness.activeKeyboard); + + // This part provides assurance that the keyboard properly loaded. + assert.equal(keyboard.id, "Keyboard_lao_2008_basic"); + }); + + it('cannot evaluate rules', async function() { + // -- START: Standard Recorder-based unit test loading boilerplate -- + let harness = new KeyboardHarness({}, MinimalKeymanGlobal); + let keyboardLoader = new NodeKeyboardLoader(harness); + let keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // Runs a blank KeyEvent through the keyboard's rule processing... + // but via separate harness configured with a different captured global. + let ruleHarness = new KeyboardInterface({}, MinimalKeymanGlobal); + ruleHarness.activeKeyboard = keyboard; + try { + ruleHarness.processKeystroke(new Mock(), keyboard.constructNullKeyEvent(device)); + assert.fail(); + } catch (err) { + // Drives home an important detail: the 'global' object is effectively + // closure-captured. (Similar constraints may occur when experimenting with + // 'sandboxed' keyboard loading in the DOM!) + assert.equal(err.message, 'k.KKM is not a function'); + } + }); + }); + + describe('Full harness loading', () => { + it('successfully loads', async function() { + // -- START: Standard Recorder-based unit test loading boilerplate -- + let harness = new KeyboardInterface({}, MinimalKeymanGlobal); + let keyboardLoader = new NodeKeyboardLoader(harness); + let keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + harness.activeKeyboard = keyboard; + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // This part provides assurance that the keyboard properly loaded. + assert.equal(keyboard.id, "Keyboard_lao_2008_basic"); + }); + + it('can evaluate rules', async function() { + // -- START: Standard Recorder-based unit test loading boilerplate -- + let harness = new KeyboardInterface({}, MinimalKeymanGlobal); + let keyboardLoader = new NodeKeyboardLoader(harness); + let keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + harness.activeKeyboard = keyboard; + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // Runs a blank KeyEvent through the keyboard's rule processing. + harness.processKeystroke(new Mock(), keyboard.constructNullKeyEvent(device)); + }); + + it('does not change the active kehboard', async function() { + let harness = new KeyboardInterface({}, MinimalKeymanGlobal); + let keyboardLoader = new NodeKeyboardLoader(harness); + const lao_keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + assert.isNotOk(harness.activeKeyboard); + assert.isOk(lao_keyboard); + + // This part provides assurance that the keyboard properly loaded. + assert.equal(lao_keyboard.id, "Keyboard_lao_2008_basic"); + + harness.activeKeyboard = lao_keyboard; + + const khmer_keyboard = await keyboardLoader.loadKeyboardFromPath(khmerPath); + assert.strictEqual(lao_keyboard, harness.activeKeyboard); + + assert.equal(khmer_keyboard.id, "Keyboard_khmer_angkor"); + }); + }) +}); \ No newline at end of file diff --git a/common/web/keyboard-processor/tests/node/keyboard-properties.js b/common/web/keyboard-processor/tests/node/keyboard-properties.js new file mode 100644 index 0000000000..16f171ad68 --- /dev/null +++ b/common/web/keyboard-processor/tests/node/keyboard-properties.js @@ -0,0 +1,98 @@ +import { assert } from 'chai'; +import fs from 'fs'; +import path from 'path'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { KeyboardProperties, SpacebarText } from '@keymanapp/keyboard-processor'; + +describe('Keyboard Properties', function() { + let commonResourcesPackage = '@keymanapp/common-test-resources'; + let commonStubsSubpath = 'json/keyboards'; + let rootCommonStubPath = `${commonResourcesPackage}/${commonStubsSubpath}`; + + it('initialization from KMW\'s addKeyboards() API spec', () => { + // require.resolve cannot resolve a directory directly, unfortunately. + // Needs a file to 'root' the resolution mechanism. See https://github.com/nodejs/node/issues/42219. + const resolvedResourcesPath = path.dirname(require.resolve(`${commonResourcesPackage}/index.mjs`)); + const resolvedCommonStubPath = `${resolvedResourcesPath}/${commonStubsSubpath}`; + let files = fs.readdirSync(resolvedCommonStubPath); + + for(let file of files) { + let stub = JSON.parse(fs.readFileSync(`${resolvedCommonStubPath}/${file}`)); + + let dataset = []; + if(stub.languages instanceof Array) { + dataset = KeyboardProperties.fromMultilanguageAPIStub(stub); + } else { + dataset = [new KeyboardProperties(stub)]; + } + + // do verification + for(let data of dataset) { + assert.isOk(data.id); + assert.isOk(data.name); + assert.isOk(data.langId); + assert.isOk(data.langName); + // Do not make assertions on font, oskFont - those may be undefined for some keyboards. + + // Generated dynamically from the backing source data. + assert.isOk(data.displayName); + } + } + }); + + it('generates display-name text if not directly-specified', () => { + // Could convert to run on all stubs, but... this should be fine as-is. + let stub = JSON.parse(fs.readFileSync(require.resolve(`${rootCommonStubPath}/khmer_angkor.json`))); + let propObject = new KeyboardProperties(stub); + + // Without a configured SpacebarText value, will display the keyboard name. + assert.equal(propObject.displayName, propObject.name); + + propObject.spacebarTextMode = SpacebarText.BLANK; + assert.equal(propObject.displayName, ''); + + propObject.spacebarTextMode = SpacebarText.KEYBOARD; + assert.equal(propObject.displayName, propObject.name); + + propObject.spacebarTextMode = SpacebarText.LANGUAGE; + assert.equal(propObject.displayName, propObject.langName); + + propObject.spacebarTextMode = SpacebarText.LANGUAGE_KEYBOARD; + assert.isTrue(propObject.displayName.includes(propObject.langName) && propObject.displayName.includes(propObject.name)); + }); + + it('does not override directly-specified display-name text', () => { + // Could convert to run on all stubs, but... this should be fine as-is. + let stub = JSON.parse(fs.readFileSync(require.resolve(`${rootCommonStubPath}/khmer_angkor.json`))); + + const customDisplayName = "(custom)"; + let propObject = new KeyboardProperties(stub); + + propObject.displayName = customDisplayName; + + // Without a configured SpacebarText value, will display the keyboard name. + assert.equal(propObject.displayName, customDisplayName); + + propObject.spacebarTextMode = SpacebarText.BLANK; + assert.equal(propObject.displayName, customDisplayName); + + propObject.spacebarTextMode = SpacebarText.KEYBOARD; + assert.equal(propObject.displayName, customDisplayName); + + propObject.spacebarTextMode = SpacebarText.LANGUAGE; + assert.equal(propObject.displayName, customDisplayName); + + propObject.spacebarTextMode = SpacebarText.LANGUAGE_KEYBOARD; + assert.equal(propObject.displayName, customDisplayName); + + propObject.displayName = null; // clear the value + assert.notEqual(propObject.displayName, customDisplayName); + assert.isTrue(propObject.displayName.includes(propObject.langName) && propObject.displayName.includes(propObject.name)); + + propObject.displayName = customDisplayName; + assert.equal(propObject.displayName, customDisplayName); + }); +}); \ No newline at end of file diff --git a/common/web/keyboard-processor/tests/node/mocks.js b/common/web/keyboard-processor/tests/node/mocks.js new file mode 100644 index 0000000000..9a84e2a8cc --- /dev/null +++ b/common/web/keyboard-processor/tests/node/mocks.js @@ -0,0 +1,92 @@ +import { assert } from 'chai'; +import { Mock } from '@keymanapp/keyboard-processor'; + +describe('Mocks', function() { + describe('app|les', () => { + const testMock = new Mock('apples', 3); + + it('Cloning with .from()', () => { + assert.deepEqual(Mock.from(testMock), testMock); + assert.notStrictEqual(Mock.from(testMock), testMock); + }); + + it('getText', () => { + assert.equal(testMock.getText(), 'apples'); + }); + + it('getTextBeforeCaret', () => { + assert.equal(testMock.getTextBeforeCaret(), 'app'); + }); + + it('getTextAfterCaret', () => { + assert.equal(testMock.getTextAfterCaret(), 'les'); + }); + + it('getSelectedText', () => { + assert.equal(testMock.getSelectedText(), ''); + }); + + it('isSelectionEmpty', () => { + assert.isTrue(testMock.isSelectionEmpty()); + }); + + it('clearSelection', () => { + let editMock = Mock.from(testMock); + editMock.clearSelection(); + + assert.equal(editMock.getText(), testMock.getTextBeforeCaret() + testMock.getTextAfterCaret()); + assert.equal(editMock.getTextBeforeCaret(), testMock.getTextBeforeCaret()); + assert.equal(editMock.getTextAfterCaret(), testMock.getTextAfterCaret()); + assert.isTrue(editMock.isSelectionEmpty()); + + let postClear = Mock.from(editMock); + editMock.clearSelection(); // on same object; make sure its internal selection stuff updates correctly! + assert.notStrictEqual(postClear, editMock); + assert.deepEqual(postClear, editMock); + }); + }); + + describe('app|les and ba|nanas', () => { // selection = 'les and ba' + const testMock = new Mock('apples and bananas', 3, 13); + + it('Cloning with from()', () => { + assert.deepEqual(Mock.from(testMock), testMock); + assert.notStrictEqual(Mock.from(testMock), testMock); + }); + + it('getText', () => { + assert.equal(testMock.getText(), 'apples and bananas'); + }); + + it('getTextBeforeCaret', () => { + assert.equal(testMock.getTextBeforeCaret(), 'app'); + }); + + it('getTextAfterCaret', () => { + assert.equal(testMock.getTextAfterCaret(), 'nanas'); + }); + + it('getSelectedText', () => { + assert.equal(testMock.getSelectedText(), 'les and ba'); + }); + + it('isSelectionEmpty', () => { + assert.isFalse(testMock.isSelectionEmpty()); + }); + + it('clearSelection', () => { + let editMock = Mock.from(testMock); + editMock.clearSelection(); + + assert.equal(editMock.getText(), testMock.getTextBeforeCaret() + testMock.getTextAfterCaret()); + assert.equal(editMock.getTextBeforeCaret(), testMock.getTextBeforeCaret()); + assert.equal(editMock.getTextAfterCaret(), testMock.getTextAfterCaret()); + assert.isTrue(editMock.isSelectionEmpty()); + + let postClear = Mock.from(editMock); + editMock.clearSelection(); // on same object; make sure its internal selection stuff updates correctly! + assert.notStrictEqual(postClear, editMock); + assert.deepEqual(postClear, editMock); + }); + }); +}); \ No newline at end of file diff --git a/common/web/keyboard-processor/tests/cases/transcriptions.js b/common/web/keyboard-processor/tests/node/transcriptions.js similarity index 93% rename from common/web/keyboard-processor/tests/cases/transcriptions.js rename to common/web/keyboard-processor/tests/node/transcriptions.js index 1161955720..073e0dc6b3 100644 --- a/common/web/keyboard-processor/tests/cases/transcriptions.js +++ b/common/web/keyboard-processor/tests/node/transcriptions.js @@ -1,8 +1,9 @@ -var assert = require('chai').assert; -let KeyboardProcessor = require('../../build/index.bundled.js'); +import { assert } from 'chai'; -// Required initialization setup. -global.com = KeyboardProcessor.com; // exports all keyboard-processor namespacing. +import { Mock } from '@keymanapp/keyboard-processor'; +import { extendString } from '@keymanapp/web-utils'; + +extendString(); // Ensure KMW's string-extension functionality is available. String.kmwEnableSupplementaryPlane(false); @@ -19,8 +20,6 @@ describe("Transcriptions and Transforms", function() { let smpApple = u(0x1d5ba)+u(0x1d5c9)+u(0x1d5c9)+u(0x1d5c5)+u(0x1d5be); it("does not store an alias for related OutputTargets", function() { - var Mock = com.keyman.text.Mock; - // We have other texts validating Mocks; by using them as our base 'element', this unit test file // could eventually run in 'headless' mode. var target = new Mock("apple"); @@ -38,8 +37,6 @@ describe("Transcriptions and Transforms", function() { describe("Plain text operations", function() { it("handles context-free single-char output rules", function() { - var Mock = com.keyman.text.Mock; - // We have other texts validating Mocks; by using them as our base 'element', this unit test file // could eventually run in 'headless' mode. var target = new Mock("apple"); @@ -67,11 +64,9 @@ describe("Transcriptions and Transforms", function() { }); it("handles operations with moderately long text", function() { - var Mock = com.keyman.text.Mock; - var target = new Mock("The quick brown cat jumped onto the lazy dog.", 19); var original = Mock.from(target); - target.setDeadkeyCaret(30); // 19 + 11: moves it to after "onto". + target.setSelection(30); // 19 + 11: moves it to after "onto". target.deleteCharsBeforeCaret(14); // delete: "cat jumped onto" target.insertTextBeforeCaret("fox jumped over"); @@ -86,8 +81,6 @@ describe("Transcriptions and Transforms", function() { }); it("handles operations with long text", function() { - var Mock = com.keyman.text.Mock; - // Eh... had to pick SOMETHING. let text = `Did you ever hear the Tragedy of Darth Plagueis the wise? I thought not. It's not a story the Jedi would tell you. It's a Sith legend. Darth Plagueis was a @@ -116,11 +109,9 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. }); it("handles deletions around the caret without text insertion", function() { - var Mock = com.keyman.text.Mock; - var target = new Mock("apple", 2); var original = Mock.from(target); - target.setDeadkeyCaret(3); + target.setSelection(3); target.deleteCharsBeforeCaret(2); // "ale" /* It's not exactly black box, but presently we don't NEED the keyEvent object for the method to work. @@ -136,11 +127,9 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. it("handles deletions around the caret without text insertion (SMP text)", function() { try { String.kmwEnableSupplementaryPlane(true); - var Mock = com.keyman.text.Mock; - var target = new Mock(smpApple, 2); var original = Mock.from(target); - target.setDeadkeyCaret(3); + target.setSelection(3); target.deleteCharsBeforeCaret(2); // "ale" /* It's not exactly black box, but presently we don't NEED the keyEvent object for the method to work. @@ -157,13 +146,11 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. }); it("handles deletions around the caret with text insertion", function() { - var Mock = com.keyman.text.Mock; - // We have other texts validating Mocks; by using them as our base 'element', this unit test file // could eventually run in 'headless' mode. var target = new Mock("apple", 2); var original = Mock.from(target); - target.setDeadkeyCaret(3); + target.setSelection(3); target.deleteCharsBeforeCaret(2); target.insertTextBeforeCaret("PP"); // "aPPle" @@ -180,7 +167,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. var target = new Mock("apple", 2); var original = Mock.from(target); - target.setDeadkeyCaret(4); + target.setSelection(4); target.deleteCharsBeforeCaret(3); target.insertTextBeforeCaret("P"); // "aPe" @@ -197,7 +184,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. var target = new Mock("apple", 2); var original = Mock.from(target); - target.setDeadkeyCaret(4); + target.setSelection(4); target.deleteCharsBeforeCaret(3); target.insertTextBeforeCaret("aaaaaaaaaaaaaa"); // "aaaaaaaaaaaaaaae" @@ -214,7 +201,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. var target = new Mock("apple", 2); var original = Mock.from(target); - target.setDeadkeyCaret(5); + target.setSelection(5); target.deleteCharsBeforeCaret(4); target.insertTextBeforeCaret("les"); // "ales" - since we've appended a letter at the very end, the whole right-hand is indeed an insertion. @@ -232,14 +219,13 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. it("handles deletions around the caret with text insertion (SMP text)", function() { try { String.kmwEnableSupplementaryPlane(true); - var Mock = com.keyman.text.Mock; // We have other texts validating Mocks; by using them as our base 'element', this unit test file // could eventually run in 'headless' mode. var target = new Mock(smpApple, 2); let smpLE = u(0x1d5c5)+u(0x1d5be); var original = Mock.from(target); - target.setDeadkeyCaret(3); + target.setSelection(3); target.deleteCharsBeforeCaret(2); target.insertTextBeforeCaret(smpLE); // "alele" @@ -259,7 +245,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. var target = new Mock(smpApple, 2); let smpB = u(0x1d5bb); var original = Mock.from(target); - target.setDeadkeyCaret(4); + target.setSelection(4); target.deleteCharsBeforeCaret(3); target.insertTextBeforeCaret(smpB); // "aPe" @@ -276,7 +262,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. var target = new Mock(smpApple, 2); var original = Mock.from(target); - target.setDeadkeyCaret(4); + target.setSelection(4); target.deleteCharsBeforeCaret(3); target.insertTextBeforeCaret("aaaaaaaaaaaaaa"); // "aaaaaaaaaaaaaaae" @@ -294,7 +280,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. var target = new Mock(smpApple, 2); let smpLES = u(0x1d5c5)+u(0x1d5be)+u(0x1d5cb); var original = Mock.from(target); - target.setDeadkeyCaret(5); + target.setSelection(5); target.deleteCharsBeforeCaret(4); target.insertTextBeforeCaret(smpLES); // "ales" - since we've appended a letter at the very end, the whole right-hand is indeed an insertion. @@ -316,18 +302,17 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. /*describe("Operations with deadkeys", function() { // Just one, less nuanced/subdivided; it's not a present priority for our work, but it should provide a decent basis if/when it's needed. it("Correctly recognizes deadkey set mutations", function() { - var Mock = com.keyman.text.Mock; // We have other texts validating Mocks; by using them as our base 'element', this unit test file // could eventually run in 'headless' mode. var target = new Mock("apple"); var original = Mock.from(target); - target.setDeadkeyCaret(4); + target.setSelection(4); target.insertDeadkeyBeforeCaret(0); - target.setDeadkeyCaret(1); + target.setSelection(1); target.insertDeadkeyBeforeCaret(1); - target.setDeadkeyCaret(2); + target.setSelection(2); target.insertDeadkeyBeforeCaret(2); // 'a' dk(1) 'p' dk(2) | 'p' 'l' dk(0) 'e' var original = Mock.from(target); @@ -335,7 +320,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. target.hasDeadkeyMatch(0, 2); target.deadkeys().deleteMatched(); - target.setDeadkeyCaret(3); + target.setSelection(3); target.deleteCharsBeforeCaret(2); target.insertTextBeforeCaret("b"); target.insertDeadkeyBeforeCaret(3); // In effect: 'a' dk(1) 'b' dk(3) | 'l' dk(0) 'e' diff --git a/common/web/keyboard-processor/tsconfig-base.json b/common/web/keyboard-processor/tsconfig-base.json new file mode 100644 index 0000000000..43ee541cbc --- /dev/null +++ b/common/web/keyboard-processor/tsconfig-base.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig-base.json", + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "module": "es6", + "moduleResolution": "Node16", + "declaration": true, + "inlineSources": true, + "sourceMap": true, + "target": "es5", + "types": ["node"], + "lib": ["es6"], + "experimentalDecorators": true, + } +} diff --git a/common/web/keyboard-processor/tsconfig.common.json b/common/web/keyboard-processor/tsconfig.common.json new file mode 100644 index 0000000000..9ed8c35d54 --- /dev/null +++ b/common/web/keyboard-processor/tsconfig.common.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig-base.json", + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "module": "es6", + "moduleResolution": "Node16", + "declaration": true, + "inlineSources": true, + "sourceMap": true, + "sourceRoot": "keyman/", + "target": "es5", + "types": ["node"], + "lib": ["es6"], + "experimentalDecorators": true, + "baseUrl": "./", + "outDir": "build/obj/", + "tsBuildInfoFile": "build/obj/tsconfig.tsbuildinfo", + "rootDir": "./src" + }, + "references": [ + { "path": "../../models/types" }, + { "path": "../keyman-version/" }, + { "path": "../utils/" } + ], + "include": ["./src/**/*.ts"], + "exclude": ["./src/keyboards/loaders/**/*.ts"] +} diff --git a/common/web/keyboard-processor/tsconfig.json b/common/web/keyboard-processor/tsconfig.json new file mode 100644 index 0000000000..df6ac8969b --- /dev/null +++ b/common/web/keyboard-processor/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig-base.json", + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "module": "es6", + "moduleResolution": "Node16", + "declaration": true, + "inlineSources": true, + "sourceMap": true, + "sourceRoot": "keyman/", + "target": "es5", + "types": ["node"], + "lib": ["es6"], + "experimentalDecorators": true, + "baseUrl": "./", + "outDir": "build/obj/", + "tsBuildInfoFile": "build/obj/tsconfig.tsbuildinfo", + "rootDir": "./src" + }, + "references": [ + { "path": "./src/keyboards/loaders/tsconfig.dom.json" }, + { "path": "./src/keyboards/loaders/tsconfig.node.json" } + ], + "include": ["./src/**/*.ts"], + "exclude": ["./src/keyboards/loaders/**/*.ts"] +} diff --git a/common/web/keyman-version/build-bundler.js b/common/web/keyman-version/build-bundler.js new file mode 100644 index 0000000000..a5f26a44df --- /dev/null +++ b/common/web/keyman-version/build-bundler.js @@ -0,0 +1,22 @@ +/* + * Bundles @keymanapp/keyman-version as a single-file CommonJS module for components + * still in need of it. + */ + +import esbuild from 'esbuild'; + +// Bundles to a compact ESModule +esbuild.buildSync({ + entryPoints: ['build/version.inc.js'], + bundle: true, + sourcemap: true, + //minify: true, // No need to minify a module. + //keepNames: true, + format: "cjs", + // Sets 'common/web' as a root folder for module resolution; + // this allows the keyman-version import to resolve. + nodePaths: ['..'], + outfile: "build/version.inc.cjs", + tsconfig: 'tsconfig.json', + target: "es5" +}); \ No newline at end of file diff --git a/common/web/keyman-version/build.sh b/common/web/keyman-version/build.sh index 7cd5b25e08..8362ac29ca 100755 --- a/common/web/keyman-version/build.sh +++ b/common/web/keyman-version/build.sh @@ -29,7 +29,7 @@ builder_describe "Build the include script for current Keyman version" \ builder_describe_outputs \ configure "/node_modules" \ - build "/common/web/keyman-version/build/keyman-version.mjs" + build "/common/web/keyman-version/build/version.inc.js" builder_parse "$@" @@ -42,53 +42,41 @@ fi if builder_start_action clean; then npm run clean rm -f ./version.inc.ts + rm -f ./keyman-version.mts + rm -rf build builder_finish_action success clean fi if builder_start_action build; then - # Generate keyman-version.mts + # Generate version.inc.ts echo " - // Generated by common/web/keyman-version/build.sh - export default class KEYMAN_VERSION { - static readonly VERSION = \"$VERSION\"; - static readonly VERSION_RELEASE =\"$VERSION_RELEASE\"; - static readonly VERSION_MAJOR = \"$VERSION_MAJOR\"; - static readonly VERSION_MINOR = \"$VERSION_MINOR\"; - static readonly VERSION_PATCH = \"$VERSION_PATCH\"; - static readonly TIER =\"$TIER\"; - static readonly VERSION_TAG = \"$VERSION_TAG\"; - static readonly VERSION_WITH_TAG = \"$VERSION_WITH_TAG\"; - static readonly VERSION_ENVIRONMENT = \"$VERSION_ENVIRONMENT\"; - static readonly SENTRY_RELEASE = \"release-$VERSION_WITH_TAG\"; - } - " > ./keyman-version.mts - - # Generate version.inc.ts -- used by TypeScript code that isn't yet modular - echo " - // Generated by common/web/keyman-version/build.sh - namespace com.keyman { - export class KEYMAN_VERSION { - static readonly VERSION = \"$VERSION\"; - static readonly VERSION_RELEASE =\"$VERSION_RELEASE\"; - static readonly VERSION_MAJOR = \"$VERSION_MAJOR\"; - static readonly VERSION_MINOR = \"$VERSION_MINOR\"; - static readonly VERSION_PATCH = \"$VERSION_PATCH\"; - static readonly TIER =\"$TIER\"; - static readonly VERSION_TAG = \"$VERSION_TAG\"; - static readonly VERSION_WITH_TAG = \"$VERSION_WITH_TAG\"; - static readonly VERSION_ENVIRONMENT = \"$VERSION_ENVIRONMENT\"; - static readonly VERSION_GIT_TAG = \"$VERSION_GIT_TAG\"; - } - } +// Generated by common/web/keyman-version/build.sh +// +// Note: does not use the 'default' keyword so that the export name is +// correct when converted to a CommonJS module with \`esbuild\`. +export class KEYMAN_VERSION { + static readonly VERSION = \"$VERSION\"; + static readonly VERSION_RELEASE =\"$VERSION_RELEASE\"; + static readonly VERSION_MAJOR = \"$VERSION_MAJOR\"; + static readonly VERSION_MINOR = \"$VERSION_MINOR\"; + static readonly VERSION_PATCH = \"$VERSION_PATCH\"; + static readonly TIER =\"$TIER\"; + static readonly VERSION_TAG = \"$VERSION_TAG\"; + static readonly VERSION_WITH_TAG = \"$VERSION_WITH_TAG\"; + static readonly VERSION_ENVIRONMENT = \"$VERSION_ENVIRONMENT\"; + static readonly VERSION_GIT_TAG = \"$VERSION_GIT_TAG\"; +} + +// Also provides it as a 'default' export. +export default KEYMAN_VERSION; " > ./version.inc.ts - # Note: in a dependency build, we'll expect keyman-version to be built by tsc -b - if builder_is_dep_build; then - builder_echo "skipping tsc -b; will be completed by $builder_dep_parent" - else - echo 'Building @keymanapp/keyman-version' - tsc -b $builder_verbose - fi + tsc -b $builder_verbose + # kmlmc (the lexical model compiler) relies on a Node-based import, but after some of the earlier + # ES-modularization work, our main output's an ES module. Fortunately, esbuild can provide an easy stopgap. + + # Generates a CommonJS variant (in case other modules still need it). + node ./build-bundler.js builder_finish_action success build fi diff --git a/common/web/keyman-version/index.ts b/common/web/keyman-version/index.ts deleted file mode 100644 index 64d0ff3da8..0000000000 --- a/common/web/keyman-version/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/// - -// Aliases the namespaced type with the non-namespaced module export. -declare let KEYMAN_VERSION: typeof com.keyman.KEYMAN_VERSION; - -// Add all namespaces defined here to the global scope: -if (typeof module != 'undefined' && typeof module.exports != 'undefined') { - module.exports['KEYMAN_VERSION'] = com.keyman.KEYMAN_VERSION; -} diff --git a/common/web/keyman-version/package.json b/common/web/keyman-version/package.json index 1fd26b2898..4476b512bd 100644 --- a/common/web/keyman-version/package.json +++ b/common/web/keyman-version/package.json @@ -1,18 +1,21 @@ { "name": "@keymanapp/keyman-version", "description": "Keyman global version data", - "main": "./build/index.js", + "main": "./build/version.inc.cjs", "exports": { ".": { - "import": "./build/keyman-version.mjs", - "require": "./build/index.js" - } + "import": "./build/version.inc.js", + "require": "./build/version.inc.cjs" + }, + "./build/version.inc.cjs": "./build/version.inc.cjs" }, "scripts": { "build": "echo 'Building @keymanapp/keyman-version' && tsc -b", - "clean": "tsc -b --clean" + "clean": "tsc -b --clean", + "tsc": "tsc" }, "license": "MIT", + "type": "module", "devDependencies": { "@types/node": "^18.7.13", "typescript": "^4.9.5" diff --git a/common/web/keyman-version/tsconfig.esm.json b/common/web/keyman-version/tsconfig.esm.json deleted file mode 100644 index 1e72269873..0000000000 --- a/common/web/keyman-version/tsconfig.esm.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "../../../tsconfig-base.json", - - "compilerOptions": { - "allowJs": false, - "declaration": true, - "module": "es2020", - "outDir": "./build/", - "baseUrl": ".", - "rootDir": ".", - "sourceMap": true, - "lib": ["es6"], - "target": "es6", - }, - "include": [ - "keyman-version.mts" - ] -} diff --git a/common/web/keyman-version/tsconfig.json b/common/web/keyman-version/tsconfig.json index 18ed6ce938..8618c6dd92 100644 --- a/common/web/keyman-version/tsconfig.json +++ b/common/web/keyman-version/tsconfig.json @@ -4,15 +4,15 @@ "compilerOptions": { "allowJs": false, "declaration": true, - "module": "none", - "outFile": "./build/index.js", + "module": "es6", "sourceMap": true, + "outDir": "./build", "lib": ["es6"], "target": "es5", "downlevelIteration": true, + "rootDir": "." }, "include": [ - "index.ts", "version.inc.ts" ] } diff --git a/common/web/lm-worker/build-bundler.js b/common/web/lm-worker/build-bundler.js new file mode 100644 index 0000000000..44c03ec2f1 --- /dev/null +++ b/common/web/lm-worker/build-bundler.js @@ -0,0 +1,63 @@ +/* + * Note: while this file is not meant to exist long-term, it provides a nice + * low-level proof-of-concept for esbuild bundling of the various Web submodules. + * + * Add some extra code at the end of src/index.ts and run it to verify successful bundling! + */ + +import esbuild from 'esbuild'; +import { spawn } from 'child_process'; + +await esbuild.build({ + bundle: true, + sourcemap: true, + /* + * https://esbuild.github.io/api/#sources-content would theoretically allow us to strip the source + * while still keeping info useful for stack-tracing... but it doesn't pass through the sourcemap + * concatenation setup. + * + * That said, we know how to 'nix it ourselves in post now, so... yeah. + */ + sourcesContent: true, + sourceRoot: "/", + format: "esm", + nodePaths: ['..', '../../models'], + entryPoints: { + 'index': 'build/obj/index.js', + 'worker-main': 'build/obj/worker-main.js' + }, + outdir: 'build/lib', + outExtension: { '.js': '.mjs' }, + tsconfig: 'tsconfig.json', + target: "es5" +}); + +// Bundled CommonJS (classic Node) module version +esbuild.buildSync({ + bundle: true, + sourcemap: true, + format: "cjs", + nodePaths: ['..'], + entryPoints: { + 'index': 'build/obj/index.js', + 'worker-main': 'build/obj/worker-main.js' + }, + outdir: 'build/lib', + outExtension: { '.js': '.cjs' }, + tsconfig: 'tsconfig.json', + target: "es5" +}); + +// Direct-use version +esbuild.buildSync({ + bundle: true, + sourcemap: true, + format: "iife", + nodePaths: ['..'], + entryPoints: { + 'worker-main': 'build/obj/worker-main.js' + }, + outdir: 'build/lib', + tsconfig: 'tsconfig.json', + target: "es5" +}); diff --git a/common/web/lm-worker/build-polyfill-concatenator.js b/common/web/lm-worker/build-polyfill-concatenator.js new file mode 100644 index 0000000000..e1c750284f --- /dev/null +++ b/common/web/lm-worker/build-polyfill-concatenator.js @@ -0,0 +1,147 @@ +import fs from 'fs'; + +import SourceMapRemapper from '@keymanapp/sourcemap-path-remapper'; + +import SourceMapCombiner from 'combine-source-map'; +import convertSourceMap from 'convert-source-map'; // Transforms sourcemaps among various common formats. + // Base64, stringified-JSON, end-of-file comment... + +let loadPolyfill = function(scriptFile, sourceMapFile) { + // May want to retool the pathing somewhat! + return { + source: fs.readFileSync(scriptFile).toString(), + sourceFile: sourceMapFile || scriptFile + }; +} + +let loadCompiledModuleFilePair = function(file, mapFilename) { + let module = fs.readFileSync(file); + let moduleSourcemapJSON = JSON.parse(fs.readFileSync(file + '.map').toString()); + + // May want to retool the pathing somewhat! + return { + plainSource: `${module}`, + sourceMapAsJSON: moduleSourcemapJSON, + sourceFile: mapFilename || file, + + /**The source + inlined sourcemap-as-comment used by `combine-source-map`. */ + get source() { + let jsonAsBuffer = Buffer.from(JSON.stringify(this.sourceMapAsJSON)); + return `${this.plainSource}\n${convertSourceMap.fromJSON(jsonAsBuffer).toComment()}`; + } + }; +} + +function concatScriptsAndSourcemaps(files, finalName, separatorFile) { + let combiner = SourceMapCombiner.create(finalName); + + let finalConcatenationArray = []; + let lineCountThusFar = 0; + + for(let filePairing of files) { + let offset = { + line: lineCountThusFar + }; + + console.log(`- ${filePairing.sourceFile}`); + combiner = combiner.addFile(filePairing, offset); + + let rawSourceToConcat = filePairing.plainSource || filePairing.source; + lineCountThusFar += rawSourceToConcat.split('\n').length + 1; // Not sure why it needs the fudge-factor, but it does. + + finalConcatenationArray.push(filePairing); + if(filePairing != files[files.length-1]) { + combiner = combiner.addFile(separatorFile); + finalConcatenationArray.push(separatorFile); + } + } + + let bundledSource = finalConcatenationArray.map((pair => pair.plainSource || pair.source)).join(''); + + return { + script: bundledSource, + sourcemapJSON: JSON.parse(convertSourceMap.fromBase64(combiner.base64()).toJSON()), + scriptFilename: finalName + } +} + +// Centralized? + +console.log("Pass 1: worker + polyfill concatenation"); + +let separatorFile = { + source: ` + + `, + sourceFile: '' +} + +let sourceFileSet = [ + // Needed for Android / Chromium browser pre-45. + loadPolyfill('src/polyfills/array.fill.js', 'polyfills/array.fill.js'), + // Needed for Android / Chromium browser pre-45. + loadPolyfill('src/polyfills/array.findIndex.js', 'polyfills/array.findIndex.js'), + // Needed for Android / Chromium browser pre-45. + loadPolyfill('src/polyfills/array.from.js', 'polyfills/array.from.js'), + // Needed for Android / Chromium browser pre-47. + loadPolyfill('src/polyfills/array.includes.js', 'polyfills/array.includes.js'), + // For Object.values, for iteration over object-based associate arrays. + // Needed for Android / Chromium browser pre-54. + loadPolyfill('src/polyfills/object.values.js', 'polyfills/object.values.js'), + // Needed to support Symbol.iterator, as used by the correction algorithm. + // Needed for Android / Chromium browser pre-43. + loadPolyfill('src/polyfills/symbol-es6.min.js', 'polyfills/symbol-es6.min.js'), + loadCompiledModuleFilePair('build/lib/worker-main.mjs', 'worker-main.mjs'), +]; + +let fullWorkerConcatenation = concatScriptsAndSourcemaps(sourceFileSet, "worker-main.polyfilled.js", separatorFile); + +// New stage: cleaning the sourcemaps +console.log(); + +// Because we're compiling the main project based on its TS build outputs, and our cross-module references +// also link to build outputs, we need to clean up the sourcemap-paths. Also, the individual module sourcemaps' +// paths result in unwanted extra pathing that needs to be cleaned up (models/models, correction/correction, etc) +console.log("Pass 2: cleaning sourcemap source paths"); + +let remappingState = SourceMapRemapper + .fromObject(fullWorkerConcatenation.sourcemapJSON) + .remapPaths([ + {from: /^polyfills\//, to: '/common/web/lm-worker/src/polyfills/'}, + {from: 'models/templates/build/obj/', to :'common/models/templates/src/'}, + {from: 'models/wordbreakers/build/obj/default/default', to: 'common/models/wordbreakers/src/default/'}, + {from: 'models/wordbreakers/build/obj/', to: 'common/models/wordbreakers/src/'}, + {from: 'obj/models/models/', to: 'common/web/lm-worker/src/models/'}, + {from: 'obj/correction/correction/', to: 'common/web/lm-worker/src/correction/'}, + {from: 'obj/', to: 'common/web/lm-worker/src/'}, + {from: /^\/utils\/src\//, to: '/common/web/utils/src/'}, + {from: '/keyman-version/', to: '/common/web/keyman-version/'}, + // {from: /^\//, to: ''} // To avoid the later minification pass mangling the paths. + ], (from, to) => console.log(`- ${from} => ${to}`)); + +if(remappingState.unchangedSourcepaths.length > 0) { + console.log(); + console.log("Not mapped:"); + + for(let path of remappingState.unchangedSourcepaths) { + console.log(`- ${path}`); + } +} + +console.log(); +let sourceRoot = "/@keymanapp/keyman"; +console.log(`Setting sourceRoot: ${sourceRoot}`) +remappingState.sourceRoot = sourceRoot; +fullWorkerConcatenation.sourcemapJSON = remappingState.sourceMap; + +// End "cleaning the sourcemaps" + +console.log(); +console.log("Pass 3: Output intermediate state and perform minification"); + +// IMPORTANT: Remove file-end sourcemap ref comment and replace it! +fullWorkerConcatenation.script = fullWorkerConcatenation.script.substring(0, fullWorkerConcatenation.script.lastIndexOf('//# sourceMappingURL')); +fullWorkerConcatenation.script += `//# sourceMappingURL=${fullWorkerConcatenation.scriptFilename}.map`; + +fs.writeFileSync(`build/lib/${fullWorkerConcatenation.scriptFilename}`, fullWorkerConcatenation.script); +fs.writeFileSync(`build/lib/${fullWorkerConcatenation.scriptFilename}.map`, convertSourceMap.fromObject(fullWorkerConcatenation.sourcemapJSON).toJSON()); \ No newline at end of file diff --git a/common/web/lm-worker/build-wrap-and-minify.js b/common/web/lm-worker/build-wrap-and-minify.js new file mode 100644 index 0000000000..d8bcce5b72 --- /dev/null +++ b/common/web/lm-worker/build-wrap-and-minify.js @@ -0,0 +1,76 @@ +import fs from 'fs'; +import esbuild from 'esbuild'; + +import convertSourcemap from 'convert-source-map'; // Transforms sourcemaps among various common formats. + // Base64, stringified-JSON, end-of-file comment... + +let DEBUG = false; +let MINIFY = false; + +if(process.argv.length > 2) { + for(let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + + switch(arg) { + case '--debug': + DEBUG = true; + break; + case '--minify': + MINIFY = true; + break; + // May add other options if desired in the future. + default: + console.error("Invalid command-line option set for script; only --debug and --minify are permitted."); + process.exit(1); + } + } +} + +if(MINIFY) { + await esbuild.build({ + entryPoints: [`build/lib/worker-main.polyfilled.js`], + sourcemap: 'external', + sourcesContent: DEBUG, + minify: MINIFY, + keepNames: true, + outfile: `build/lib/worker-main.polyfilled.min.js` + }); +} + +const workerConcatenation = { + script: fs.readFileSync(`build/lib/worker-main.polyfilled${MINIFY ? '.min' : ''}.js`), + sourcemapJSON: convertSourcemap.fromJSON(fs.readFileSync(`build/lib/worker-main.polyfilled${MINIFY ? '.min' : ''}.js.map`)).toObject(), +} + +// While it IS possible to do partial sourcemaps (without the sources, but with everything else) within the worker... +// the resulting sourcemaps are -surprisingly- large - larger than the code itself! +// +// So... we don't use that strategy here. + +console.log(); +console.log(`Wrapping + generating final output: ${MINIFY ? 'minified' : 'unminified'} + ${DEBUG ? 'full sourcemaps' : 'reduced sourcemaps'}`); + +// Now, to build the wrapper... + +// First, let's build the encoded sourcemap. +const encodedSrcMap = convertSourcemap.fromObject(workerConcatenation.sourcemapJSON).toBase64(); +const srcMapString = `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${encodedSrcMap}`; + +/* + * It'd be nice to do a 'partial' encodeURIComponent that only gets the important bits... + * but my attempts to do so end up triggering errors when loading. + */ + +let wrapper = ` +// Autogenerated code. Do not modify! +// --START:LMLayerWorkerCode-- + +export var LMLayerWorkerCode = \`${encodeURIComponent(workerConcatenation.script.toString())}\` + +${MINIFY && "// Sourcemaps have been omitted for this release build."} +export var LMLayerWorkerSourcemapComment = "${DEBUG ? srcMapString : ''}"; + +// --END:LMLayerWorkerCode +`; + +fs.writeFileSync(`build/lib/worker-main.wrapped${MINIFY ? '.min' : ''}.js`, wrapper); \ No newline at end of file diff --git a/common/web/lm-worker/build.sh b/common/web/lm-worker/build.sh index 59472b0f93..d679e4a31a 100755 --- a/common/web/lm-worker/build.sh +++ b/common/web/lm-worker/build.sh @@ -18,72 +18,22 @@ THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" . "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" # This script runs from its own folder -cd "$(dirname "$THIS_SCRIPT")" - -WORKER_OUTPUT=build -WORKER_OUTPUT_FILENAME=$WORKER_OUTPUT/index.js -WORKER_TEST_BUNDLE_TARGET_FILENAME=$WORKER_OUTPUT/index.wrapped-for-bundle.ts - -# Wraps JavaScript code in a way that can be embedded in a worker. -# To get the inner source code, include the file generated by this function, -# then use name.toString() where `name` is the name passed into this -# function. -wrap-worker-code ( ) { - name="$1" - js="$2" - # Reference: https://stackoverflow.com/a/59046041 - # Use of this annotation allows us to actually build against the file, rather - # than hackily insert it later when/where needed. - echo "// @ts-nocheck" - echo "// Autogenerated code. Do not modify!" - echo "// --START:LMLayerWorkerCode--" - printf "function %s () {\n" "${name}" - - # Since the worker is compiled with "allowJS=false" so that we can make - # declaration files, we have to insert polyfills here. - - ### NOTE ### - # Android API 21 (our current minimum) released with Chrome for Android 37. - # It's also updatable as of this version, but we can't guarantee that the user - # actually updated it, especially on first launch of the Android app/keyboard. - - # This one's a minimal, targeted polyfill. es6-shim could do the same, - # but also adds a lot more code the worker doesn't need to use. - # Recommended by MDN while keeping the worker lean and efficient. - # Needed for Android / Chromium browser pre-41. - cat "../../../node_modules/string.prototype.codepointat/codepointat.js" || builder_die - - # These two are straight from MDN - I didn't find any NPM ones that don't - # use the node `require` statement for the second. They're also relatively - # short and simple, which is good. - cat "src/polyfills/array.fill.js" || builder_die # Needed for Android / Chromium browser pre-45. - cat "src/polyfills/array.findIndex.js" || builder_die # Needed for Android / Chromium browser pre-45. - cat "src/polyfills/array.from.js" || builder_die # Needed for Android / Chromium browser pre-45. - cat "src/polyfills/array.includes.js" || builder_die # Needed for Android / Chromium browser pre-47. - - # For Object.values, for iteration over object-based associate arrays. - cat "src/polyfills/object.values.js" || builder_die # Needed for Android / Chromium browser pre-54. - - # Needed to support Symbol.iterator, as used by the correction algorithm. - cat "src/polyfills/symbol-es6.min.js" || builder_die # Needed for Android / Chromium browser pre-43. - - echo "" - - cat "${js}" || builder_die - printf "\n}\n" - echo "// --END:LMLlayerWorkerCode" -} +cd "$THIS_SCRIPT_PATH" + +WORKER_OUTPUT=build/obj +WORKER_OUTPUT_FILENAME=build/lib/worker-main.js ################################ Main script ################################ builder_describe \ "Compiles the Language Modeling Layer for common use in predictive text and autocorrective applications." \ "@/common/web/keyman-version" \ + "@/common/tools/sourcemap-path-remapper" \ configure clean build test builder_describe_outputs \ configure /node_modules \ - build /common/web/lm-worker/build/index.js + build /common/web/lm-worker/build/lib/worker-main.wrapped.min.js builder_parse "$@" @@ -95,38 +45,40 @@ if builder_start_action configure; then builder_finish_action success configure fi -# We always need to clean first because the wrapping function -# breaks the typescript build, as it makes the index.tsbuildinfo file -# inconsistent with the actual index.js and causes the file to be -# corrupted on subsequent builds. In order to use the --build parameter -# of typescript, we need to avoid this! -# TODO: we should try and rework this to avoid the need to manually wrap - if builder_start_action clean; then npm run clean builder_finish_action success clean fi if builder_start_action build; then - if ! builder_has_action clean; then - npm run clean - fi - # Build worker with tsc first tsc -b $builder_verbose || builder_die "Could not build worker." - # Wrap the worker code and create embedded index.js. Must be run after the - # worker is built - echo "Wrapping worker in function LMLayerWorkerCode ${WORKER_OUTPUT_FILENAME}" - # Note: We use intermediate.js for unit tests in predictive-text - cp "${WORKER_OUTPUT_FILENAME}" "${WORKER_OUTPUT}/intermediate.js" - wrap-worker-code LMLayerWorkerCode "${WORKER_OUTPUT}/intermediate.js" > "${WORKER_OUTPUT_FILENAME}" || builder_die - cp "${WORKER_OUTPUT_FILENAME}" "${WORKER_TEST_BUNDLE_TARGET_FILENAME}" || builder_die + + echo "Bundling worker modules" + node build-bundler.js + + # Declaration bundling. + npm run tsc -- --emitDeclarationOnly --outFile ./build/lib/index.d.ts + npm run tsc -- --emitDeclarationOnly --outFile ./build/lib/worker-main.d.ts + + echo "Preparing the polyfills + worker for script-embedding" + node build-polyfill-concatenator.js + + node build-wrap-and-minify.js --debug + node build-wrap-and-minify.js --minify builder_finish_action success build fi if builder_start_action test; then - npm test || builder_die "Tests failed" + MOCHA_FLAGS= + + if builder_has_option --ci; then + MOCHA_FLAGS="$MOCHA_FLAGS --reporter mocha-teamcity-reporter" + fi + + mocha --recursive $MOCHA_FLAGS ./src/test/cases/ + builder_finish_action success test fi diff --git a/common/web/lm-worker/package.json b/common/web/lm-worker/package.json index a25290d5d3..e6037c813d 100644 --- a/common/web/lm-worker/package.json +++ b/common/web/lm-worker/package.json @@ -1,20 +1,33 @@ { "name": "@keymanapp/lm-worker", "description": "Keyman/Predictive Text worker", - "main": "build/index.js", + "main": "./build/lib/worker-main.wrapped.js", + "exports": { + "./worker-main.wrapped.js": "./build/lib/worker-main.wrapped.js", + "./worker-main.wrapped.min.js": "./build/lib/worker-main.wrapped.min.js" + }, + "imports": { + "#./*.js": "./build/obj/*.js", + "#./correction/*.js": "./build/obj/correction/*.js", + "#./models/*.js": "./build/obj/models/*.js" + }, + "type": "module", "scripts": { "karma": "karma", "mocha": "mocha", "test": "gosh ./test/test.sh", "build": "tsc -b", - "clean": "tsc -b --clean" + "clean": "tsc -b --clean", + "tsc": "tsc" }, "license": "MIT", "devDependencies": { + "@keymanapp/common-test-resources": "*", "@keymanapp/models-types": "*", "@keymanapp/resources-gosh": "*", "@types/node": "^10.17.21", "chai": "^4.3.4", + "combine-source-map": "^0.8.0", "karma": "^6.4.1", "karma-browserstack-launcher": "^1.6.0", "karma-chai": "^0.1.0", @@ -36,9 +49,10 @@ "typescript": "^4.9.5" }, "dependencies": { + "@keymanapp/keyman-version": "*", "@keymanapp/models-templates": "*", "@keymanapp/models-wordbreakers": "*", - "@keymanapp/keyman-version": "*", + "@keymanapp/sourcemap-path-remapper": "*", "@keymanapp/web-utils": "*", "es6-shim": "^0.35.5", "string.prototype.codepointat": "^0.2.1", diff --git a/common/web/lm-worker/src/correction/classical-calculation.ts b/common/web/lm-worker/src/correction/classical-calculation.ts deleted file mode 100644 index 3ac31ecd54..0000000000 --- a/common/web/lm-worker/src/correction/classical-calculation.ts +++ /dev/null @@ -1,572 +0,0 @@ -namespace correction { - type EditOperation = 'insert' | 'delete' | 'match' | 'substitute' | 'transpose-start' | 'transpose-end' | 'transpose-insert' | 'transpose-delete'; - - /** - * Represents the lowest-level unit for comparison during edit-distance calculations. - */ - export interface EditToken { - key: TUnit; - } - - // A semi-optimized 'online'/iterative Damerau-Levenshtein calculator with the following features: - // - may add new character to the 'input' string or to the 'match' string, reusing all old calculations efficiently. - // - allows a 'focused' evaluation that seeks if the edit distance is within a specific range. Designed for use in match-searching, - // where we want to find the 'closest' matching strings in a lexicon. - // - towards such a match-searching algorithm/heuristic: should nothing be found within that range, all prior calculations may be reused - // to search across the lexicon with an incremented edit distance. - // - minimized memory footprint: O(m) memory footprint (where m = length of 'input' string), rather than O(mn) (where n = length of 'match' string) - // - guaranteed to use a smaller footprint than DiagonalizedIterativeDamerauLevenshteinCalculation. - // - // In short: Used to optimize calculations for low edit-distance checks, then expanded if/as necessary - // if a greater edit distance is requested. - // - // Reference: https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm#Possible_modifications - // - Motivating statement: "if we are only interested in the distance if it is smaller than a threshold..." - export class ClassicalDistanceCalculation = EditToken, TMatch extends EditToken = EditToken> { - /** - * Stores ONLY the computed diagonal elements, nothing else. - * - * Mapped as seen in the example below (with a diagonal of width 1): - * ``` - * MAX | MAX | MAX | MAX | MAX | ... - * MAX | 0 | 1 | 2 | 3 | ... > - * MAX | 1 | a | b | - | ... ====>> | - | a | b | - * MAX | 2 | c | d | e | ... > | c | d | e | - * MAX | 3 | - | f | g | ... | f | g | ... | - * ... | ... | ... | ... | ... | ... | ... | ... | ... | - * ``` - * - * Any "`-`" entries are undefined, as they lie outside of the diagonal under consideration. - * - * Things of note: - * - The entry where row index = col index will always lie at the center of the row's array. - * - For each +1 increase in row index, the row's entries are (logically) shifted by -1 in order to make this happen. - * - As all of the MAX entries and numerical entries above are fixed, known values, they are not represented here. - */ - resolvedDistances: number[][]; - /** - * Specifies how far off-diagonal calculations should be performed. A value of 0 only evaluates cells with matching - * row and column indicies. - * - * The resulting value from .getFinalCost() is only guaranteed correct if it is less than or equal to this value. - * Otherwise, this object represents a heuristic that _may_ overestimate the true edit distance. Note that it will - * never underestimate. - */ - diagonalWidth: number = 2; // TODO: Ideally, should start at 1... but we'll start at 2 for now - // as a naive workaround for multi-char transform limitations. - - // The sequence of characters input so far. - inputSequence: TInput[] = []; - matchSequence: TMatch[] = []; - - constructor(); - constructor(other: ClassicalDistanceCalculation); - constructor(other?: ClassicalDistanceCalculation) { - if(other) { - // Clone class properties. - let rowCount = other.resolvedDistances.length; - this.resolvedDistances = Array(rowCount); - - for(let r = 0; r < rowCount; r++) { - this.resolvedDistances[r] = Array.from(other.resolvedDistances[r]); - } - - this.inputSequence = Array.from(other.inputSequence); - this.matchSequence = Array.from(other.matchSequence); - this.diagonalWidth = other.diagonalWidth; - } else { - this.resolvedDistances = []; - } - } - - private getTrueIndex(r: number, c: number, width: number): {row: number, col: number, sparse: boolean} { - let retVal = { - row: r, - col: c - r + width, - sparse: false - } - - if(retVal.col < 0 || retVal.col > 2 * width) { - retVal.sparse = true; - } - - return retVal; - } - - private getCostAt(i: number, j: number, width: number = this.diagonalWidth): number { - // Check for and handle the set of fixed-value virtualized indices. - if(i < 0 || j < 0) { - if(i == -1 && j >= -1) { - return j+1; - } else if(j == -1 && i >= -1) { - return i+1; - } - - return Number.MAX_VALUE; - } - - let index = this.getTrueIndex(i, j, width); - return index.sparse ? Number.MAX_VALUE : this.resolvedDistances[index.row][index.col]; - } - - /** - * Noting the above link's statement prefixed "By examining diagonals instead of rows, and by using lazy evaluation...", - * this function will return the actual edit distance between the strings, temporarily increasing the computed - * diagonal's size if necessary. - * - * Does not actually mutate the instance. - */ - getFinalCost(): number { - let buffer = this as ClassicalDistanceCalculation; - let val = buffer.getHeuristicFinalCost(); - - while(val > buffer.diagonalWidth) { - // A consequence of treating this class as immutable. - buffer = buffer.increaseMaxDistance(); - val = buffer.getHeuristicFinalCost(); - } - - return val; - } - - /** - * Returns this instance's computed edit distance. If greater than the diagonal's width value, note that it may be an overestimate. - */ - getHeuristicFinalCost(): number { - return this.getCostAt(this.inputSequence.length-1, this.matchSequence.length-1); - } - - /** - * Returns `true` if the represented edit distance is less than or equal to the specified threshold, minimizing the amount of calculations - * needed to meet the specified limit. - * - * Does not mutate the instance. - * @param threshold - */ - hasFinalCostWithin(threshold: number): boolean { - let buffer = this as ClassicalDistanceCalculation; - let val = buffer.getHeuristicFinalCost(); - let guaranteedBound = this.diagonalWidth; - - do { - // val will never exceed the length of the longer string, no matter how large the threshold. - if(val <= threshold) { - return true; - } else if(guaranteedBound < threshold) { - buffer = buffer.increaseMaxDistance(); - guaranteedBound++; - val = buffer.getHeuristicFinalCost(); - } else { - break; - } - } while(true); - - return false; - } - - /** - * Determines the edit path used to obtain the optimal cost, distinguishing between zero-cost - * substitutions ('match' operations) and actual substitutions. - * @param row - * @param col - */ - public editPath(row: number = this.inputSequence.length - 1, col: number = this.matchSequence.length - 1): EditOperation[] { - let currentCost = this.getCostAt(row, col); - let ops: EditOperation[] = null; - let parent: [number, number] = null; - - let insertParentCost = this.getCostAt(row, col-1); - let deleteParentCost = this.getCostAt(row-1, col); - let substitutionParentCost = this.getCostAt(row-1, col-1); - let [lastInputIndex, lastMatchIndex] = ClassicalDistanceCalculation.getTransposeParent(this, row, col); - if(lastInputIndex >= 0 && lastMatchIndex >= 0) { - // OK, a transposition source is quite possible. Still need to do more vetting, to be sure. - let expectedCost = 1; - - // This transposition includes either 'transpose-insert' or 'transpose-delete' operations. - ops = ['transpose-start']; // always needs a 'start'. - if(lastInputIndex != row-1) { - let count = row - lastInputIndex - 1; - ops = ops.concat( Array(count).fill('transpose-delete') ); - expectedCost += count; - } else { - let count = col - lastMatchIndex - 1; - ops = ops.concat( Array(count).fill('transpose-insert') ); - expectedCost += count; - } - ops.push('transpose-end'); - - // Double-check our expectations. - if(this.getCostAt(lastInputIndex-1, lastMatchIndex-1) != currentCost - expectedCost) { - ops = null; - } - parent = [lastInputIndex-1, lastMatchIndex-1]; - } - - if(ops) { - // bypass the ladder. - } else if(substitutionParentCost == currentCost - 1) { - ops = ['substitute']; - parent = [row-1, col-1]; - } else if(insertParentCost == currentCost - 1) { - ops = ['insert']; - parent = [row, col-1]; - } else if(deleteParentCost == currentCost - 1) { - ops = ['delete']; - parent = [row-1, col]; - } else { //if(substitutionParentCost == currentCost) { - ops = ['match']; - parent = [row-1, col-1]; - } - - // Recursively build the edit path. - if(parent[0] >= 0 && parent[1] >= 0) { - return this.editPath(parent[0], parent[1]).concat(ops); - } else { - if(parent[0] > -1) { - // There are initial deletions. - return Array(parent[0]+1).fill('delete').concat(ops); - } else if(parent[1] > -1) { - // There are initial insertions. - return Array(parent[1]+1).fill('insert').concat(ops); - } else { - return ops; - } - } - } - - private static getTransposeParent, TMatch extends EditToken>( - buffer: ClassicalDistanceCalculation, - r: number, - c: number - ): [number, number] { - // Block any transpositions where the tokens are identical. - // Other operations will be cheaper. Also, block cases where 'parents' are impossible. - if(r < 0 || c < 0 || buffer.inputSequence[r].key == buffer.matchSequence[c].key) { - return [-1, -1]; - } - - // Transposition checks - let lastInputIndex = -1; - for(let i = r-1; i >= 0; i--) { - if(buffer.inputSequence[i].key == buffer.matchSequence[c].key) { - lastInputIndex = i; - break; - } - } - - let lastMatchIndex = -1; - for(let i = c-1; i >= 0; i--) { - if(buffer.matchSequence[i].key == buffer.inputSequence[r].key) { - lastMatchIndex = i; - break; - } - } - - return [lastInputIndex, lastMatchIndex]; - } - - private static initialCostAt, TMatch extends EditToken>( - buffer: ClassicalDistanceCalculation, - r: number, - c: number, - insertCost?: number, - deleteCost?: number) { - var baseSubstitutionCost = buffer.inputSequence[r].key == buffer.matchSequence[c].key ? 0 : 1; - var substitutionCost: number = buffer.getCostAt(r-1, c-1) + baseSubstitutionCost; - var insertionCost: number = insertCost || buffer.getCostAt(r, c-1) + 1; // If set meaningfully, will never equal zero. - var deletionCost: number = deleteCost || buffer.getCostAt(r-1, c) + 1; // If set meaningfully, will never equal zero. - var transpositionCost: number = Number.MAX_VALUE - - if(r > 0 && c > 0) { // bypass when transpositions are known to be impossible. - let [lastInputIndex, lastMatchIndex] = ClassicalDistanceCalculation.getTransposeParent(buffer, r, c); - transpositionCost = buffer.getCostAt(lastInputIndex-1, lastMatchIndex-1) + (r - lastInputIndex - 1) + 1 + (c - lastMatchIndex - 1); - } - - return Math.min(substitutionCost, deletionCost, insertionCost, transpositionCost); - } - - getSubset(inputLength: number, matchLength: number): ClassicalDistanceCalculation { - let trimmedInstance = new ClassicalDistanceCalculation(this); - - if(inputLength > this.inputSequence.length || matchLength > this.matchSequence.length) { - throw "Invalid dimensions specified for trim operation"; - } - // Trim our tracked input & match sequences. - trimmedInstance.inputSequence.splice(inputLength); - trimmedInstance.matchSequence.splice(matchLength); - - // Major index corresponds to input length. - trimmedInstance.resolvedDistances.splice(inputLength); - - // The real fun: trimming off columns. (Minor index, corresponds to match length) - let finalTrueIndex = this.getTrueIndex(inputLength-1, matchLength-1, this.diagonalWidth); - // The diagonal index increases as the row index decreases. - for(let diagonalIndex = finalTrueIndex.col; diagonalIndex <= 2 * this.diagonalWidth; diagonalIndex++) { - let row = finalTrueIndex.row - (diagonalIndex - finalTrueIndex.col); - if(row < 0) { - break; - } - - if(diagonalIndex < 0) { - trimmedInstance.resolvedDistances[row] = Array(2 * trimmedInstance.diagonalWidth + 1).fill(Number.MAX_VALUE); - } else { - let newCount = 2 * this.diagonalWidth - diagonalIndex; - let keptEntries = trimmedInstance.resolvedDistances[row].splice(0, diagonalIndex+1); - let newEntries = Array(newCount).fill(Number.MAX_VALUE); - trimmedInstance.resolvedDistances[row] = keptEntries.concat(newEntries); - } - } - - return trimmedInstance; - } - - private static forDiagonalOfAxis(diagonalWidth: number, centerIndex: number, axisCap: number, closure: (axisIndex: number, diagIndex: number) => void) { - let diagonalCap = axisCap - centerIndex < diagonalWidth ? axisCap - centerIndex + diagonalWidth : 2 * diagonalWidth; - let startOffset = centerIndex - diagonalWidth; // The axis's index for diagonal entry 0. May be negative. - let diagonalStart = startOffset < 0 ? 0 : startOffset; - - for(let diagonalIndex = diagonalStart - startOffset; diagonalIndex <= diagonalCap; diagonalIndex++) { - closure(startOffset + diagonalIndex, diagonalIndex); - } - } - - // Inputs add an extra row / first index entry. - addInputChar(token: TInput): ClassicalDistanceCalculation { - let returnBuffer = new ClassicalDistanceCalculation(this); - - let r = returnBuffer.inputSequence.length; - returnBuffer.inputSequence.push(token); - - // Insert a row, even if we don't actually do anything with it yet. - // Initialize all entries with Number.MAX_VALUE, as `undefined` use leads to JS math issues. - let row = Array(2 * returnBuffer.diagonalWidth + 1).fill(Number.MAX_VALUE); - returnBuffer.resolvedDistances[r] = row; - - // If there isn't a 'match' entry yet, there are no values to compute. Exit immediately. - if(returnBuffer.matchSequence.length == 0) { - return returnBuffer; - } - - ClassicalDistanceCalculation.forDiagonalOfAxis(returnBuffer.diagonalWidth, r, returnBuffer.matchSequence.length - 1, function(c, diagIndex) { - row[diagIndex] = ClassicalDistanceCalculation.initialCostAt(returnBuffer, r, c); - }); - - return returnBuffer; - } - - addMatchChar(token: TMatch): ClassicalDistanceCalculation { - let returnBuffer = new ClassicalDistanceCalculation(this); - - let c = returnBuffer.matchSequence.length; - returnBuffer.matchSequence.push(token); - - // If there isn't a 'match' entry yet, there are no values to compute. Exit immediately. - if(returnBuffer.inputSequence.length == 0) { - return returnBuffer; - } - - ClassicalDistanceCalculation.forDiagonalOfAxis(returnBuffer.diagonalWidth, c, returnBuffer.inputSequence.length - 1, function(r, diagIndex) { - var row = returnBuffer.resolvedDistances[r]; - // Since diagIndex is from the perspective of the row, it must be inverted to properly index the column. - row[2 * returnBuffer.diagonalWidth - diagIndex] = ClassicalDistanceCalculation.initialCostAt(returnBuffer, r, c); - }); - - return returnBuffer; - } - - public increaseMaxDistance(): ClassicalDistanceCalculation { - let returnBuffer = new ClassicalDistanceCalculation(this); - returnBuffer.diagonalWidth++; - - if(returnBuffer.inputSequence.length < 1 || returnBuffer.matchSequence.length < 1) { - return returnBuffer; - } - - // An abstraction of the common aspects of transposition handling during diagonal extensions. - function forPossibleTranspositionsInDiagonal(startPos: number, fixedChar: TUnit, lookupString: EditToken[], closure: (axisIndex: number, diagIndex: number) => void) { - let diagonalCap = 2 * (returnBuffer.diagonalWidth - 1); // The maximum diagonal index permitted - let axisCap = lookupString.length - 1; // The maximum index supported by the axis of iteration - - // Ensures that diagonal iteration only occurs within the axis's supported range - diagonalCap = diagonalCap < axisCap - startPos ? diagonalCap : axisCap - startPos; - - // Iterate within the diagonal and call our closure for any potential transpositions. - for(let diagonalIndex = 0; diagonalIndex <= diagonalCap; diagonalIndex++) { - if(fixedChar == lookupString[startPos + diagonalIndex].key) { - closure(startPos + diagonalIndex, diagonalIndex); - } - } - } - - for(let r = 0; r < returnBuffer.inputSequence.length; r++) { - let leftCell = Number.MAX_VALUE; - let c = r - returnBuffer.diagonalWidth // External index of the left-most entry, which we will now calculate. - if(c >= 0) { - // If c == 0, cell is at edge, thus a known value for insertions exists. - // Base cost: r+1, +1 for inserting. - let insertionCost = c == 0 ? r + 2 : Number.MAX_VALUE; - // compute new left cell - leftCell = ClassicalDistanceCalculation.initialCostAt(returnBuffer, r, c, insertionCost, undefined); - let addedCost = leftCell; - - // daisy-chain possible updates - - // cell (r, c+1): new insertion source - if(c < returnBuffer.matchSequence.length-1) { - // We propagate the new added cost (via insertion) to the old left-most cell, which is one to our right. - ClassicalDistanceCalculation.propagateUpdateFrom(returnBuffer, r, c+1, addedCost+1, 0); - - // Only possible if insertions are also possible AND more conditions are met. - // cells (r+2, * > c+2): new transposition source - let transposeRow = r+2; - if(r+2 < this.inputSequence.length) { // Row to check for transposes must exist. - let rowChar = returnBuffer.inputSequence[r+1].key; - // First possible match in input could be at index c + 2, which adjusts col c+2's cost. Except that entry in r+2 - // doesn't exist yet - so we start with c+3 instead. - forPossibleTranspositionsInDiagonal(c + 3, rowChar, returnBuffer.matchSequence, function(axisIndex, diagIndex) { - // Because (r+2, c+3) is root, not (r+2, c+2). Min cost of 2. - ClassicalDistanceCalculation.propagateUpdateFrom(returnBuffer, transposeRow, axisIndex, addedCost + diagIndex + 2, diagIndex); - }); - } - } - } - - let rightCell = Number.MAX_VALUE; - c = r + returnBuffer.diagonalWidth; - if(c < returnBuffer.matchSequence.length) { - // If r == 0, cell is at edge, thus a known value for insertions exists. - // Base cost: c+1, +1 for inserting. - let deletionCost = r == 0 ? c + 2 : Number.MAX_VALUE; - - // the current row wants to use adjusted diagonal width; we must specify use of the old width & its mapping instead. - var insertionCost: number = returnBuffer.getCostAt(r, c-1, this.diagonalWidth) + 1; - - // compute new right cell - rightCell = ClassicalDistanceCalculation.initialCostAt(returnBuffer, r, c, insertionCost, deletionCost); - let addedCost = rightCell; - - // daisy-chain possible updates - - // cell (r+1, c): new deletion source - if(r < returnBuffer.inputSequence.length - 1) { - // We propagate the new added cost (via deletion) to the old right-most cell, which is one to our right. - ClassicalDistanceCalculation.propagateUpdateFrom(returnBuffer, r+1, c, addedCost + 1, 2 * this.diagonalWidth); - - // Only possible if deletions are also possible AND more conditions are met. - // cells(* > r+2, c+2): new transposition source - let transposeCol = c+2; - if(c+2 < this.matchSequence.length) { // Row to check for transposes must exist. - let colChar = returnBuffer.matchSequence[r+1].key; - // First possible match in input could be at index r + 2, which adjusts row r+2's cost. Except that entry in c+2 - // doesn't exist yet - so we start with r+3 instead. - forPossibleTranspositionsInDiagonal(r+3, colChar, returnBuffer.inputSequence, function(axisIndex, diagIndex) { - let diagColIndex = 2 * (returnBuffer.diagonalWidth - 1) - diagIndex; - // Because (r+3, c+2) is root, not (r+2, c+2). Min cost of 2. - ClassicalDistanceCalculation.propagateUpdateFrom(returnBuffer, axisIndex, transposeCol, addedCost + diagIndex + 2, diagColIndex); - }); - } - } - } - - // Constructs the final expanded diagonal for the row. - returnBuffer.resolvedDistances[r] = [leftCell].concat(returnBuffer.resolvedDistances[r], rightCell); - } - - return returnBuffer; - } - - private static propagateUpdateFrom, TMatch extends EditToken>( - buffer: ClassicalDistanceCalculation, - r: number, - c: number, - value: number, - diagonalIndex: number) { - // Note: this function does not actually need the `c` parameter! - // That said, it's very useful when tracing stack traces & debugging. - if(value < buffer.resolvedDistances[r][diagonalIndex]) { - buffer.resolvedDistances[r][diagonalIndex] = value; - } else { - return - } - - let internalRow = r < buffer.inputSequence.length - 1; - let internalCol = c < buffer.matchSequence.length - 1; - - // We have to compensate for the current & following rows not having been expanded yet. - if(diagonalIndex < 2 * (buffer.diagonalWidth - 1) && internalCol) { - // We've inserted to the left of an existing calculation - check for propagation via insertion. - let updateCost = value + 1; - this.propagateUpdateFrom(buffer, r, c+1, updateCost, diagonalIndex+1); - } - - if(diagonalIndex > 0 && internalRow) { - // We've inserted above an existing calculation - check for propagation via deletion - let updateCost = value + 1 - this.propagateUpdateFrom(buffer, r+1, c, updateCost, diagonalIndex-1); - } - - // If both, check for propagation via substitution and possible transpositions - if(internalRow && internalCol) { - let updateCost = value + (buffer.inputSequence[r+1].key == buffer.matchSequence[c+1].key ? 0 : 1); - this.propagateUpdateFrom(buffer, r+1, c+1, updateCost, diagonalIndex); - - // Propagating transpositions (only possible if 'internal'.) - let nextInputIndex = -1; - for(let i = r+2; i < buffer.inputSequence.length; i++) { - if(buffer.inputSequence[i].key == buffer.matchSequence[c+1].key) { - nextInputIndex = i; - break; - } - } - - let nextMatchIndex = -1; - for(let i = c+2; i < buffer.matchSequence.length; i++) { - if(buffer.matchSequence[i].key == buffer.inputSequence[r+1].key) { - nextMatchIndex = i; - break; - } - } - - if(nextInputIndex > 0 && nextMatchIndex > 0) { - let transpositionCost = value + (nextInputIndex - r - 2) + 1 + (nextMatchIndex - c - 2); - this.propagateUpdateFrom(buffer, nextInputIndex, nextMatchIndex, transpositionCost, (buffer.diagonalWidth - 1) + nextMatchIndex - nextInputIndex); - } - } - } - - get mapKey(): string { - let inputString = this.inputSequence.map((value) => value.key).join(''); - let matchString = this.matchSequence.map((value) => value.key).join(''); - return inputString + models.SENTINEL_CODE_UNIT + matchString + models.SENTINEL_CODE_UNIT + this.diagonalWidth; - } - - get lastInputEntry(): TInput { - return this.inputSequence[this.inputSequence.length-1]; - } - - get lastMatchEntry(): TMatch { - return this.matchSequence[this.matchSequence.length-1]; - } - - static computeDistance, TMatch extends EditToken>( - input: TInput[], - match: TMatch[], - bandSize: number = 1) { - // Initialize the calculation buffer, setting the diagonal width (as appropriate) in advance. - let buffer = new ClassicalDistanceCalculation(); - bandSize = bandSize || 1; - buffer.diagonalWidth = bandSize; - - for(let i = 0; i < input.length; i++) { - buffer = buffer.addInputChar(input[i]); - } - - for(let j = 0; j < match.length; j++) { - buffer = buffer.addMatchChar(match[j]); - } - - return buffer; - } - } -} diff --git a/common/web/lm-worker/src/correction/context-tracker.ts b/common/web/lm-worker/src/correction/context-tracker.ts deleted file mode 100644 index e17e201d78..0000000000 --- a/common/web/lm-worker/src/correction/context-tracker.ts +++ /dev/null @@ -1,539 +0,0 @@ -/// - -namespace correction { - - function textToCharTransforms(text: string, transformId?: number) { - let perCharTransforms: Transform[] = []; - - for(let i=0; i < text.kmwLength(); i++) { - let char = text.kmwCharAt(i); // is SMP-aware - - let transform: Transform = { - insert: char, - deleteLeft: 0, - id: transformId - }; - - perCharTransforms.push(transform); - } - - return perCharTransforms; - } - export class TrackedContextSuggestion { - suggestion: Suggestion; - tokenWidth: number; - } - - export class TrackedContextToken { - raw: string; - replacementText: string; - - transformDistributions: Distribution[] = []; - replacements: TrackedContextSuggestion[]; - activeReplacementId: number = -1; - - get currentText(): string { - if(this.replacementText === undefined || this.replacementText === null) { - return this.raw; - } else { - return this.replacementText; - } - } - - get replacement(): TrackedContextSuggestion { - let replacementId = this.activeReplacementId; - return this.replacements.find(function(replacement) { - return replacement.suggestion.id == replacementId; - }); - } - - revert() { - delete this.activeReplacementId; - } - } - - export class TrackedContextState { - // Stores the source Context (as a debugging reference). Not currently utilized. - taggedContext: Context; - model: LexicalModel; - - tokens: TrackedContextToken[]; - /** - * How many tokens were removed from the start of the best-matching ancestor. - * Useful for restoring older states, e.g., when the user moves the caret backwards, we can recover the context at that position. - */ - indexOffset: number; - - // Tracks all search spaces starting at the current token. - // In the lm-layer's current form, this should only ever have one entry. - // Leaves 'design space' for if/when we add support for phrase-level corrections/predictions. - searchSpace: SearchSpace[] = []; - - constructor(source: TrackedContextState); - constructor(model: LexicalModel); - constructor(obj: TrackedContextState | LexicalModel) { - if(obj instanceof TrackedContextState) { - let source = obj; - // Be sure to deep-copy the tokens! Pointer-aliasing is bad here. - this.tokens = source.tokens.map(function(token) { - let copy = new TrackedContextToken(); - copy.raw = token.raw; - copy.replacements = token.replacements - copy.activeReplacementId = token.activeReplacementId; - copy.transformDistributions = token.transformDistributions; - - if(token.replacementText) { - copy.replacementText = token.replacementText; - } - - return copy; - }); - this.searchSpace = obj.searchSpace; - this.indexOffset = 0; - this.model = obj.model; - } else { - let lexicalModel = obj; - this.tokens = []; - this.indexOffset = Number.MIN_SAFE_INTEGER; - this.model = lexicalModel; - - if(lexicalModel && lexicalModel.traverseFromRoot) { - this.searchSpace = [new SearchSpace(lexicalModel)]; - } - } - } - - get head(): TrackedContextToken { - return this.tokens[0]; - } - - get tail(): TrackedContextToken { - return this.tokens[this.tokens.length - 1]; - } - - popHead() { - this.tokens.splice(0, 2); - this.indexOffset -= 1; - } - - pushTail(token: TrackedContextToken) { - if(this.model && this.model.traverseFromRoot) { - this.searchSpace = [new SearchSpace(this.model)]; // yeah, need to update SearchSpace for compatibility - } else { - this.searchSpace = []; - } - this.tokens.push(token); - - let state = this; - if(state.searchSpace.length > 0) { - token.transformDistributions.forEach(distrib => state.searchSpace[0].addInput(distrib)); - } - } - - pushWhitespaceToTail(transformDistribution: Distribution = null) { - let whitespaceToken = new TrackedContextToken(); - - // Track the Transform that resulted in the whitespace 'token'. - // Will be needed for phrase-level correction/prediction. - whitespaceToken.transformDistributions = transformDistribution ? [transformDistribution] : []; - - whitespaceToken.raw = null; - this.tokens.push(whitespaceToken); - } - - /** - * Used for 14.0's backspace workaround, which flattens all previous Distribution - * entries because of limitations with direct use of backspace transforms. - * @param tokenText - * @param transformId - */ - replaceTailForBackspace(tokenText: USVString, transformId: number) { - this.tokens.pop(); - - // It's a backspace transform; time for special handling! - // - // For now, with 14.0, we simply compress all remaining Transforms for the token into - // multiple single-char transforms. Probabalistically modeling BKSP is quite complex, - // so we simplify by assuming everything remaining after a BKSP is 'true' and 'intended' text. - // - // Note that we cannot just use a single, monolithic transform at this point b/c - // of our current edit-distance optimization strategy; diagonalization is currently... - // not very compatible with that. - let backspacedTokenContext: Distribution[] = textToCharTransforms(tokenText, transformId).map(function(transform) { - return [{sample: transform, p: 1.0}]; - }); - - let compactedToken = new TrackedContextToken(); - compactedToken.raw = tokenText; - compactedToken.transformDistributions = backspacedTokenContext; - this.pushTail(compactedToken); - } - - updateTail(transformDistribution: Distribution, tokenText?: USVString) { - let editedToken = this.tail; - - // Preserve existing text if new text isn't specified. - tokenText = tokenText || (tokenText === '' ? '' : editedToken.raw); - - if(transformDistribution && transformDistribution.length > 0) { - editedToken.transformDistributions.push(transformDistribution); - if(this.searchSpace) { - this.searchSpace.forEach(space => space.addInput(transformDistribution)); - } - } - // Replace old token's raw-text with new token's raw-text. - editedToken.raw = tokenText; - } - - toRawTokenization() { - let sequence: USVString[] = []; - - for(let token of this.tokens) { - // Hide any tokens representing wordbreaks. (Thinking ahead to phrase-level possibilities) - if(token.currentText !== null) { - sequence.push(token.currentText); - } - } - - return sequence; - } - } - - class CircularArray { - static readonly DEFAULT_ARRAY_SIZE = 5; - private circle: Item[]; - private currentHead: number=0; - private currentTail: number=0; - - constructor(size: number = CircularArray.DEFAULT_ARRAY_SIZE) { - this.circle = Array(size); - } - - get count(): number { - let diff = this.currentHead - this.currentTail; - - if(diff < 0) { - diff = diff + this.circle.length; - } - - return diff; - } - - get maxCount(): number { - return this.circle.length; - } - - get oldest(): Item { - if(this.count == 0) { - return undefined; - } - - return this.item(0); - } - - get newest(): Item { - if(this.count == 0) { - return undefined; - } - - return this.item(this.count - 1); - } - - enqueue(item: Item): Item { - var prevItem = null; - let nextHead = (this.currentHead + 1) % this.maxCount; - - if(nextHead == this.currentTail) { - prevItem = this.circle[this.currentTail]; - this.currentTail = (this.currentTail + 1) % this.maxCount; - } - - this.circle[this.currentHead] = item; - this.currentHead = nextHead; - - return prevItem; - } - - dequeue(): Item { - if(this.currentTail == this.currentHead) { - return null; - } else { - let item = this.circle[this.currentTail]; - this.currentTail = (this.currentTail + 1) % this.maxCount; - return item; - } - } - - popNewest(): Item { - if(this.currentTail == this.currentHead) { - return null; - } else { - let item = this.circle[this.currentHead]; - this.currentHead = (this.currentHead - 1 + this.maxCount) % this.maxCount; - return item; - } - } - - /** - * Returns items contained within the circular array, ordered from 'oldest' to 'newest' - - * the same order in which the items will be dequeued. - * @param index - */ - item(index: number) { - if(index >= this.count) { - throw "Invalid array index"; - } - - let mappedIndex = (this.currentTail + index) % this.maxCount; - return this.circle[mappedIndex]; - } - } - - export class ContextTracker extends CircularArray { - static attemptMatchContext(tokenizedContext: USVString[], - matchState: TrackedContextState, - transformDistribution?: Distribution,): TrackedContextState { - // Map the previous tokenized state to an edit-distance friendly version. - let matchContext: USVString[] = matchState.toRawTokenization(); - - // Inverted order, since 'match' existed before our new context. - let mapping = ClassicalDistanceCalculation.computeDistance(matchContext.map(value => ({key: value})), - tokenizedContext.map(value => ({key: value})), - 1); - - let editPath = mapping.editPath(); - - let poppedHead = false; - let pushedTail = false; - - // Matters greatly when starting from a nil context. - if(editPath.length > 1) { - // First entry: may not be an 'insert' or a 'transpose' op. - // 'insert' allowed if the next token is 'substitute', as this may occur with an edit path of length 2. - if((editPath[0] == 'insert' && !(editPath[1] == 'substitute' && editPath.length == 2)) || editPath[0].indexOf('transpose') >= 0) { - return null; - } else if(editPath[0] == 'delete') { - poppedHead = true; // a token from the previous state has been wholly removed. - } - } - - // Last entry: may not be a 'delete' or a 'transpose' op. - let tailIndex = editPath.length -1; - let ignorePenultimateMatch = false; - if(editPath[tailIndex] == 'delete' || editPath[0].indexOf('transpose') >= 0) { - return null; - } else if(editPath[tailIndex] == 'insert') { - pushedTail = true; - } else if(tailIndex > 0 && editPath[tailIndex-1] == 'insert' && editPath[tailIndex] == 'substitute') { - // Tends to happen when accepting suggestions. - pushedTail = true; - ignorePenultimateMatch = true; - } - - // Can happen for the first text input after backspace deletes a wordbreaking character, - // thus the new input continues a previous word while dropping the empty word after - // that prior wordbreaking character. - // - // We can't handle it reliably from this match state, but a previous entry (without the empty token) - // should still be in the cache and will be reliable for this example case. - if(tailIndex > 0 && editPath[tailIndex-1] == 'delete' && editPath[tailIndex] == 'substitute') { - return null; - } - - // Now to check everything in-between: should be exclusively 'match'es. - for(let index = 1; index < editPath.length - (ignorePenultimateMatch ? 2 : 1); index++) { - if(editPath[index] != 'match') { - return null; - } - } - - // If we've made it here... success! We have a context match! - let state: TrackedContextState; - - if(pushedTail) { - // On suggestion acceptance, we should update the previous final token. - // We do it first so that the acceptance is replicated in the new TrackedContextState - // as well. - if(ignorePenultimateMatch) { - // For this case, we were likely called by ModelCompositor.acceptSuggestion(), which - // would have marked the accepted suggestion. - matchState.tail.replacementText = tokenizedContext[tokenizedContext.length-2]; - } - - state = new TrackedContextState(matchState); - } else { - // Since we're continuing a previously-cached context, we can reuse the same SearchSpace - // to continue making predictions. - state = matchState; - } - - const hasDistribution = transformDistribution && Array.isArray(transformDistribution); - let primaryInput = hasDistribution ? transformDistribution[0].sample : null; - if(primaryInput && primaryInput.insert == "" && primaryInput.deleteLeft == 0 && !primaryInput.deleteRight) { - primaryInput = null; - } - - const isWhitespace = primaryInput && TransformUtils.isWhitespace(primaryInput); - const isBackspace = primaryInput && TransformUtils.isBackspace(primaryInput); - const finalToken = tokenizedContext[tokenizedContext.length-1]; - - /* Assumption: This is an adequate check for its two sub-branches. - * - * Basis: - * - Assumption: one keystroke may only cause a single token to rotate out of context. - * - That is, no "reasonable" keystroke would emit enough code points to 'bump' two words simultaneously. - * - ... This one may need to be loosened a bit... but it should be enough for initial correction testing as-is. - * - Assumption: one keystroke may only cause a single token to be appended to the context - * - That is, no "reasonable" keystroke would emit a Transform adding two separate word tokens - * - For languages using whitespace to word-break, said keystroke would have to include said whitespace to break the assumption. - */ - - // If there is/was more than one context token available... - if(editPath.length > 1) { - // We're removing a context token, but at least one remains. - if(poppedHead) { - state.popHead(); - } - - // We're adding an additional context token. - if(pushedTail) { - const tokenizedTail = tokenizedContext[tokenizedContext.length - 1]; - /* - * Common-case: most transforms that trigger this case are from pure-whitespace Transforms. MOST. - * - * Less-common, but noteworthy: some wordbreaks may occur without whitespace. Example: - * `"o` => ['"', 'o']. Make sure to double-check against `tokenizedContext`! - */ - let pushedToken = new TrackedContextToken(); - pushedToken.raw = tokenizedTail; - - if(isWhitespace || !primaryInput) { - state.pushWhitespaceToTail(transformDistribution ?? []); - // Continuing the earlier assumption, that 'pure-whitespace Transform' does not emit any initial characters - // for the new word (token), so the input keystrokes do not correspond to the new text token. - pushedToken.transformDistributions = []; - } else { - state.pushWhitespaceToTail(); - // Assumption: Since we only allow one-transform-at-a-time changes between states, we shouldn't be missing - // any metadata used to construct the new context state token. - pushedToken.transformDistributions = transformDistribution ? [transformDistribution] : []; - } - - state.pushTail(pushedToken); - } else { // We're editing the final context token. - // TODO: Assumption: we didn't 'miss' any inputs somehow. - // As is, may be prone to fragility should the lm-layer's tracked context 'desync' from its host's. - if(isBackspace) { - state.replaceTailForBackspace(finalToken, primaryInput.id); - } else { - state.updateTail(primaryInput ? transformDistribution : null, finalToken); - } - } - // There is only one word in the context. - } else { - // TODO: Assumption: we didn't 'miss' any inputs somehow. - // As is, may be prone to fragility should the lm-layer's tracked context 'desync' from its host's. - - if(editPath[tailIndex] == 'insert') { - // Construct appropriate initial token. - let token = new TrackedContextToken(); - token.raw = tokenizedContext[0]; - token.transformDistributions = [transformDistribution]; - state.pushTail(token); - } else { // Edit the lone context token. - // Consider backspace entry for this case? - if(isBackspace) { - state.replaceTailForBackspace(finalToken, primaryInput.id); - } else { - state.updateTail(primaryInput ? transformDistribution : null, finalToken); - } - } - } - return state; - } - - static modelContextState(tokenizedContext: USVString[], - transformDistribution: Distribution, - lexicalModel: LexicalModel): TrackedContextState { - let baseTokens = tokenizedContext.map(function(entry) { - let token = new TrackedContextToken(); - token.raw = entry; - if(token.raw) { - token.transformDistributions = textToCharTransforms(token.raw).map(function(transform) { - return [{sample: transform, p: 1.0}]; - }); - } else { - // Helps model context-final wordbreaks. - token.transformDistributions = []; - } - return token; - }); - - // And now build the final context state object, which includes whitespace 'tokens'. - let state = new TrackedContextState(lexicalModel); - - if(baseTokens.length > 0) { - state.pushTail(baseTokens.splice(0, 1)[0]); - } - - while(baseTokens.length > 0) { - state.pushWhitespaceToTail(); - state.pushTail(baseTokens.splice(0, 1)[0]); - } - - if(state.tokens.length == 0) { - let token = new TrackedContextToken(); - token.raw = ''; - - state.pushTail(token); - } - - return state; - } - - /** - * Compares the current, post-input context against the most recently-seen contexts from previous prediction calls, returning - * the most information-rich `TrackedContextState` possible. If a match is found, the state will be annotated with the - * input information provided to previous prediction calls and persisted correction-search calculations for re-use. - * - * @param model - * @param context - * @param transformDistribution - */ - analyzeState(model: LexicalModel, - context: Context, - transformDistribution?: Distribution): TrackedContextState { - if(!model.traverseFromRoot) { - // Assumption: LexicalModel provides a valid traverseFromRoot function. (Is technically optional) - // Without it, no 'corrections' may be made; the model can only be used to predict, not correct. - throw "This lexical model does not provide adequate data for correction algorithms and context reuse"; - } - - let tokenizedContext = models.tokenize(model.wordbreaker || wordBreakers.default, context); - - if(tokenizedContext.left.length > 0) { - for(let i = this.count - 1; i >= 0; i--) { - let resultState = ContextTracker.attemptMatchContext(tokenizedContext.left, this.item(i), transformDistribution); - - if(resultState) { - resultState.taggedContext = context; - if(resultState != this.item(i)) { - this.enqueue(resultState); - } - return resultState; - } - } - } - - // Else: either empty OR we've detected a 'new context'. Initialize from scratch; no prior input information is - // available. Only the results of the prior inputs are known. - // - // Assumption: as a caret needs to move to context before any actual transform distributions occur, - // this state is only reached on caret moves; thus, transformDistribution is actually just a single null transform. - let state = ContextTracker.modelContextState(tokenizedContext.left, transformDistribution, model); - state.taggedContext = context; - this.enqueue(state); - return state; - } - } -} diff --git a/common/web/lm-worker/src/correction/distance-modeler.ts b/common/web/lm-worker/src/correction/distance-modeler.ts deleted file mode 100644 index 723cbc93ca..0000000000 --- a/common/web/lm-worker/src/correction/distance-modeler.ts +++ /dev/null @@ -1,805 +0,0 @@ -/// - -namespace correction { - type RealizedInput = ProbabilityMass[]; // NOT Distribution - they're masses from separate distributions. - - export type TraversableToken = { - key: TUnit, - traversal: LexiconTraversal - } - - export const QUEUE_NODE_COMPARATOR: models.Comparator = function(arg1, arg2) { - return arg1.currentCost - arg2.currentCost; - } - - // Represents a processed node for the correction-search's search-space's tree-like graph. May represent - // internal and 'leaf' nodes on said graph, as well as the overall root of the search. Also used to represent - // edges on the graph TO said nodes - there's a bit of overloading here. Either way, it stores the cost of the - // optimum path used to reach the ndoe. - // - // The stored path cost may be an overestimate when the edit distance is greater than the current search threshold. The - // first version of the node to be dequeued from SearchSpace's priority queue hierarchy 'wins' and is taken as the absolute - // minimum; subsequent versions are ignored as suboptimal. - // - // Provides functions usable to enumerate across the node's outward edges to new nodes for continued search. - // Most of the actual calculations occur as part of this process. - // - // For nodes with raw edit-distance cost within the current threshold for correction searches, we do have admissibility. - // If not enough nodes are available within that threshold, however, admissibility may be lost, leaving our search as a - // heuristic. - // - export class SearchNode { - calculation: ClassicalDistanceCalculation, TraversableToken>; - - currentTraversal: LexiconTraversal; - toKey: (wordform: USVString) => USVString = str => str; - priorInput: RealizedInput; - - // Internal lazy-cache for .inputSamplingCost, as it's a bit expensive to re-compute. - private _inputCost?: number; - - constructor(rootTraversal: LexiconTraversal, toKey?: (USVString) => USVString); - constructor(node: SearchNode); - constructor(rootTraversal: LexiconTraversal | SearchNode, toKey?: (USVString) => USVString) { - toKey = toKey || (x => x); - - if(rootTraversal instanceof SearchNode) { - let priorNode = rootTraversal; - this.calculation = priorNode.calculation; - this.currentTraversal = priorNode.currentTraversal; - this.priorInput = priorNode.priorInput; - this.toKey = priorNode.toKey; - } else { - this.calculation = new ClassicalDistanceCalculation(); - this.currentTraversal = rootTraversal; - this.priorInput = []; - this.toKey = toKey; - } - } - - get knownCost(): number { - return this.calculation.getHeuristicFinalCost(); - } - - get inputSamplingCost(): number { - if(this._inputCost !== undefined) { - return this._inputCost; - } else { - let MIN_P = SearchSpace.MIN_KEYSTROKE_PROBABILITY; - // Should technically re-normalize the sampling distribution. - // -ln(p) is smaller for larger probabilities, as ln(p) is always <= 0. Approaches infinity as p => 0. - - // TODO: probably more efficient to instead use actual 'probability space'... but that'll involve extra changes. - this._inputCost = this.priorInput.map(mass => mass.p > MIN_P ? mass.p : MIN_P).reduce((previous, current) => previous - Math.log(current), 0); - return this._inputCost; - } - } - - // The part used to prioritize our search. - get currentCost(): number { - // - We reintrepret 'known cost' as a psuedo-probability. - // - Noting that 1/e = 0.367879441, an edit-distance cost of 1 may be intepreted as -ln(1/e) - a log-space 'likelihood'. - // - Not exactly normalized, though. - // That's a really, really high likelihood, thoough. - // - // At any rate, we can linearly scale the known-cost to have about whatever probability we want. - // If we can state it as p = 1 / (c * e), note then that ln(c * e) = ln(c) + 1. So, scale * (ln(c) + 1). - // If we can state it as e^x, note that ln(e^x) = x * ln(e) = x - just scale by 'x'! - - // p = 1 / (e^4) = 0.01831563888. This still exceeds many neighboring keys! - // p = 1 / (e^5) = 0.00673794699. Strikes a good balance. - // Should easily give priority to neighboring keys before edit-distance kicks in (when keys are a bit ambiguous) - return SearchSpace.EDIT_DISTANCE_COST_SCALE * this.knownCost + this.inputSamplingCost; - } - - buildInsertionEdges(): SearchNode[] { - let edges: SearchNode[] = []; - - for(let lexicalChild of this.currentTraversal.children()) { - let traversal = lexicalChild.traversal(); - let matchToken = { - key: lexicalChild.char, - traversal: traversal - } - - let childCalc = this.calculation.addMatchChar(matchToken); - - let searchChild = new SearchNode(this); - searchChild.calculation = childCalc; - searchChild.priorInput = this.priorInput; - searchChild.currentTraversal = traversal; - - edges.push(searchChild); - } - - return edges; - } - - buildDeletionEdges(inputDistribution: Distribution): SearchNode[] { - let edges: SearchNode[] = []; - - /* - * If the probability of an input is less than the highest probability * the base edit-distance likelihood, - * don't build an edge for it; just rely on edits from the highest-probability edge. - * - * We may be able to be stricter, but this should be a decent start. - * - * Note: thanks to ModelCompositor.predict, we know the distribution is pre-sorted. - */ - for(let probMass of inputDistribution) { - if(probMass.p < inputDistribution[0].p * Math.exp(-SearchSpace.EDIT_DISTANCE_COST_SCALE)) { - // Again, we're pre-sorted. All further entries will be too low-cost to consider. - break; - } - - let edgeCalc = this.calculation; - let transform = probMass.sample; - if(transform.deleteLeft) { - edgeCalc = edgeCalc.getSubset(edgeCalc.inputSequence.length - transform.deleteLeft, edgeCalc.matchSequence.length); - } - - // TODO: transform.deleteRight currently not supported. - - let inputPath = Array.from(this.priorInput); - inputPath.push(probMass); - // Tokenize and iterate over input chars, adding them into the calc. - for(let i=0; i < transform.insert.length; i++) { - let char = transform.insert[i]; - if(models.isHighSurrogate(char)) { - i++; - char = char + transform.insert[i]; - } - - // In case of NFD input, filter out any empty-strings that may arise - // when 'keying' raw diacritics. - let keyedChar = this.toKey(char); - if(keyedChar) { - edgeCalc = edgeCalc.addInputChar({key: keyedChar}); - } - } - - let childEdge = new SearchNode(this); - childEdge.calculation = edgeCalc; - childEdge.priorInput = inputPath; - - edges.push(childEdge); - } - - return edges; - } - - // While this may SEEM to be unnecessary, note that sometimes substitutions (which are computed - // via insert + delete) may be lower cost than both just-insert and just-delete. - buildSubstitutionEdges(inputDistribution: Distribution): SearchNode[] { - // Handles the 'input' component. - let intermediateEdges = this.buildDeletionEdges(inputDistribution); - let edges: SearchNode[] = []; - - for(let lexicalChild of this.currentTraversal.children()) { - for(let edge of intermediateEdges) { - let traversal = lexicalChild.traversal(); - let matchToken = { - key: lexicalChild.char, - traversal: traversal - } - - let childCalc = edge.calculation.addMatchChar(matchToken); - - let searchChild = new SearchNode(this); - searchChild.calculation = childCalc; - searchChild.priorInput = edge.priorInput; - searchChild.currentTraversal = traversal; - - edges.push(searchChild); - } - } - - return edges; - } - - get mapKey(): string { - let inputString = this.priorInput.map((value) => '+' + value.sample.insert + '-' + value.sample.deleteLeft).join(''); - let matchString = this.calculation.matchSequence.map((value) => value.key).join(''); - - // TODO: might should also track diagonalWidth. - return inputString + models.SENTINEL_CODE_UNIT + matchString; - } - - get isFullReplacement(): boolean { - // If the known edit-distance cost is equal to the input length, this means - // that literally every input has been full-on replaced. Thus, this is - // likely not a good 'root' to use for predictions. - // - // Logic exception: 0 cost, 0 length != a "replacement". - return this.knownCost && this.knownCost == this.priorInput.length; - } - } - - class SearchSpaceTier { - correctionQueue: models.PriorityQueue; - processed: SearchNode[] = []; - index: number; - - constructor(index: number, initialEdges?: SearchNode[]) { - this.index = index; - this.correctionQueue = new models.PriorityQueue(QUEUE_NODE_COMPARATOR, initialEdges); - } - - increaseMaxEditDistance() { - // By extracting the entries from the priority queue and increasing distance outside of it as a batch job, - // we get an O(N) implementation, rather than the O(N log N) that would result from maintaining the original queue. - let entries = this.correctionQueue.toArray(); - - entries.forEach(function(edge) { edge.calculation = edge.calculation.increaseMaxDistance(); }); - - // Since we just modified the stored instances, and the costs may have shifted, we need to re-heapify. - this.correctionQueue = new models.PriorityQueue(QUEUE_NODE_COMPARATOR, entries); - } - } - - export class SearchResult { - private resultNode: SearchNode; - - constructor(node: SearchNode) { - this.resultNode = node; - } - - get inputSequence(): ProbabilityMass[] { - return this.resultNode.priorInput; - } - - get matchSequence(): TraversableToken[] { - return this.resultNode.calculation.matchSequence; - }; - - get matchString(): USVString { - return this.matchSequence.map(value => value.key).join(''); - } - - get knownCost(): number { - return this.resultNode.knownCost; - } - - get inputSamplingCost(): number { - return this.resultNode.inputSamplingCost; - } - - get totalCost(): number { - return this.resultNode.currentCost; - } - - get finalTraversal(): LexiconTraversal { - return this.resultNode.currentTraversal; - } - } - - type NullPath = { - type: 'none' - } - - type IntermediateSearchPath = { - type: 'intermediate', - cost: number - } - - type CompleteSearchPath = { - type: 'complete', - cost: number, - finalNode: SearchNode - } - - type PathResult = NullPath | IntermediateSearchPath | CompleteSearchPath; - - // The set of search spaces corresponding to the same 'context' for search. - // Whenever a wordbreak boundary is crossed, a new instance should be made. - export class SearchSpace { - private QUEUE_SPACE_COMPARATOR: models.Comparator; - - // p = 1 / (e^4) = 0.01831563888. This still exceeds many neighboring keys! - // p = 1 / (e^5) = 0.00673794699. Strikes a good balance. - // Should easily give priority to neighboring keys before edit-distance kicks in (when keys are a bit ambiguous) - static readonly EDIT_DISTANCE_COST_SCALE = 5; - static readonly MIN_KEYSTROKE_PROBABILITY = 0.0001; - static readonly DEFAULT_ALLOTTED_CORRECTION_TIME_INTERVAL = 33; // in milliseconds. - - private tierOrdering: SearchSpaceTier[] = []; - private selectionQueue: models.PriorityQueue; - private inputSequence: Distribution[] = []; - private minInputCost: number[] = []; - private rootNode: SearchNode; - - // We use an array and not a PriorityQueue b/c batch-heapifying at a single point in time - // is cheaper than iteratively building a priority queue. - private completedPaths: SearchNode[]; - - // Marks all results that have already been returned since the last input was received. - private returnedValues: {[mapKey: string]: SearchNode} = {}; - - // Signals that the edge has already been processed. - private processedEdgeSet: {[mapKey: string]: boolean} = {}; - - constructor(model: LexicalModel) { - if(!model) { - throw "The LexicalModel parameter must not be null / undefined."; - } else if(!model.traverseFromRoot) { - throw "The provided model does not implement the `traverseFromRoot` function, which is needed to support robust correction searching."; - } - - // Constructs the comparator needed for the following line. - this.buildQueueSpaceComparator(); - this.selectionQueue = new models.PriorityQueue(this.QUEUE_SPACE_COMPARATOR); - this.rootNode = new SearchNode(model.traverseFromRoot(), model.toKey ? model.toKey.bind(model) : null); - - this.completedPaths = [this.rootNode]; - - // Adds a base level queue to handle initial insertions. - // Start with _just_ the root node. Necessary for proper empty-token, empty-input handling! - let baseTier = new SearchSpaceTier(0, [this.rootNode]); - this.tierOrdering.push(baseTier); - this.selectionQueue.enqueue(baseTier); - } - - private buildQueueSpaceComparator() { - let searchSpace = this; - - this.QUEUE_SPACE_COMPARATOR = function(space1, space2) { - let node1 = space1.correctionQueue.peek(); - let node2 = space2.correctionQueue.peek(); - - let index1 = space1.index; - let index2 = space2.index; - - let tierMinCost: number = 0; - let sign = 1; - - if(index2 < index1) { - let temp = index2; - index2 = index1; - index1 = temp; - - sign = -1; - } - - // Boost the cost of the lower tier by the minimum cost possible for the missing inputs between them. - // In essence, compare the nodes as if the lower tier had the most likely input appended for each such - // input missing at the lower tier. - // - // A 100% admissible heuristic to favor a deeper search, since the added cost is guaranteed if the path - // is traversed further. - // - // Remember, tier index i's last used input was from input index i-1. - // As a result, i is the first needed input index, with index2 - 1 the last entry needed to match them. - for(let i=index1; i < index2; i++) { - tierMinCost = tierMinCost + searchSpace.minInputCost[i]; - } - - // Guards, just in case one of the search spaces ever has an empty node. - if(node1 && node2) { - // If node1 is lower-tier, node1 is the one in need of boosted cost. - // `sign` flips it when node2 is lower tier. - return node1.currentCost - node2.currentCost + sign * tierMinCost; - } else if(node2) { - return 1; - } else { - return -1; - } - } - } - - increaseMaxEditDistance() { - this.tierOrdering.forEach(function(tier) { tier.increaseMaxEditDistance() }); - } - - addInput(inputDistribution: Distribution) { - this.inputSequence.push(inputDistribution); - - // Assumes that `inputDistribution` is already sorted. - this.minInputCost.push(-Math.log(inputDistribution[0].p)); - - // With a newly-available input, we can extend new input-dependent paths from - // our previously-reached 'extractedResults' nodes. - let newlyAvailableEdges: SearchNode[] = []; - let batches = this.completedPaths.map(function(node) { - let deletions = node.buildDeletionEdges(inputDistribution); - let substitutions = node.buildSubstitutionEdges(inputDistribution); - - return deletions.concat(substitutions); - }); - - // Don't forget to reset the array; the contained nodes no longer reach the search's end. - this.completedPaths = []; - this.returnedValues = {}; - - batches.forEach(function(batch) { - newlyAvailableEdges = newlyAvailableEdges.concat(batch); - }); - - // Now that we've built the new edges, we can efficiently construct the new search tier. - let tier = new SearchSpaceTier(this.tierOrdering.length, newlyAvailableEdges); - this.tierOrdering.push(tier); - this.selectionQueue.enqueue(tier); - } - - // TODO: will want eventually for reversions and/or backspaces - removeLastInput() { - // 1. truncate all entries from that search tier; we need to 'restore' extractedResults to match - // the state that would have existed without the last search tier. - // 2. remove the last search tier. Which may necessitate reconstructing the tier queue, but oh well. - } - - private hasNextMatchEntry(): boolean { - let topQueue = this.selectionQueue.peek(); - if(topQueue) { - return topQueue.correctionQueue.count > 0; - } else { - return false; - } - } - - private handleNextNode(): PathResult { - if(!this.hasNextMatchEntry()) { - return { type: 'none' }; - } - - let bestTier = this.selectionQueue.dequeue(); - let currentNode = bestTier.correctionQueue.dequeue(); - - let unmatchedResult: IntermediateSearchPath = { - type: 'intermediate', - cost: currentNode.currentCost - } - - // Have we already processed a matching edge? If so, skip it. - // We already know the previous edge is of lower cost. - if(this.processedEdgeSet[currentNode.mapKey]) { - this.selectionQueue.enqueue(bestTier); - return unmatchedResult; - } else { - this.processedEdgeSet[currentNode.mapKey] = true; - } - - // Stage 1: filter out nodes/edges we want to prune - - // Forbid a raw edit-distance of greater than 2. - // Note: .knownCost is not scaled, while its contribution to .currentCost _is_ scaled. - let substitutionsOnly = false; - if(currentNode.knownCost > 2) { - return unmatchedResult; - } else if(currentNode.knownCost == 2) { - // Hard restriction: no further edits will be supported. This helps keep the search - // more narrowly focused. - substitutionsOnly = true; - } - - let tierMinCost = 0; - for(let i = 0; i <= bestTier.index; i++) { - tierMinCost += this.minInputCost[i]; - } - - // Thresholds _any_ path, partially based on currently-traversed distance. - // Allows a little 'wiggle room' + 2 "hard" edits. - // Can be important if needed characters don't actually exist on the keyboard - // ... or even just not the then-current layer of the keyboard. - if(currentNode.currentCost > tierMinCost + 2.5 * SearchSpace.EDIT_DISTANCE_COST_SCALE) { - return unmatchedResult; - } - - // Stage 2: build remaining edges - - // Always possible, as this does not require any new input. - if(!substitutionsOnly) { - let insertionEdges = currentNode.buildInsertionEdges(); - bestTier.correctionQueue.enqueueAll(insertionEdges); - } - - if(bestTier.index == this.tierOrdering.length - 1) { - // It was the final tier - store the node for future reference. - this.completedPaths.push(currentNode); - - // Since we don't modify any other tier, we may simply reinsert the removed tier. - this.selectionQueue.enqueue(bestTier); - - return { - type: 'complete', - cost: currentNode.currentCost, - finalNode: currentNode - }; - } else { - // Time to construct new edges for the next tier! - let nextTier = this.tierOrdering[bestTier.index+1]; - - let inputIndex = nextTier.index; - - let deletionEdges = []; - if(!substitutionsOnly) { - deletionEdges = currentNode.buildDeletionEdges(this.inputSequence[inputIndex-1]); - } - let substitutionEdges = currentNode.buildSubstitutionEdges(this.inputSequence[inputIndex-1]); - - // Note: we're live-modifying the tier's cost here! The priority queue loses its guarantees as a result. - nextTier.correctionQueue.enqueueAll(deletionEdges.concat(substitutionEdges)); - - // So, we simply rebuild the selection queue. - this.selectionQueue = new models.PriorityQueue(this.QUEUE_SPACE_COMPARATOR, this.tierOrdering); - - // We didn't reach an end-node, so we just end the iteration and continue the search. - } - - // If we've somehow fully exhausted all search options, indicate that none remain. - return unmatchedResult; - } - - // Current best guesstimate of how compositor will retrieve ideal corrections. - *getBestMatches(waitMillis?: number): Generator { - // might should also include a 'base cost' parameter of sorts? - let searchSpace = this; - let currentReturns: {[mapKey: string]: SearchNode} = {}; - - let maxTime: number; - if(waitMillis == 0) { - maxTime = Infinity; - } else if(waitMillis == undefined || Number.isNaN(waitMillis)) { // also covers null. - maxTime = SearchSpace.DEFAULT_ALLOTTED_CORRECTION_TIME_INTERVAL; - } else { - maxTime = waitMillis; - } - - /** - * This inner class is designed to help the algorithm detect its active execution time. - * While there's no official JS way to do this, we can approximate it by polling the - * current system time (in ms) after each iteration of a short-duration loop. Unusual - * spikes in system time for a single iteration is likely to indicate that an OS - * context switch occurred at some point during the iteration's execution. - */ - class ExecutionTimer { - /** - * The system time when this instance was created. - */ - private start: number; - - /** - * Marks the system time at the start of the currently-running loop, as noted - * by a call to the `startLoop` function. - */ - private loopStart: number; - - private maxExecutionTime: number; - private maxTrueTime: number; - - private executionTime: number; - - /** - * Used to track intervals in which potential context swaps by the OS may - * have occurred. Context switches generally seem to pause threads for - * at least 16 ms, while we expect each loop iteration to complete - * within just 1 ms. So, any possible context switch should have the - * longest observed change in system time. - * - * See `updateOutliers` for more details. - */ - private largestIntervals: number[] = [0]; - - constructor(maxExecutionTime: number, maxTrueTime: number) { - // JS measures time by the number of milliseconds since Jan 1, 1970. - this.loopStart = this.start = Date.now(); - this.maxExecutionTime = maxExecutionTime; - this.maxTrueTime = maxTrueTime; - } - - startLoop() { - this.loopStart = Date.now(); - } - - markIteration() { - const now = Date.now(); - const delta = now - this.loopStart; - this.executionTime += delta; - - /** - * Update the list of the three longest system-time intervals observed - * for execution of a single loop iteration. - * - * Ignore any zero-ms length intervals; they'd make the logic much - * messier than necessary otherwise. - */ - if(delta) { - // If the currently-observed interval is longer than the shortest of the 3 - // previously-observed longest intervals, replace it. - if(this.largestIntervals.length > 2 && delta > this.largestIntervals[0]) { - this.largestIntervals[0] = delta; - } else { - this.largestIntervals.push(delta); - } - // Puts the list in ascending order. Shortest of the list becomes the head, - // longest one the tail. - this.largestIntervals.sort(); - - // Then, determine if we need to update our outlier-based tweaks. - this.updateOutliers(); - } - } - - updateOutliers() { - /* Base assumption: since each loop of the search should evaluate within ~1ms, - * notably longer execution times are probably context switches. - * - * Base assumption: OS context switches generally last at least 16ms. (Based on - * a window.setTimeout() usually not evaluating for at least - * that long, even if set to 1ms.) - * - * To mitigate these assumptions: we'll track the execution time of every loop - * iteration. If the longest observation somehow matches or exceeds the length of - * the next two almost-longest observations twice over... we have a very strong - * 'context switch' candidate. - * - * Or, in near-formal math/stats: we expect a very low variance in execution - * time among the iterations of the search's loops. With a very low variance, - * ANY significant proportional spikes in execution time are outliers - outliers - * likely caused by an OS context switch. - * - * Rather than do intensive math, we use a somewhat lazy approach below that - * achieves the same net results given our assumptions, even when relaxed somewhat. - * - * The logic below relaxes the base assumptions a bit to be safe: - * - [2ms, 2ms, 8ms] will cause 8ms to be seen as an outlier. - * - [2ms, 3ms, 10ms] will cause 10ms to be seen as an outlier. - * - * Ideally: - * - [1ms, 1ms, 4ms] will view 4ms as an outlier. - * - * So we can safely handle slightly longer average intervals and slightly shorter - * OS context-switch time intervals. - */ - if(this.largestIntervals.length > 2) { - // Precondition: the `largestIntervals` array is sorted in ascending order. - // Shortest entry is at the head, longest at the tail. - if(this.largestIntervals[2] >= 2 * (this.largestIntervals[0] + this.largestIntervals[1])) { - this.executionTime -= this.largestIntervals[2]; - this.largestIntervals.pop(); - } - } - } - - shouldTimeout(): boolean { - const now = Date.now(); - if(now - this.start > this.maxTrueTime) { - return true; - } - - return this.executionTime > this.maxExecutionTime; - } - - resetOutlierCheck() { - this.largestIntervals = []; - } - } - - class BatchingAssistant { - currentCost = Number.MIN_SAFE_INTEGER; - entries: SearchResult[] = []; - - checkAndAdd(entry: SearchNode): SearchResult[] | null { - var result: SearchResult[] = null; - - if(entry.currentCost > this.currentCost) { - result = this.tryFinalize(); - - this.currentCost = entry.currentCost; - } - - // Filter out any duplicated match sequences. The same match sequence may be reached via - // different input sequences, after all. - let outputMapKey = entry.calculation.matchSequence.map(value => value.key).join(''); - - // First, ensure the edge has an existing 'shared' cache entry. - if(!searchSpace.returnedValues[outputMapKey]) { - searchSpace.returnedValues[outputMapKey] = entry; - } - - // Check the generator's local returned-value cache - this determines whether or not we - // need to add a new 'return' to the batch. - if(!currentReturns[outputMapKey]) { - this.entries.push(new SearchResult(entry)); - currentReturns[outputMapKey] = entry; - } - - return result; - } - - tryFinalize(): SearchResult[] | null { - var result: SearchResult[] = null; - if(this.entries.length > 0) { - result = this.entries; - this.entries = []; - } - - return result; - } - } - - let batcher = new BatchingAssistant(); - - const timer = new ExecutionTimer(maxTime*1.5, maxTime); - - // Stage 1 - if we already have extracted results, build a queue just for them and iterate over it first. - let returnedValues = Object.values(this.returnedValues); - if(returnedValues.length > 0) { - let preprocessedQueue = new models.PriorityQueue(QUEUE_NODE_COMPARATOR, returnedValues); - - // Build batches of same-cost entries. - timer.startLoop(); - while(preprocessedQueue.count > 0) { - let entry = preprocessedQueue.dequeue(); - - // Is the entry a reasonable result? - if(entry.isFullReplacement) { - // If the entry's 'match' fully replaces the input string, we consider it - // unreasonable and ignore it. - continue; - } - - let batch = batcher.checkAndAdd(entry); - timer.markIteration(); - - if(batch) { - // Do not track yielded time. - yield batch; - } - } - - // As we only return a batch once all entries of the same cost have been processed, we can safely - // finalize the last preprocessed group without issue. - let batch = batcher.tryFinalize(); - if(batch) { - // Do not track yielded time. - yield batch; - } - } - - // Stage 2: the fun part; actually searching! - timer.resetOutlierCheck(); - timer.startLoop(); - let timedOut = false; - do { - let newResult: PathResult; - - // Search for a 'complete' path, skipping all partial paths as long as time remains. - do { - newResult = this.handleNextNode(); - timer.markIteration(); - - if(timer.shouldTimeout()) { - timedOut = true; - } - } while(!timedOut && newResult.type == 'intermediate') - - // TODO: check 'cost' on intermediate, running it through batcher to early-detect cost changes. - let batch: SearchResult[]; - if(newResult.type == 'none') { - break; - } else if(newResult.type == 'complete') { - // Is the entry a reasonable result? - if(newResult.finalNode.isFullReplacement) { - // If the entry's 'match' fully replaces the input string, we consider it - // unreasonable and ignore it. Also, if we've reached this point... - // we can(?) assume that everything thereafter is as well. - break; - } - batch = batcher.checkAndAdd(newResult.finalNode); - } - - if(batch) { - yield batch; - } - } while(!timedOut && this.hasNextMatchEntry()); - - // If we _somehow_ exhaust all search options, make sure to return the final results. - let batch = batcher.tryFinalize(); - if(batch) { - yield batch; - } - - return null; - } - } -} diff --git a/common/web/lm-worker/src/main/correction/classical-calculation.ts b/common/web/lm-worker/src/main/correction/classical-calculation.ts new file mode 100644 index 0000000000..50ddf93551 --- /dev/null +++ b/common/web/lm-worker/src/main/correction/classical-calculation.ts @@ -0,0 +1,572 @@ +import { SENTINEL_CODE_UNIT } from '@keymanapp/models-templates'; + +type EditOperation = 'insert' | 'delete' | 'match' | 'substitute' | 'transpose-start' | 'transpose-end' | 'transpose-insert' | 'transpose-delete'; + +/** + * Represents the lowest-level unit for comparison during edit-distance calculations. + */ +export interface EditToken { + key: TUnit; +} + +// A semi-optimized 'online'/iterative Damerau-Levenshtein calculator with the following features: +// - may add new character to the 'input' string or to the 'match' string, reusing all old calculations efficiently. +// - allows a 'focused' evaluation that seeks if the edit distance is within a specific range. Designed for use in match-searching, +// where we want to find the 'closest' matching strings in a lexicon. +// - towards such a match-searching algorithm/heuristic: should nothing be found within that range, all prior calculations may be reused +// to search across the lexicon with an incremented edit distance. +// - minimized memory footprint: O(m) memory footprint (where m = length of 'input' string), rather than O(mn) (where n = length of 'match' string) +// - guaranteed to use a smaller footprint than DiagonalizedIterativeDamerauLevenshteinCalculation. +// +// In short: Used to optimize calculations for low edit-distance checks, then expanded if/as necessary +// if a greater edit distance is requested. +// +// Reference: https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm#Possible_modifications +// - Motivating statement: "if we are only interested in the distance if it is smaller than a threshold..." +export class ClassicalDistanceCalculation = EditToken, TMatch extends EditToken = EditToken> { + /** + * Stores ONLY the computed diagonal elements, nothing else. + * + * Mapped as seen in the example below (with a diagonal of width 1): + * ``` + * MAX | MAX | MAX | MAX | MAX | ... + * MAX | 0 | 1 | 2 | 3 | ... > + * MAX | 1 | a | b | - | ... ====>> | - | a | b | + * MAX | 2 | c | d | e | ... > | c | d | e | + * MAX | 3 | - | f | g | ... | f | g | ... | + * ... | ... | ... | ... | ... | ... | ... | ... | ... | + * ``` + * + * Any "`-`" entries are undefined, as they lie outside of the diagonal under consideration. + * + * Things of note: + * - The entry where row index = col index will always lie at the center of the row's array. + * - For each +1 increase in row index, the row's entries are (logically) shifted by -1 in order to make this happen. + * - As all of the MAX entries and numerical entries above are fixed, known values, they are not represented here. + */ + resolvedDistances: number[][]; + /** + * Specifies how far off-diagonal calculations should be performed. A value of 0 only evaluates cells with matching + * row and column indicies. + * + * The resulting value from .getFinalCost() is only guaranteed correct if it is less than or equal to this value. + * Otherwise, this object represents a heuristic that _may_ overestimate the true edit distance. Note that it will + * never underestimate. + */ + diagonalWidth: number = 2; // TODO: Ideally, should start at 1... but we'll start at 2 for now + // as a naive workaround for multi-char transform limitations. + + // The sequence of characters input so far. + inputSequence: TInput[] = []; + matchSequence: TMatch[] = []; + + constructor(); + constructor(other: ClassicalDistanceCalculation); + constructor(other?: ClassicalDistanceCalculation) { + if(other) { + // Clone class properties. + let rowCount = other.resolvedDistances.length; + this.resolvedDistances = Array(rowCount); + + for(let r = 0; r < rowCount; r++) { + this.resolvedDistances[r] = Array.from(other.resolvedDistances[r]); + } + + this.inputSequence = Array.from(other.inputSequence); + this.matchSequence = Array.from(other.matchSequence); + this.diagonalWidth = other.diagonalWidth; + } else { + this.resolvedDistances = []; + } + } + + private getTrueIndex(r: number, c: number, width: number): {row: number, col: number, sparse: boolean} { + let retVal = { + row: r, + col: c - r + width, + sparse: false + } + + if(retVal.col < 0 || retVal.col > 2 * width) { + retVal.sparse = true; + } + + return retVal; + } + + private getCostAt(i: number, j: number, width: number = this.diagonalWidth): number { + // Check for and handle the set of fixed-value virtualized indices. + if(i < 0 || j < 0) { + if(i == -1 && j >= -1) { + return j+1; + } else if(j == -1 && i >= -1) { + return i+1; + } + + return Number.MAX_VALUE; + } + + let index = this.getTrueIndex(i, j, width); + return index.sparse ? Number.MAX_VALUE : this.resolvedDistances[index.row][index.col]; + } + + /** + * Noting the above link's statement prefixed "By examining diagonals instead of rows, and by using lazy evaluation...", + * this function will return the actual edit distance between the strings, temporarily increasing the computed + * diagonal's size if necessary. + * + * Does not actually mutate the instance. + */ + getFinalCost(): number { + let buffer = this as ClassicalDistanceCalculation; + let val = buffer.getHeuristicFinalCost(); + + while(val > buffer.diagonalWidth) { + // A consequence of treating this class as immutable. + buffer = buffer.increaseMaxDistance(); + val = buffer.getHeuristicFinalCost(); + } + + return val; + } + + /** + * Returns this instance's computed edit distance. If greater than the diagonal's width value, note that it may be an overestimate. + */ + getHeuristicFinalCost(): number { + return this.getCostAt(this.inputSequence.length-1, this.matchSequence.length-1); + } + + /** + * Returns `true` if the represented edit distance is less than or equal to the specified threshold, minimizing the amount of calculations + * needed to meet the specified limit. + * + * Does not mutate the instance. + * @param threshold + */ + hasFinalCostWithin(threshold: number): boolean { + let buffer = this as ClassicalDistanceCalculation; + let val = buffer.getHeuristicFinalCost(); + let guaranteedBound = this.diagonalWidth; + + do { + // val will never exceed the length of the longer string, no matter how large the threshold. + if(val <= threshold) { + return true; + } else if(guaranteedBound < threshold) { + buffer = buffer.increaseMaxDistance(); + guaranteedBound++; + val = buffer.getHeuristicFinalCost(); + } else { + break; + } + } while(true); + + return false; + } + + /** + * Determines the edit path used to obtain the optimal cost, distinguishing between zero-cost + * substitutions ('match' operations) and actual substitutions. + * @param row + * @param col + */ + public editPath(row: number = this.inputSequence.length - 1, col: number = this.matchSequence.length - 1): EditOperation[] { + let currentCost = this.getCostAt(row, col); + let ops: EditOperation[] = null; + let parent: [number, number] = null; + + let insertParentCost = this.getCostAt(row, col-1); + let deleteParentCost = this.getCostAt(row-1, col); + let substitutionParentCost = this.getCostAt(row-1, col-1); + let [lastInputIndex, lastMatchIndex] = ClassicalDistanceCalculation.getTransposeParent(this, row, col); + if(lastInputIndex >= 0 && lastMatchIndex >= 0) { + // OK, a transposition source is quite possible. Still need to do more vetting, to be sure. + let expectedCost = 1; + + // This transposition includes either 'transpose-insert' or 'transpose-delete' operations. + ops = ['transpose-start']; // always needs a 'start'. + if(lastInputIndex != row-1) { + let count = row - lastInputIndex - 1; + ops = ops.concat( Array(count).fill('transpose-delete') ); + expectedCost += count; + } else { + let count = col - lastMatchIndex - 1; + ops = ops.concat( Array(count).fill('transpose-insert') ); + expectedCost += count; + } + ops.push('transpose-end'); + + // Double-check our expectations. + if(this.getCostAt(lastInputIndex-1, lastMatchIndex-1) != currentCost - expectedCost) { + ops = null; + } + parent = [lastInputIndex-1, lastMatchIndex-1]; + } + + if(ops) { + // bypass the ladder. + } else if(substitutionParentCost == currentCost - 1) { + ops = ['substitute']; + parent = [row-1, col-1]; + } else if(insertParentCost == currentCost - 1) { + ops = ['insert']; + parent = [row, col-1]; + } else if(deleteParentCost == currentCost - 1) { + ops = ['delete']; + parent = [row-1, col]; + } else { //if(substitutionParentCost == currentCost) { + ops = ['match']; + parent = [row-1, col-1]; + } + + // Recursively build the edit path. + if(parent[0] >= 0 && parent[1] >= 0) { + return this.editPath(parent[0], parent[1]).concat(ops); + } else { + if(parent[0] > -1) { + // There are initial deletions. + return Array(parent[0]+1).fill('delete').concat(ops); + } else if(parent[1] > -1) { + // There are initial insertions. + return Array(parent[1]+1).fill('insert').concat(ops); + } else { + return ops; + } + } + } + + private static getTransposeParent, TMatch extends EditToken>( + buffer: ClassicalDistanceCalculation, + r: number, + c: number + ): [number, number] { + // Block any transpositions where the tokens are identical. + // Other operations will be cheaper. Also, block cases where 'parents' are impossible. + if(r < 0 || c < 0 || buffer.inputSequence[r].key == buffer.matchSequence[c].key) { + return [-1, -1]; + } + + // Transposition checks + let lastInputIndex = -1; + for(let i = r-1; i >= 0; i--) { + if(buffer.inputSequence[i].key == buffer.matchSequence[c].key) { + lastInputIndex = i; + break; + } + } + + let lastMatchIndex = -1; + for(let i = c-1; i >= 0; i--) { + if(buffer.matchSequence[i].key == buffer.inputSequence[r].key) { + lastMatchIndex = i; + break; + } + } + + return [lastInputIndex, lastMatchIndex]; + } + + private static initialCostAt, TMatch extends EditToken>( + buffer: ClassicalDistanceCalculation, + r: number, + c: number, + insertCost?: number, + deleteCost?: number) { + var baseSubstitutionCost = buffer.inputSequence[r].key == buffer.matchSequence[c].key ? 0 : 1; + var substitutionCost: number = buffer.getCostAt(r-1, c-1) + baseSubstitutionCost; + var insertionCost: number = insertCost || buffer.getCostAt(r, c-1) + 1; // If set meaningfully, will never equal zero. + var deletionCost: number = deleteCost || buffer.getCostAt(r-1, c) + 1; // If set meaningfully, will never equal zero. + var transpositionCost: number = Number.MAX_VALUE + + if(r > 0 && c > 0) { // bypass when transpositions are known to be impossible. + let [lastInputIndex, lastMatchIndex] = ClassicalDistanceCalculation.getTransposeParent(buffer, r, c); + transpositionCost = buffer.getCostAt(lastInputIndex-1, lastMatchIndex-1) + (r - lastInputIndex - 1) + 1 + (c - lastMatchIndex - 1); + } + + return Math.min(substitutionCost, deletionCost, insertionCost, transpositionCost); + } + + getSubset(inputLength: number, matchLength: number): ClassicalDistanceCalculation { + let trimmedInstance = new ClassicalDistanceCalculation(this); + + if(inputLength > this.inputSequence.length || matchLength > this.matchSequence.length) { + throw "Invalid dimensions specified for trim operation"; + } + // Trim our tracked input & match sequences. + trimmedInstance.inputSequence.splice(inputLength); + trimmedInstance.matchSequence.splice(matchLength); + + // Major index corresponds to input length. + trimmedInstance.resolvedDistances.splice(inputLength); + + // The real fun: trimming off columns. (Minor index, corresponds to match length) + let finalTrueIndex = this.getTrueIndex(inputLength-1, matchLength-1, this.diagonalWidth); + // The diagonal index increases as the row index decreases. + for(let diagonalIndex = finalTrueIndex.col; diagonalIndex <= 2 * this.diagonalWidth; diagonalIndex++) { + let row = finalTrueIndex.row - (diagonalIndex - finalTrueIndex.col); + if(row < 0) { + break; + } + + if(diagonalIndex < 0) { + trimmedInstance.resolvedDistances[row] = Array(2 * trimmedInstance.diagonalWidth + 1).fill(Number.MAX_VALUE); + } else { + let newCount = 2 * this.diagonalWidth - diagonalIndex; + let keptEntries = trimmedInstance.resolvedDistances[row].splice(0, diagonalIndex+1); + let newEntries = Array(newCount).fill(Number.MAX_VALUE); + trimmedInstance.resolvedDistances[row] = keptEntries.concat(newEntries); + } + } + + return trimmedInstance; + } + + private static forDiagonalOfAxis(diagonalWidth: number, centerIndex: number, axisCap: number, closure: (axisIndex: number, diagIndex: number) => void) { + let diagonalCap = axisCap - centerIndex < diagonalWidth ? axisCap - centerIndex + diagonalWidth : 2 * diagonalWidth; + let startOffset = centerIndex - diagonalWidth; // The axis's index for diagonal entry 0. May be negative. + let diagonalStart = startOffset < 0 ? 0 : startOffset; + + for(let diagonalIndex = diagonalStart - startOffset; diagonalIndex <= diagonalCap; diagonalIndex++) { + closure(startOffset + diagonalIndex, diagonalIndex); + } + } + + // Inputs add an extra row / first index entry. + addInputChar(token: TInput): ClassicalDistanceCalculation { + let returnBuffer = new ClassicalDistanceCalculation(this); + + let r = returnBuffer.inputSequence.length; + returnBuffer.inputSequence.push(token); + + // Insert a row, even if we don't actually do anything with it yet. + // Initialize all entries with Number.MAX_VALUE, as `undefined` use leads to JS math issues. + let row = Array(2 * returnBuffer.diagonalWidth + 1).fill(Number.MAX_VALUE); + returnBuffer.resolvedDistances[r] = row; + + // If there isn't a 'match' entry yet, there are no values to compute. Exit immediately. + if(returnBuffer.matchSequence.length == 0) { + return returnBuffer; + } + + ClassicalDistanceCalculation.forDiagonalOfAxis(returnBuffer.diagonalWidth, r, returnBuffer.matchSequence.length - 1, function(c, diagIndex) { + row[diagIndex] = ClassicalDistanceCalculation.initialCostAt(returnBuffer, r, c); + }); + + return returnBuffer; + } + + addMatchChar(token: TMatch): ClassicalDistanceCalculation { + let returnBuffer = new ClassicalDistanceCalculation(this); + + let c = returnBuffer.matchSequence.length; + returnBuffer.matchSequence.push(token); + + // If there isn't a 'match' entry yet, there are no values to compute. Exit immediately. + if(returnBuffer.inputSequence.length == 0) { + return returnBuffer; + } + + ClassicalDistanceCalculation.forDiagonalOfAxis(returnBuffer.diagonalWidth, c, returnBuffer.inputSequence.length - 1, function(r, diagIndex) { + var row = returnBuffer.resolvedDistances[r]; + // Since diagIndex is from the perspective of the row, it must be inverted to properly index the column. + row[2 * returnBuffer.diagonalWidth - diagIndex] = ClassicalDistanceCalculation.initialCostAt(returnBuffer, r, c); + }); + + return returnBuffer; + } + + public increaseMaxDistance(): ClassicalDistanceCalculation { + let returnBuffer = new ClassicalDistanceCalculation(this); + returnBuffer.diagonalWidth++; + + if(returnBuffer.inputSequence.length < 1 || returnBuffer.matchSequence.length < 1) { + return returnBuffer; + } + + // An abstraction of the common aspects of transposition handling during diagonal extensions. + function forPossibleTranspositionsInDiagonal(startPos: number, fixedChar: TUnit, lookupString: EditToken[], closure: (axisIndex: number, diagIndex: number) => void) { + let diagonalCap = 2 * (returnBuffer.diagonalWidth - 1); // The maximum diagonal index permitted + let axisCap = lookupString.length - 1; // The maximum index supported by the axis of iteration + + // Ensures that diagonal iteration only occurs within the axis's supported range + diagonalCap = diagonalCap < axisCap - startPos ? diagonalCap : axisCap - startPos; + + // Iterate within the diagonal and call our closure for any potential transpositions. + for(let diagonalIndex = 0; diagonalIndex <= diagonalCap; diagonalIndex++) { + if(fixedChar == lookupString[startPos + diagonalIndex].key) { + closure(startPos + diagonalIndex, diagonalIndex); + } + } + } + + for(let r = 0; r < returnBuffer.inputSequence.length; r++) { + let leftCell = Number.MAX_VALUE; + let c = r - returnBuffer.diagonalWidth // External index of the left-most entry, which we will now calculate. + if(c >= 0) { + // If c == 0, cell is at edge, thus a known value for insertions exists. + // Base cost: r+1, +1 for inserting. + let insertionCost = c == 0 ? r + 2 : Number.MAX_VALUE; + // compute new left cell + leftCell = ClassicalDistanceCalculation.initialCostAt(returnBuffer, r, c, insertionCost, undefined); + let addedCost = leftCell; + + // daisy-chain possible updates + + // cell (r, c+1): new insertion source + if(c < returnBuffer.matchSequence.length-1) { + // We propagate the new added cost (via insertion) to the old left-most cell, which is one to our right. + ClassicalDistanceCalculation.propagateUpdateFrom(returnBuffer, r, c+1, addedCost+1, 0); + + // Only possible if insertions are also possible AND more conditions are met. + // cells (r+2, * > c+2): new transposition source + let transposeRow = r+2; + if(r+2 < this.inputSequence.length) { // Row to check for transposes must exist. + let rowChar = returnBuffer.inputSequence[r+1].key; + // First possible match in input could be at index c + 2, which adjusts col c+2's cost. Except that entry in r+2 + // doesn't exist yet - so we start with c+3 instead. + forPossibleTranspositionsInDiagonal(c + 3, rowChar, returnBuffer.matchSequence, function(axisIndex, diagIndex) { + // Because (r+2, c+3) is root, not (r+2, c+2). Min cost of 2. + ClassicalDistanceCalculation.propagateUpdateFrom(returnBuffer, transposeRow, axisIndex, addedCost + diagIndex + 2, diagIndex); + }); + } + } + } + + let rightCell = Number.MAX_VALUE; + c = r + returnBuffer.diagonalWidth; + if(c < returnBuffer.matchSequence.length) { + // If r == 0, cell is at edge, thus a known value for insertions exists. + // Base cost: c+1, +1 for inserting. + let deletionCost = r == 0 ? c + 2 : Number.MAX_VALUE; + + // the current row wants to use adjusted diagonal width; we must specify use of the old width & its mapping instead. + var insertionCost: number = returnBuffer.getCostAt(r, c-1, this.diagonalWidth) + 1; + + // compute new right cell + rightCell = ClassicalDistanceCalculation.initialCostAt(returnBuffer, r, c, insertionCost, deletionCost); + let addedCost = rightCell; + + // daisy-chain possible updates + + // cell (r+1, c): new deletion source + if(r < returnBuffer.inputSequence.length - 1) { + // We propagate the new added cost (via deletion) to the old right-most cell, which is one to our right. + ClassicalDistanceCalculation.propagateUpdateFrom(returnBuffer, r+1, c, addedCost + 1, 2 * this.diagonalWidth); + + // Only possible if deletions are also possible AND more conditions are met. + // cells(* > r+2, c+2): new transposition source + let transposeCol = c+2; + if(c+2 < this.matchSequence.length) { // Row to check for transposes must exist. + let colChar = returnBuffer.matchSequence[r+1].key; + // First possible match in input could be at index r + 2, which adjusts row r+2's cost. Except that entry in c+2 + // doesn't exist yet - so we start with r+3 instead. + forPossibleTranspositionsInDiagonal(r+3, colChar, returnBuffer.inputSequence, function(axisIndex, diagIndex) { + let diagColIndex = 2 * (returnBuffer.diagonalWidth - 1) - diagIndex; + // Because (r+3, c+2) is root, not (r+2, c+2). Min cost of 2. + ClassicalDistanceCalculation.propagateUpdateFrom(returnBuffer, axisIndex, transposeCol, addedCost + diagIndex + 2, diagColIndex); + }); + } + } + } + + // Constructs the final expanded diagonal for the row. + returnBuffer.resolvedDistances[r] = [leftCell].concat(returnBuffer.resolvedDistances[r], rightCell); + } + + return returnBuffer; + } + + private static propagateUpdateFrom, TMatch extends EditToken>( + buffer: ClassicalDistanceCalculation, + r: number, + c: number, + value: number, + diagonalIndex: number) { + // Note: this function does not actually need the `c` parameter! + // That said, it's very useful when tracing stack traces & debugging. + if(value < buffer.resolvedDistances[r][diagonalIndex]) { + buffer.resolvedDistances[r][diagonalIndex] = value; + } else { + return + } + + let internalRow = r < buffer.inputSequence.length - 1; + let internalCol = c < buffer.matchSequence.length - 1; + + // We have to compensate for the current & following rows not having been expanded yet. + if(diagonalIndex < 2 * (buffer.diagonalWidth - 1) && internalCol) { + // We've inserted to the left of an existing calculation - check for propagation via insertion. + let updateCost = value + 1; + this.propagateUpdateFrom(buffer, r, c+1, updateCost, diagonalIndex+1); + } + + if(diagonalIndex > 0 && internalRow) { + // We've inserted above an existing calculation - check for propagation via deletion + let updateCost = value + 1 + this.propagateUpdateFrom(buffer, r+1, c, updateCost, diagonalIndex-1); + } + + // If both, check for propagation via substitution and possible transpositions + if(internalRow && internalCol) { + let updateCost = value + (buffer.inputSequence[r+1].key == buffer.matchSequence[c+1].key ? 0 : 1); + this.propagateUpdateFrom(buffer, r+1, c+1, updateCost, diagonalIndex); + + // Propagating transpositions (only possible if 'internal'.) + let nextInputIndex = -1; + for(let i = r+2; i < buffer.inputSequence.length; i++) { + if(buffer.inputSequence[i].key == buffer.matchSequence[c+1].key) { + nextInputIndex = i; + break; + } + } + + let nextMatchIndex = -1; + for(let i = c+2; i < buffer.matchSequence.length; i++) { + if(buffer.matchSequence[i].key == buffer.inputSequence[r+1].key) { + nextMatchIndex = i; + break; + } + } + + if(nextInputIndex > 0 && nextMatchIndex > 0) { + let transpositionCost = value + (nextInputIndex - r - 2) + 1 + (nextMatchIndex - c - 2); + this.propagateUpdateFrom(buffer, nextInputIndex, nextMatchIndex, transpositionCost, (buffer.diagonalWidth - 1) + nextMatchIndex - nextInputIndex); + } + } + } + + get mapKey(): string { + let inputString = this.inputSequence.map((value) => value.key).join(''); + let matchString = this.matchSequence.map((value) => value.key).join(''); + return inputString + SENTINEL_CODE_UNIT + matchString + SENTINEL_CODE_UNIT + this.diagonalWidth; + } + + get lastInputEntry(): TInput { + return this.inputSequence[this.inputSequence.length-1]; + } + + get lastMatchEntry(): TMatch { + return this.matchSequence[this.matchSequence.length-1]; + } + + static computeDistance, TMatch extends EditToken>( + input: TInput[], + match: TMatch[], + bandSize: number = 1) { + // Initialize the calculation buffer, setting the diagonal width (as appropriate) in advance. + let buffer = new ClassicalDistanceCalculation(); + bandSize = bandSize || 1; + buffer.diagonalWidth = bandSize; + + for(let i = 0; i < input.length; i++) { + buffer = buffer.addInputChar(input[i]); + } + + for(let j = 0; j < match.length; j++) { + buffer = buffer.addMatchChar(match[j]); + } + + return buffer; + } +} \ No newline at end of file diff --git a/common/web/lm-worker/src/main/correction/context-tracker.ts b/common/web/lm-worker/src/main/correction/context-tracker.ts new file mode 100644 index 0000000000..27783a4852 --- /dev/null +++ b/common/web/lm-worker/src/main/correction/context-tracker.ts @@ -0,0 +1,542 @@ +import { tokenize } from '@keymanapp/models-templates'; +import { defaultWordbreaker } from '@keymanapp/models-wordbreakers'; + +import { ClassicalDistanceCalculation } from './classical-calculation.js'; +import { SearchSpace } from './distance-modeler.js'; +import TransformUtils from '../transformUtils.js'; + +function textToCharTransforms(text: string, transformId?: number) { + let perCharTransforms: Transform[] = []; + + for(let i=0; i < text.kmwLength(); i++) { + let char = text.kmwCharAt(i); // is SMP-aware + + let transform: Transform = { + insert: char, + deleteLeft: 0, + id: transformId + }; + + perCharTransforms.push(transform); + } + + return perCharTransforms; +} + +export class TrackedContextSuggestion { + suggestion: Suggestion; + tokenWidth: number; +} + +export class TrackedContextToken { + raw: string; + replacementText: string; + + transformDistributions: Distribution[] = []; + replacements: TrackedContextSuggestion[]; + activeReplacementId: number = -1; + + get currentText(): string { + if(this.replacementText === undefined || this.replacementText === null) { + return this.raw; + } else { + return this.replacementText; + } + } + + get replacement(): TrackedContextSuggestion { + let replacementId = this.activeReplacementId; + return this.replacements.find(function(replacement) { + return replacement.suggestion.id == replacementId; + }); + } + + revert() { + delete this.activeReplacementId; + } +} + +export class TrackedContextState { + // Stores the source Context (as a debugging reference). Not currently utilized. + taggedContext: Context; + model: LexicalModel; + + tokens: TrackedContextToken[]; + /** + * How many tokens were removed from the start of the best-matching ancestor. + * Useful for restoring older states, e.g., when the user moves the caret backwards, we can recover the context at that position. + */ + indexOffset: number; + + // Tracks all search spaces starting at the current token. + // In the lm-layer's current form, this should only ever have one entry. + // Leaves 'design space' for if/when we add support for phrase-level corrections/predictions. + searchSpace: SearchSpace[] = []; + + constructor(source: TrackedContextState); + constructor(model: LexicalModel); + constructor(obj: TrackedContextState | LexicalModel) { + if(obj instanceof TrackedContextState) { + let source = obj; + // Be sure to deep-copy the tokens! Pointer-aliasing is bad here. + this.tokens = source.tokens.map(function(token) { + let copy = new TrackedContextToken(); + copy.raw = token.raw; + copy.replacements = token.replacements + copy.activeReplacementId = token.activeReplacementId; + copy.transformDistributions = token.transformDistributions; + + if(token.replacementText) { + copy.replacementText = token.replacementText; + } + + return copy; + }); + this.searchSpace = obj.searchSpace; + this.indexOffset = 0; + this.model = obj.model; + } else { + let lexicalModel = obj; + this.tokens = []; + this.indexOffset = Number.MIN_SAFE_INTEGER; + this.model = lexicalModel; + + if(lexicalModel && lexicalModel.traverseFromRoot) { + this.searchSpace = [new SearchSpace(lexicalModel)]; + } + } + } + + get head(): TrackedContextToken { + return this.tokens[0]; + } + + get tail(): TrackedContextToken { + return this.tokens[this.tokens.length - 1]; + } + + popHead() { + this.tokens.splice(0, 2); + this.indexOffset -= 1; + } + + pushTail(token: TrackedContextToken) { + if(this.model && this.model.traverseFromRoot) { + this.searchSpace = [new SearchSpace(this.model)]; // yeah, need to update SearchSpace for compatibility + } else { + this.searchSpace = []; + } + this.tokens.push(token); + + let state = this; + if(state.searchSpace.length > 0) { + token.transformDistributions.forEach(distrib => state.searchSpace[0].addInput(distrib)); + } + } + + pushWhitespaceToTail(transformDistribution: Distribution = null) { + let whitespaceToken = new TrackedContextToken(); + + // Track the Transform that resulted in the whitespace 'token'. + // Will be needed for phrase-level correction/prediction. + whitespaceToken.transformDistributions = transformDistribution ? [transformDistribution] : []; + + whitespaceToken.raw = null; + this.tokens.push(whitespaceToken); + } + + /** + * Used for 14.0's backspace workaround, which flattens all previous Distribution + * entries because of limitations with direct use of backspace transforms. + * @param tokenText + * @param transformId + */ + replaceTailForBackspace(tokenText: USVString, transformId: number) { + this.tokens.pop(); + + // It's a backspace transform; time for special handling! + // + // For now, with 14.0, we simply compress all remaining Transforms for the token into + // multiple single-char transforms. Probabalistically modeling BKSP is quite complex, + // so we simplify by assuming everything remaining after a BKSP is 'true' and 'intended' text. + // + // Note that we cannot just use a single, monolithic transform at this point b/c + // of our current edit-distance optimization strategy; diagonalization is currently... + // not very compatible with that. + let backspacedTokenContext: Distribution[] = textToCharTransforms(tokenText, transformId).map(function(transform) { + return [{sample: transform, p: 1.0}]; + }); + + let compactedToken = new TrackedContextToken(); + compactedToken.raw = tokenText; + compactedToken.transformDistributions = backspacedTokenContext; + this.pushTail(compactedToken); + } + + updateTail(transformDistribution: Distribution, tokenText?: USVString) { + let editedToken = this.tail; + + // Preserve existing text if new text isn't specified. + tokenText = tokenText || (tokenText === '' ? '' : editedToken.raw); + + if(transformDistribution && transformDistribution.length > 0) { + editedToken.transformDistributions.push(transformDistribution); + if(this.searchSpace) { + this.searchSpace.forEach(space => space.addInput(transformDistribution)); + } + } + // Replace old token's raw-text with new token's raw-text. + editedToken.raw = tokenText; + } + + toRawTokenization() { + let sequence: USVString[] = []; + + for(let token of this.tokens) { + // Hide any tokens representing wordbreaks. (Thinking ahead to phrase-level possibilities) + if(token.currentText !== null) { + sequence.push(token.currentText); + } + } + + return sequence; + } +} + +class CircularArray { + static readonly DEFAULT_ARRAY_SIZE = 5; + private circle: Item[]; + private currentHead: number=0; + private currentTail: number=0; + + constructor(size: number = CircularArray.DEFAULT_ARRAY_SIZE) { + this.circle = Array(size); + } + + get count(): number { + let diff = this.currentHead - this.currentTail; + + if(diff < 0) { + diff = diff + this.circle.length; + } + + return diff; + } + + get maxCount(): number { + return this.circle.length; + } + + get oldest(): Item { + if(this.count == 0) { + return undefined; + } + + return this.item(0); + } + + get newest(): Item { + if(this.count == 0) { + return undefined; + } + + return this.item(this.count - 1); + } + + enqueue(item: Item): Item { + var prevItem = null; + let nextHead = (this.currentHead + 1) % this.maxCount; + + if(nextHead == this.currentTail) { + prevItem = this.circle[this.currentTail]; + this.currentTail = (this.currentTail + 1) % this.maxCount; + } + + this.circle[this.currentHead] = item; + this.currentHead = nextHead; + + return prevItem; + } + + dequeue(): Item { + if(this.currentTail == this.currentHead) { + return null; + } else { + let item = this.circle[this.currentTail]; + this.currentTail = (this.currentTail + 1) % this.maxCount; + return item; + } + } + + popNewest(): Item { + if(this.currentTail == this.currentHead) { + return null; + } else { + let item = this.circle[this.currentHead]; + this.currentHead = (this.currentHead - 1 + this.maxCount) % this.maxCount; + return item; + } + } + + /** + * Returns items contained within the circular array, ordered from 'oldest' to 'newest' - + * the same order in which the items will be dequeued. + * @param index + */ + item(index: number) { + if(index >= this.count) { + throw "Invalid array index"; + } + + let mappedIndex = (this.currentTail + index) % this.maxCount; + return this.circle[mappedIndex]; + } +} + +export class ContextTracker extends CircularArray { + static attemptMatchContext(tokenizedContext: USVString[], + matchState: TrackedContextState, + transformDistribution?: Distribution,): TrackedContextState { + // Map the previous tokenized state to an edit-distance friendly version. + let matchContext: USVString[] = matchState.toRawTokenization(); + + // Inverted order, since 'match' existed before our new context. + let mapping = ClassicalDistanceCalculation.computeDistance(matchContext.map(value => ({key: value})), + tokenizedContext.map(value => ({key: value})), + 1); + + let editPath = mapping.editPath(); + + let poppedHead = false; + let pushedTail = false; + + // Matters greatly when starting from a nil context. + if(editPath.length > 1) { + // First entry: may not be an 'insert' or a 'transpose' op. + // 'insert' allowed if the next token is 'substitute', as this may occur with an edit path of length 2. + if((editPath[0] == 'insert' && !(editPath[1] == 'substitute' && editPath.length == 2)) || editPath[0].indexOf('transpose') >= 0) { + return null; + } else if(editPath[0] == 'delete') { + poppedHead = true; // a token from the previous state has been wholly removed. + } + } + + // Last entry: may not be a 'delete' or a 'transpose' op. + let tailIndex = editPath.length -1; + let ignorePenultimateMatch = false; + if(editPath[tailIndex] == 'delete' || editPath[0].indexOf('transpose') >= 0) { + return null; + } else if(editPath[tailIndex] == 'insert') { + pushedTail = true; + } else if(tailIndex > 0 && editPath[tailIndex-1] == 'insert' && editPath[tailIndex] == 'substitute') { + // Tends to happen when accepting suggestions. + pushedTail = true; + ignorePenultimateMatch = true; + } + + // Can happen for the first text input after backspace deletes a wordbreaking character, + // thus the new input continues a previous word while dropping the empty word after + // that prior wordbreaking character. + // + // We can't handle it reliably from this match state, but a previous entry (without the empty token) + // should still be in the cache and will be reliable for this example case. + if(tailIndex > 0 && editPath[tailIndex-1] == 'delete' && editPath[tailIndex] == 'substitute') { + return null; + } + + // Now to check everything in-between: should be exclusively 'match'es. + for(let index = 1; index < editPath.length - (ignorePenultimateMatch ? 2 : 1); index++) { + if(editPath[index] != 'match') { + return null; + } + } + + // If we've made it here... success! We have a context match! + let state: TrackedContextState; + + if(pushedTail) { + // On suggestion acceptance, we should update the previous final token. + // We do it first so that the acceptance is replicated in the new TrackedContextState + // as well. + if(ignorePenultimateMatch) { + // For this case, we were likely called by ModelCompositor.acceptSuggestion(), which + // would have marked the accepted suggestion. + matchState.tail.replacementText = tokenizedContext[tokenizedContext.length-2]; + } + + state = new TrackedContextState(matchState); + } else { + // Since we're continuing a previously-cached context, we can reuse the same SearchSpace + // to continue making predictions. + state = matchState; + } + + const hasDistribution = transformDistribution && Array.isArray(transformDistribution); + let primaryInput = hasDistribution ? transformDistribution[0].sample : null; + if(primaryInput && primaryInput.insert == "" && primaryInput.deleteLeft == 0 && !primaryInput.deleteRight) { + primaryInput = null; + } + + const isWhitespace = primaryInput && TransformUtils.isWhitespace(primaryInput); + const isBackspace = primaryInput && TransformUtils.isBackspace(primaryInput); + const finalToken = tokenizedContext[tokenizedContext.length-1]; + + /* Assumption: This is an adequate check for its two sub-branches. + * + * Basis: + * - Assumption: one keystroke may only cause a single token to rotate out of context. + * - That is, no "reasonable" keystroke would emit enough code points to 'bump' two words simultaneously. + * - ... This one may need to be loosened a bit... but it should be enough for initial correction testing as-is. + * - Assumption: one keystroke may only cause a single token to be appended to the context + * - That is, no "reasonable" keystroke would emit a Transform adding two separate word tokens + * - For languages using whitespace to word-break, said keystroke would have to include said whitespace to break the assumption. + */ + + // If there is/was more than one context token available... + if(editPath.length > 1) { + // We're removing a context token, but at least one remains. + if(poppedHead) { + state.popHead(); + } + + // We're adding an additional context token. + if(pushedTail) { + const tokenizedTail = tokenizedContext[tokenizedContext.length - 1]; + /* + * Common-case: most transforms that trigger this case are from pure-whitespace Transforms. MOST. + * + * Less-common, but noteworthy: some wordbreaks may occur without whitespace. Example: + * `"o` => ['"', 'o']. Make sure to double-check against `tokenizedContext`! + */ + let pushedToken = new TrackedContextToken(); + pushedToken.raw = tokenizedTail; + + if(isWhitespace || !primaryInput) { + state.pushWhitespaceToTail(transformDistribution ?? []); + // Continuing the earlier assumption, that 'pure-whitespace Transform' does not emit any initial characters + // for the new word (token), so the input keystrokes do not correspond to the new text token. + pushedToken.transformDistributions = []; + } else { + state.pushWhitespaceToTail(); + // Assumption: Since we only allow one-transform-at-a-time changes between states, we shouldn't be missing + // any metadata used to construct the new context state token. + pushedToken.transformDistributions = transformDistribution ? [transformDistribution] : []; + } + + state.pushTail(pushedToken); + } else { // We're editing the final context token. + // TODO: Assumption: we didn't 'miss' any inputs somehow. + // As is, may be prone to fragility should the lm-layer's tracked context 'desync' from its host's. + if(isBackspace) { + state.replaceTailForBackspace(finalToken, primaryInput.id); + } else { + state.updateTail(primaryInput ? transformDistribution : null, finalToken); + } + } + // There is only one word in the context. + } else { + // TODO: Assumption: we didn't 'miss' any inputs somehow. + // As is, may be prone to fragility should the lm-layer's tracked context 'desync' from its host's. + + if(editPath[tailIndex] == 'insert') { + // Construct appropriate initial token. + let token = new TrackedContextToken(); + token.raw = tokenizedContext[0]; + token.transformDistributions = [transformDistribution]; + state.pushTail(token); + } else { // Edit the lone context token. + // Consider backspace entry for this case? + if(isBackspace) { + state.replaceTailForBackspace(finalToken, primaryInput.id); + } else { + state.updateTail(primaryInput ? transformDistribution : null, finalToken); + } + } + } + return state; + } + + static modelContextState(tokenizedContext: USVString[], + transformDistribution: Distribution, + lexicalModel: LexicalModel): TrackedContextState { + let baseTokens = tokenizedContext.map(function(entry) { + let token = new TrackedContextToken(); + token.raw = entry; + if(token.raw) { + token.transformDistributions = textToCharTransforms(token.raw).map(function(transform) { + return [{sample: transform, p: 1.0}]; + }); + } else { + // Helps model context-final wordbreaks. + token.transformDistributions = []; + } + return token; + }); + + // And now build the final context state object, which includes whitespace 'tokens'. + let state = new TrackedContextState(lexicalModel); + + if(baseTokens.length > 0) { + state.pushTail(baseTokens.splice(0, 1)[0]); + } + + while(baseTokens.length > 0) { + state.pushWhitespaceToTail(); + state.pushTail(baseTokens.splice(0, 1)[0]); + } + + if(state.tokens.length == 0) { + let token = new TrackedContextToken(); + token.raw = ''; + + state.pushTail(token); + } + + return state; + } + + /** + * Compares the current, post-input context against the most recently-seen contexts from previous prediction calls, returning + * the most information-rich `TrackedContextState` possible. If a match is found, the state will be annotated with the + * input information provided to previous prediction calls and persisted correction-search calculations for re-use. + * + * @param model + * @param context + * @param transformDistribution + */ + analyzeState(model: LexicalModel, + context: Context, + transformDistribution?: Distribution): TrackedContextState { + if(!model.traverseFromRoot) { + // Assumption: LexicalModel provides a valid traverseFromRoot function. (Is technically optional) + // Without it, no 'corrections' may be made; the model can only be used to predict, not correct. + throw "This lexical model does not provide adequate data for correction algorithms and context reuse"; + } + + let tokenizedContext = tokenize(model.wordbreaker || defaultWordbreaker, context); + + if(tokenizedContext.left.length > 0) { + for(let i = this.count - 1; i >= 0; i--) { + let resultState = ContextTracker.attemptMatchContext(tokenizedContext.left, this.item(i), transformDistribution); + + if(resultState) { + resultState.taggedContext = context; + if(resultState != this.item(i)) { + this.enqueue(resultState); + } + return resultState; + } + } + } + + // Else: either empty OR we've detected a 'new context'. Initialize from scratch; no prior input information is + // available. Only the results of the prior inputs are known. + // + // Assumption: as a caret needs to move to context before any actual transform distributions occur, + // this state is only reached on caret moves; thus, transformDistribution is actually just a single null transform. + let state = ContextTracker.modelContextState(tokenizedContext.left, transformDistribution, model); + state.taggedContext = context; + this.enqueue(state); + return state; + } +} diff --git a/common/web/lm-worker/src/main/correction/distance-modeler.ts b/common/web/lm-worker/src/main/correction/distance-modeler.ts new file mode 100644 index 0000000000..20d72c5468 --- /dev/null +++ b/common/web/lm-worker/src/main/correction/distance-modeler.ts @@ -0,0 +1,805 @@ +import { Comparator, isHighSurrogate, SENTINEL_CODE_UNIT, PriorityQueue } from '@keymanapp/models-templates'; + +import { ClassicalDistanceCalculation, EditToken } from './classical-calculation.js'; + +type RealizedInput = ProbabilityMass[]; // NOT Distribution - they're masses from separate distributions. + +export type TraversableToken = { + key: TUnit, + traversal: LexiconTraversal +} + +export const QUEUE_NODE_COMPARATOR: Comparator = function(arg1, arg2) { + return arg1.currentCost - arg2.currentCost; +} + +// Represents a processed node for the correction-search's search-space's tree-like graph. May represent +// internal and 'leaf' nodes on said graph, as well as the overall root of the search. Also used to represent +// edges on the graph TO said nodes - there's a bit of overloading here. Either way, it stores the cost of the +// optimum path used to reach the ndoe. +// +// The stored path cost may be an overestimate when the edit distance is greater than the current search threshold. The +// first version of the node to be dequeued from SearchSpace's priority queue hierarchy 'wins' and is taken as the absolute +// minimum; subsequent versions are ignored as suboptimal. +// +// Provides functions usable to enumerate across the node's outward edges to new nodes for continued search. +// Most of the actual calculations occur as part of this process. +// +// For nodes with raw edit-distance cost within the current threshold for correction searches, we do have admissibility. +// If not enough nodes are available within that threshold, however, admissibility may be lost, leaving our search as a +// heuristic. +// +export class SearchNode { + calculation: ClassicalDistanceCalculation, TraversableToken>; + + currentTraversal: LexiconTraversal; + toKey: (wordform: USVString) => USVString = str => str; + priorInput: RealizedInput; + + // Internal lazy-cache for .inputSamplingCost, as it's a bit expensive to re-compute. + private _inputCost?: number; + + constructor(rootTraversal: LexiconTraversal, toKey?: (USVString) => USVString); + constructor(node: SearchNode); + constructor(rootTraversal: LexiconTraversal | SearchNode, toKey?: (USVString) => USVString) { + toKey = toKey || (x => x); + + if(rootTraversal instanceof SearchNode) { + let priorNode = rootTraversal; + this.calculation = priorNode.calculation; + this.currentTraversal = priorNode.currentTraversal; + this.priorInput = priorNode.priorInput; + this.toKey = priorNode.toKey; + } else { + this.calculation = new ClassicalDistanceCalculation(); + this.currentTraversal = rootTraversal; + this.priorInput = []; + this.toKey = toKey; + } + } + + get knownCost(): number { + return this.calculation.getHeuristicFinalCost(); + } + + get inputSamplingCost(): number { + if(this._inputCost !== undefined) { + return this._inputCost; + } else { + let MIN_P = SearchSpace.MIN_KEYSTROKE_PROBABILITY; + // Should technically re-normalize the sampling distribution. + // -ln(p) is smaller for larger probabilities, as ln(p) is always <= 0. Approaches infinity as p => 0. + + // TODO: probably more efficient to instead use actual 'probability space'... but that'll involve extra changes. + this._inputCost = this.priorInput.map(mass => mass.p > MIN_P ? mass.p : MIN_P).reduce((previous, current) => previous - Math.log(current), 0); + return this._inputCost; + } + } + + // The part used to prioritize our search. + get currentCost(): number { + // - We reintrepret 'known cost' as a psuedo-probability. + // - Noting that 1/e = 0.367879441, an edit-distance cost of 1 may be intepreted as -ln(1/e) - a log-space 'likelihood'. + // - Not exactly normalized, though. + // That's a really, really high likelihood, thoough. + // + // At any rate, we can linearly scale the known-cost to have about whatever probability we want. + // If we can state it as p = 1 / (c * e), note then that ln(c * e) = ln(c) + 1. So, scale * (ln(c) + 1). + // If we can state it as e^x, note that ln(e^x) = x * ln(e) = x - just scale by 'x'! + + // p = 1 / (e^4) = 0.01831563888. This still exceeds many neighboring keys! + // p = 1 / (e^5) = 0.00673794699. Strikes a good balance. + // Should easily give priority to neighboring keys before edit-distance kicks in (when keys are a bit ambiguous) + return SearchSpace.EDIT_DISTANCE_COST_SCALE * this.knownCost + this.inputSamplingCost; + } + + buildInsertionEdges(): SearchNode[] { + let edges: SearchNode[] = []; + + for(let lexicalChild of this.currentTraversal.children()) { + let traversal = lexicalChild.traversal(); + let matchToken = { + key: lexicalChild.char, + traversal: traversal + } + + let childCalc = this.calculation.addMatchChar(matchToken); + + let searchChild = new SearchNode(this); + searchChild.calculation = childCalc; + searchChild.priorInput = this.priorInput; + searchChild.currentTraversal = traversal; + + edges.push(searchChild); + } + + return edges; + } + + buildDeletionEdges(inputDistribution: Distribution): SearchNode[] { + let edges: SearchNode[] = []; + + /* + * If the probability of an input is less than the highest probability * the base edit-distance likelihood, + * don't build an edge for it; just rely on edits from the highest-probability edge. + * + * We may be able to be stricter, but this should be a decent start. + * + * Note: thanks to ModelCompositor.predict, we know the distribution is pre-sorted. + */ + for(let probMass of inputDistribution) { + if(probMass.p < inputDistribution[0].p * Math.exp(-SearchSpace.EDIT_DISTANCE_COST_SCALE)) { + // Again, we're pre-sorted. All further entries will be too low-cost to consider. + break; + } + + let edgeCalc = this.calculation; + let transform = probMass.sample; + if(transform.deleteLeft) { + edgeCalc = edgeCalc.getSubset(edgeCalc.inputSequence.length - transform.deleteLeft, edgeCalc.matchSequence.length); + } + + // TODO: transform.deleteRight currently not supported. + + let inputPath = Array.from(this.priorInput); + inputPath.push(probMass); + // Tokenize and iterate over input chars, adding them into the calc. + for(let i=0; i < transform.insert.length; i++) { + let char = transform.insert[i]; + if(isHighSurrogate(char)) { + i++; + char = char + transform.insert[i]; + } + + // In case of NFD input, filter out any empty-strings that may arise + // when 'keying' raw diacritics. + let keyedChar = this.toKey(char); + if(keyedChar) { + edgeCalc = edgeCalc.addInputChar({key: keyedChar}); + } + } + + let childEdge = new SearchNode(this); + childEdge.calculation = edgeCalc; + childEdge.priorInput = inputPath; + + edges.push(childEdge); + } + + return edges; + } + + // While this may SEEM to be unnecessary, note that sometimes substitutions (which are computed + // via insert + delete) may be lower cost than both just-insert and just-delete. + buildSubstitutionEdges(inputDistribution: Distribution): SearchNode[] { + // Handles the 'input' component. + let intermediateEdges = this.buildDeletionEdges(inputDistribution); + let edges: SearchNode[] = []; + + for(let lexicalChild of this.currentTraversal.children()) { + for(let edge of intermediateEdges) { + let traversal = lexicalChild.traversal(); + let matchToken = { + key: lexicalChild.char, + traversal: traversal + } + + let childCalc = edge.calculation.addMatchChar(matchToken); + + let searchChild = new SearchNode(this); + searchChild.calculation = childCalc; + searchChild.priorInput = edge.priorInput; + searchChild.currentTraversal = traversal; + + edges.push(searchChild); + } + } + + return edges; + } + + get mapKey(): string { + let inputString = this.priorInput.map((value) => '+' + value.sample.insert + '-' + value.sample.deleteLeft).join(''); + let matchString = this.calculation.matchSequence.map((value) => value.key).join(''); + + // TODO: might should also track diagonalWidth. + return inputString + SENTINEL_CODE_UNIT + matchString; + } + + get isFullReplacement(): boolean { + // If the known edit-distance cost is equal to the input length, this means + // that literally every input has been full-on replaced. Thus, this is + // likely not a good 'root' to use for predictions. + // + // Logic exception: 0 cost, 0 length != a "replacement". + return this.knownCost && this.knownCost == this.priorInput.length; + } +} + +class SearchSpaceTier { + correctionQueue: PriorityQueue; + processed: SearchNode[] = []; + index: number; + + constructor(index: number, initialEdges?: SearchNode[]) { + this.index = index; + this.correctionQueue = new PriorityQueue(QUEUE_NODE_COMPARATOR, initialEdges); + } + + increaseMaxEditDistance() { + // By extracting the entries from the priority queue and increasing distance outside of it as a batch job, + // we get an O(N) implementation, rather than the O(N log N) that would result from maintaining the original queue. + let entries = this.correctionQueue.toArray(); + + entries.forEach(function(edge) { edge.calculation = edge.calculation.increaseMaxDistance(); }); + + // Since we just modified the stored instances, and the costs may have shifted, we need to re-heapify. + this.correctionQueue = new PriorityQueue(QUEUE_NODE_COMPARATOR, entries); + } +} + +export class SearchResult { + private resultNode: SearchNode; + + constructor(node: SearchNode) { + this.resultNode = node; + } + + get inputSequence(): ProbabilityMass[] { + return this.resultNode.priorInput; + } + + get matchSequence(): TraversableToken[] { + return this.resultNode.calculation.matchSequence; + }; + + get matchString(): USVString { + return this.matchSequence.map(value => value.key).join(''); + } + + get knownCost(): number { + return this.resultNode.knownCost; + } + + get inputSamplingCost(): number { + return this.resultNode.inputSamplingCost; + } + + get totalCost(): number { + return this.resultNode.currentCost; + } + + get finalTraversal(): LexiconTraversal { + return this.resultNode.currentTraversal; + } +} + +type NullPath = { + type: 'none' +} + +type IntermediateSearchPath = { + type: 'intermediate', + cost: number +} + +type CompleteSearchPath = { + type: 'complete', + cost: number, + finalNode: SearchNode +} + +type PathResult = NullPath | IntermediateSearchPath | CompleteSearchPath; + +// The set of search spaces corresponding to the same 'context' for search. +// Whenever a wordbreak boundary is crossed, a new instance should be made. +export class SearchSpace { + private QUEUE_SPACE_COMPARATOR: Comparator; + + // p = 1 / (e^4) = 0.01831563888. This still exceeds many neighboring keys! + // p = 1 / (e^5) = 0.00673794699. Strikes a good balance. + // Should easily give priority to neighboring keys before edit-distance kicks in (when keys are a bit ambiguous) + static readonly EDIT_DISTANCE_COST_SCALE = 5; + static readonly MIN_KEYSTROKE_PROBABILITY = 0.0001; + static readonly DEFAULT_ALLOTTED_CORRECTION_TIME_INTERVAL = 33; // in milliseconds. + + private tierOrdering: SearchSpaceTier[] = []; + private selectionQueue: PriorityQueue; + private inputSequence: Distribution[] = []; + private minInputCost: number[] = []; + private rootNode: SearchNode; + + // We use an array and not a PriorityQueue b/c batch-heapifying at a single point in time + // is cheaper than iteratively building a priority queue. + private completedPaths: SearchNode[]; + + // Marks all results that have already been returned since the last input was received. + private returnedValues: {[mapKey: string]: SearchNode} = {}; + + // Signals that the edge has already been processed. + private processedEdgeSet: {[mapKey: string]: boolean} = {}; + + constructor(model: LexicalModel) { + if(!model) { + throw "The LexicalModel parameter must not be null / undefined."; + } else if(!model.traverseFromRoot) { + throw "The provided model does not implement the `traverseFromRoot` function, which is needed to support robust correction searching."; + } + + // Constructs the comparator needed for the following line. + this.buildQueueSpaceComparator(); + this.selectionQueue = new PriorityQueue(this.QUEUE_SPACE_COMPARATOR); + this.rootNode = new SearchNode(model.traverseFromRoot(), model.toKey ? model.toKey.bind(model) : null); + + this.completedPaths = [this.rootNode]; + + // Adds a base level queue to handle initial insertions. + // Start with _just_ the root node. Necessary for proper empty-token, empty-input handling! + let baseTier = new SearchSpaceTier(0, [this.rootNode]); + this.tierOrdering.push(baseTier); + this.selectionQueue.enqueue(baseTier); + } + + private buildQueueSpaceComparator() { + let searchSpace = this; + + this.QUEUE_SPACE_COMPARATOR = function(space1, space2) { + let node1 = space1.correctionQueue.peek(); + let node2 = space2.correctionQueue.peek(); + + let index1 = space1.index; + let index2 = space2.index; + + let tierMinCost: number = 0; + let sign = 1; + + if(index2 < index1) { + let temp = index2; + index2 = index1; + index1 = temp; + + sign = -1; + } + + // Boost the cost of the lower tier by the minimum cost possible for the missing inputs between them. + // In essence, compare the nodes as if the lower tier had the most likely input appended for each such + // input missing at the lower tier. + // + // A 100% admissible heuristic to favor a deeper search, since the added cost is guaranteed if the path + // is traversed further. + // + // Remember, tier index i's last used input was from input index i-1. + // As a result, i is the first needed input index, with index2 - 1 the last entry needed to match them. + for(let i=index1; i < index2; i++) { + tierMinCost = tierMinCost + searchSpace.minInputCost[i]; + } + + // Guards, just in case one of the search spaces ever has an empty node. + if(node1 && node2) { + // If node1 is lower-tier, node1 is the one in need of boosted cost. + // `sign` flips it when node2 is lower tier. + return node1.currentCost - node2.currentCost + sign * tierMinCost; + } else if(node2) { + return 1; + } else { + return -1; + } + } + } + + increaseMaxEditDistance() { + this.tierOrdering.forEach(function(tier) { tier.increaseMaxEditDistance() }); + } + + addInput(inputDistribution: Distribution) { + this.inputSequence.push(inputDistribution); + + // Assumes that `inputDistribution` is already sorted. + this.minInputCost.push(-Math.log(inputDistribution[0].p)); + + // With a newly-available input, we can extend new input-dependent paths from + // our previously-reached 'extractedResults' nodes. + let newlyAvailableEdges: SearchNode[] = []; + let batches = this.completedPaths.map(function(node) { + let deletions = node.buildDeletionEdges(inputDistribution); + let substitutions = node.buildSubstitutionEdges(inputDistribution); + + return deletions.concat(substitutions); + }); + + // Don't forget to reset the array; the contained nodes no longer reach the search's end. + this.completedPaths = []; + this.returnedValues = {}; + + batches.forEach(function(batch) { + newlyAvailableEdges = newlyAvailableEdges.concat(batch); + }); + + // Now that we've built the new edges, we can efficiently construct the new search tier. + let tier = new SearchSpaceTier(this.tierOrdering.length, newlyAvailableEdges); + this.tierOrdering.push(tier); + this.selectionQueue.enqueue(tier); + } + + // TODO: will want eventually for reversions and/or backspaces + removeLastInput() { + // 1. truncate all entries from that search tier; we need to 'restore' extractedResults to match + // the state that would have existed without the last search tier. + // 2. remove the last search tier. Which may necessitate reconstructing the tier queue, but oh well. + } + + private hasNextMatchEntry(): boolean { + let topQueue = this.selectionQueue.peek(); + if(topQueue) { + return topQueue.correctionQueue.count > 0; + } else { + return false; + } + } + + private handleNextNode(): PathResult { + if(!this.hasNextMatchEntry()) { + return { type: 'none' }; + } + + let bestTier = this.selectionQueue.dequeue(); + let currentNode = bestTier.correctionQueue.dequeue(); + + let unmatchedResult: IntermediateSearchPath = { + type: 'intermediate', + cost: currentNode.currentCost + } + + // Have we already processed a matching edge? If so, skip it. + // We already know the previous edge is of lower cost. + if(this.processedEdgeSet[currentNode.mapKey]) { + this.selectionQueue.enqueue(bestTier); + return unmatchedResult; + } else { + this.processedEdgeSet[currentNode.mapKey] = true; + } + + // Stage 1: filter out nodes/edges we want to prune + + // Forbid a raw edit-distance of greater than 2. + // Note: .knownCost is not scaled, while its contribution to .currentCost _is_ scaled. + let substitutionsOnly = false; + if(currentNode.knownCost > 2) { + return unmatchedResult; + } else if(currentNode.knownCost == 2) { + // Hard restriction: no further edits will be supported. This helps keep the search + // more narrowly focused. + substitutionsOnly = true; + } + + let tierMinCost = 0; + for(let i = 0; i <= bestTier.index; i++) { + tierMinCost += this.minInputCost[i]; + } + + // Thresholds _any_ path, partially based on currently-traversed distance. + // Allows a little 'wiggle room' + 2 "hard" edits. + // Can be important if needed characters don't actually exist on the keyboard + // ... or even just not the then-current layer of the keyboard. + if(currentNode.currentCost > tierMinCost + 2.5 * SearchSpace.EDIT_DISTANCE_COST_SCALE) { + return unmatchedResult; + } + + // Stage 2: build remaining edges + + // Always possible, as this does not require any new input. + if(!substitutionsOnly) { + let insertionEdges = currentNode.buildInsertionEdges(); + bestTier.correctionQueue.enqueueAll(insertionEdges); + } + + if(bestTier.index == this.tierOrdering.length - 1) { + // It was the final tier - store the node for future reference. + this.completedPaths.push(currentNode); + + // Since we don't modify any other tier, we may simply reinsert the removed tier. + this.selectionQueue.enqueue(bestTier); + + return { + type: 'complete', + cost: currentNode.currentCost, + finalNode: currentNode + }; + } else { + // Time to construct new edges for the next tier! + let nextTier = this.tierOrdering[bestTier.index+1]; + + let inputIndex = nextTier.index; + + let deletionEdges = []; + if(!substitutionsOnly) { + deletionEdges = currentNode.buildDeletionEdges(this.inputSequence[inputIndex-1]); + } + let substitutionEdges = currentNode.buildSubstitutionEdges(this.inputSequence[inputIndex-1]); + + // Note: we're live-modifying the tier's cost here! The priority queue loses its guarantees as a result. + nextTier.correctionQueue.enqueueAll(deletionEdges.concat(substitutionEdges)); + + // So, we simply rebuild the selection queue. + this.selectionQueue = new PriorityQueue(this.QUEUE_SPACE_COMPARATOR, this.tierOrdering); + + // We didn't reach an end-node, so we just end the iteration and continue the search. + } + + // If we've somehow fully exhausted all search options, indicate that none remain. + return unmatchedResult; + } + + // Current best guesstimate of how compositor will retrieve ideal corrections. + *getBestMatches(waitMillis?: number): Generator { + // might should also include a 'base cost' parameter of sorts? + let searchSpace = this; + let currentReturns: {[mapKey: string]: SearchNode} = {}; + + let maxTime: number; + if(waitMillis == 0) { + maxTime = Infinity; + } else if(waitMillis == undefined || Number.isNaN(waitMillis)) { // also covers null. + maxTime = SearchSpace.DEFAULT_ALLOTTED_CORRECTION_TIME_INTERVAL; + } else { + maxTime = waitMillis; + } + + /** + * This inner class is designed to help the algorithm detect its active execution time. + * While there's no official JS way to do this, we can approximate it by polling the + * current system time (in ms) after each iteration of a short-duration loop. Unusual + * spikes in system time for a single iteration is likely to indicate that an OS + * context switch occurred at some point during the iteration's execution. + */ + class ExecutionTimer { + /** + * The system time when this instance was created. + */ + private start: number; + + /** + * Marks the system time at the start of the currently-running loop, as noted + * by a call to the `startLoop` function. + */ + private loopStart: number; + + private maxExecutionTime: number; + private maxTrueTime: number; + + private executionTime: number; + + /** + * Used to track intervals in which potential context swaps by the OS may + * have occurred. Context switches generally seem to pause threads for + * at least 16 ms, while we expect each loop iteration to complete + * within just 1 ms. So, any possible context switch should have the + * longest observed change in system time. + * + * See `updateOutliers` for more details. + */ + private largestIntervals: number[] = [0]; + + constructor(maxExecutionTime: number, maxTrueTime: number) { + // JS measures time by the number of milliseconds since Jan 1, 1970. + this.loopStart = this.start = Date.now(); + this.maxExecutionTime = maxExecutionTime; + this.maxTrueTime = maxTrueTime; + } + + startLoop() { + this.loopStart = Date.now(); + } + + markIteration() { + const now = Date.now(); + const delta = now - this.loopStart; + this.executionTime += delta; + + /** + * Update the list of the three longest system-time intervals observed + * for execution of a single loop iteration. + * + * Ignore any zero-ms length intervals; they'd make the logic much + * messier than necessary otherwise. + */ + if(delta) { + // If the currently-observed interval is longer than the shortest of the 3 + // previously-observed longest intervals, replace it. + if(this.largestIntervals.length > 2 && delta > this.largestIntervals[0]) { + this.largestIntervals[0] = delta; + } else { + this.largestIntervals.push(delta); + } + // Puts the list in ascending order. Shortest of the list becomes the head, + // longest one the tail. + this.largestIntervals.sort(); + + // Then, determine if we need to update our outlier-based tweaks. + this.updateOutliers(); + } + } + + updateOutliers() { + /* Base assumption: since each loop of the search should evaluate within ~1ms, + * notably longer execution times are probably context switches. + * + * Base assumption: OS context switches generally last at least 16ms. (Based on + * a window.setTimeout() usually not evaluating for at least + * that long, even if set to 1ms.) + * + * To mitigate these assumptions: we'll track the execution time of every loop + * iteration. If the longest observation somehow matches or exceeds the length of + * the next two almost-longest observations twice over... we have a very strong + * 'context switch' candidate. + * + * Or, in near-formal math/stats: we expect a very low variance in execution + * time among the iterations of the search's loops. With a very low variance, + * ANY significant proportional spikes in execution time are outliers - outliers + * likely caused by an OS context switch. + * + * Rather than do intensive math, we use a somewhat lazy approach below that + * achieves the same net results given our assumptions, even when relaxed somewhat. + * + * The logic below relaxes the base assumptions a bit to be safe: + * - [2ms, 2ms, 8ms] will cause 8ms to be seen as an outlier. + * - [2ms, 3ms, 10ms] will cause 10ms to be seen as an outlier. + * + * Ideally: + * - [1ms, 1ms, 4ms] will view 4ms as an outlier. + * + * So we can safely handle slightly longer average intervals and slightly shorter + * OS context-switch time intervals. + */ + if(this.largestIntervals.length > 2) { + // Precondition: the `largestIntervals` array is sorted in ascending order. + // Shortest entry is at the head, longest at the tail. + if(this.largestIntervals[2] >= 2 * (this.largestIntervals[0] + this.largestIntervals[1])) { + this.executionTime -= this.largestIntervals[2]; + this.largestIntervals.pop(); + } + } + } + + shouldTimeout(): boolean { + const now = Date.now(); + if(now - this.start > this.maxTrueTime) { + return true; + } + + return this.executionTime > this.maxExecutionTime; + } + + resetOutlierCheck() { + this.largestIntervals = []; + } + } + + class BatchingAssistant { + currentCost = Number.MIN_SAFE_INTEGER; + entries: SearchResult[] = []; + + checkAndAdd(entry: SearchNode): SearchResult[] | null { + var result: SearchResult[] = null; + + if(entry.currentCost > this.currentCost) { + result = this.tryFinalize(); + + this.currentCost = entry.currentCost; + } + + // Filter out any duplicated match sequences. The same match sequence may be reached via + // different input sequences, after all. + let outputMapKey = entry.calculation.matchSequence.map(value => value.key).join(''); + + // First, ensure the edge has an existing 'shared' cache entry. + if(!searchSpace.returnedValues[outputMapKey]) { + searchSpace.returnedValues[outputMapKey] = entry; + } + + // Check the generator's local returned-value cache - this determines whether or not we + // need to add a new 'return' to the batch. + if(!currentReturns[outputMapKey]) { + this.entries.push(new SearchResult(entry)); + currentReturns[outputMapKey] = entry; + } + + return result; + } + + tryFinalize(): SearchResult[] | null { + var result: SearchResult[] = null; + if(this.entries.length > 0) { + result = this.entries; + this.entries = []; + } + + return result; + } + } + + let batcher = new BatchingAssistant(); + + const timer = new ExecutionTimer(maxTime*1.5, maxTime); + + // Stage 1 - if we already have extracted results, build a queue just for them and iterate over it first. + let returnedValues = Object.values(this.returnedValues); + if(returnedValues.length > 0) { + let preprocessedQueue = new PriorityQueue(QUEUE_NODE_COMPARATOR, returnedValues); + + // Build batches of same-cost entries. + timer.startLoop(); + while(preprocessedQueue.count > 0) { + let entry = preprocessedQueue.dequeue(); + + // Is the entry a reasonable result? + if(entry.isFullReplacement) { + // If the entry's 'match' fully replaces the input string, we consider it + // unreasonable and ignore it. + continue; + } + + let batch = batcher.checkAndAdd(entry); + timer.markIteration(); + + if(batch) { + // Do not track yielded time. + yield batch; + } + } + + // As we only return a batch once all entries of the same cost have been processed, we can safely + // finalize the last preprocessed group without issue. + let batch = batcher.tryFinalize(); + if(batch) { + // Do not track yielded time. + yield batch; + } + } + + // Stage 2: the fun part; actually searching! + timer.resetOutlierCheck(); + timer.startLoop(); + let timedOut = false; + do { + let newResult: PathResult; + + // Search for a 'complete' path, skipping all partial paths as long as time remains. + do { + newResult = this.handleNextNode(); + timer.markIteration(); + + if(timer.shouldTimeout()) { + timedOut = true; + } + } while(!timedOut && newResult.type == 'intermediate') + + // TODO: check 'cost' on intermediate, running it through batcher to early-detect cost changes. + let batch: SearchResult[]; + if(newResult.type == 'none') { + break; + } else if(newResult.type == 'complete') { + // Is the entry a reasonable result? + if(newResult.finalNode.isFullReplacement) { + // If the entry's 'match' fully replaces the input string, we consider it + // unreasonable and ignore it. Also, if we've reached this point... + // we can(?) assume that everything thereafter is as well. + break; + } + batch = batcher.checkAndAdd(newResult.finalNode); + } + + if(batch) { + yield batch; + } + } while(!timedOut && this.hasNextMatchEntry()); + + // If we _somehow_ exhaust all search options, make sure to return the final results. + let batch = batcher.tryFinalize(); + if(batch) { + yield batch; + } + + return null; + } +} diff --git a/common/web/lm-worker/src/main/correction/index.ts b/common/web/lm-worker/src/main/correction/index.ts new file mode 100644 index 0000000000..890c49ae33 --- /dev/null +++ b/common/web/lm-worker/src/main/correction/index.ts @@ -0,0 +1,3 @@ +export * from './classical-calculation.js'; +export * from './context-tracker.js'; +export * from './distance-modeler.js'; \ No newline at end of file diff --git a/common/web/lm-worker/src/index.ts b/common/web/lm-worker/src/main/index.ts similarity index 90% rename from common/web/lm-worker/src/index.ts rename to common/web/lm-worker/src/main/index.ts index 6494c3f5d2..a44d4e68bb 100644 --- a/common/web/lm-worker/src/index.ts +++ b/common/web/lm-worker/src/main/index.ts @@ -30,9 +30,16 @@ */ /// -/// -/// -/// +import { extendString } from "@keymanapp/web-utils"; + +extendString(); + +import * as models from './models/index.js'; +import * as correction from './correction/index.js'; +import * as wordBreakers from '@keymanapp/models-wordbreakers'; + +import ModelCompositor from './model-compositor.js'; +import { ImportScripts, IncomingMessage, LMLayerWorkerState, LoadMessage, ModelEval, ModelFile, ModelSourceSpec, PostMessage } from './worker-interfaces.js'; /** * Encapsulates all the state required for the LMLayer's worker thread. @@ -57,7 +64,7 @@ * The model and the configuration are ONLY relevant in the `ready` state; * as such, they are NOT direct properties of the LMLayerWorker. */ -class LMLayerWorker { +export default class LMLayerWorker { /** * State pattern. This object handles onMessage(). * handleMessage() can transition to a different state, if @@ -288,16 +295,16 @@ class LMLayerWorker { if(payload.source.type == 'file') { _this.loadModelFile(payload.source.file); } else { - // Creates a closure capturing all top-level names that the model must be able to reference. - // `eval` runs by scope rules; our virtualized worker needs a special scope for this to work. - // - // Reference: https://stackoverflow.com/a/40108685 - // Note that we don't need `this`, but we do need the namespaces seen below. let code = payload.source.code; - let evalInContext = function(LMLayerWorker, models, correction, wordBreakers) { - eval(code); - } - evalInContext(_this, models, correction, wordBreakers); + + // Limits the scope accessible by the code we're about to evaluate; the code may only access + // global scope and the arguments specified in the constructor below. + // + // This is far more encapsulated and likely more secure... and the former point means this is + // easier to bundle and more optimizable when bundling than direct eval. + // Reference: https://esbuild.github.io/link/direct-eval + const modelLoader = new Function('LMLayerWorker', 'models', 'correction', 'wordBreakers', code); + modelLoader(_this, models, correction, wordBreakers); } } }; @@ -401,21 +408,4 @@ class LMLayerWorker { return worker; } -} - -// Let LMLayerWorker be available both in the browser and in Node. -if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { - module.exports = LMLayerWorker; - module.exports['correction'] = correction; - module.exports['models'] = models; - module.exports['wordBreakers'] = wordBreakers; - /// XXX: export the ModelCompositor for testing. - module.exports['ModelCompositor'] = ModelCompositor; - module.exports['TransformUtils'] = TransformUtils; -} else if (typeof self !== 'undefined' && 'postMessage' in self && 'importScripts' in self) { - // Automatically install if we're in a Web Worker. - LMLayerWorker.install(self as any); // really, 'as typeof globalThis', but we're currently getting TS errors from use of that. -} else { - //@ts-ignore - window.LMLayerWorker = LMLayerWorker; -} +} \ No newline at end of file diff --git a/common/web/lm-worker/src/model-compositor.ts b/common/web/lm-worker/src/main/model-compositor.ts similarity index 99% rename from common/web/lm-worker/src/model-compositor.ts rename to common/web/lm-worker/src/main/model-compositor.ts index 2bb4fdbafa..561d57a718 100644 --- a/common/web/lm-worker/src/model-compositor.ts +++ b/common/web/lm-worker/src/main/model-compositor.ts @@ -1,6 +1,10 @@ -/// +import * as models from '@keymanapp/models-templates'; +import * as wordBreakers from '@keymanapp/models-wordbreakers'; +import * as correction from './correction/index.js' -class ModelCompositor { +import TransformUtils from './transformUtils.js'; + +export default class ModelCompositor { private lexicalModel: LexicalModel; private contextTracker?: correction.ContextTracker; private static readonly MAX_SUGGESTIONS = 12; diff --git a/common/web/lm-worker/src/main/models/dummy-model.ts b/common/web/lm-worker/src/main/models/dummy-model.ts new file mode 100644 index 0000000000..5312121fde --- /dev/null +++ b/common/web/lm-worker/src/main/models/dummy-model.ts @@ -0,0 +1,86 @@ +/// + +/* + * Copyright (c) 2018 National Research Council Canada (author: Eddie A. Santos) + * Copyright (c) 2018 SIL International + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + + +/** + * @file dummy-model.ts + * + * Defines the Dummy model, which is used for testing the + * prediction API exclusively. + */ + +/** + * The Dummy Model that returns nonsensical, but predictable results. + */ +export default class DummyModel implements LexicalModel { + configuration: Configuration; + punctuation?: LexicalModelPunctuation; + private _futureSuggestions: Suggestion[][]; + + constructor(options?: any) { + options = options || {}; + // Create a shallow copy of the suggestions; + // this class mutates the array. + this._futureSuggestions = options.futureSuggestions + ? options.futureSuggestions.slice() : []; + + if (options.punctuation) { + this.punctuation = options.punctuation; + } + } + + configure(capabilities: Capabilities): Configuration { + this.configuration = { + leftContextCodePoints: capabilities.maxLeftContextCodePoints, + rightContextCodePoints: capabilities.maxRightContextCodePoints + }; + + return this.configuration; + } + + predict(transform: Transform, context: Context, injectedSuggestions?: Suggestion[]): Distribution { + let makeUniformDistribution = function(suggestions: Suggestion[]): Distribution { + let distribution: Distribution = []; + let n = suggestions.length; + + for(let s of suggestions) { + distribution.push({sample: s, p: 1}); // For a dummy model, this is sufficient. The uniformness is all that matters. + } + + return distribution; + } + + if (injectedSuggestions) { + return makeUniformDistribution(injectedSuggestions); + } + + let currentSet = this._futureSuggestions.shift(); + + if(!currentSet) { + return []; + } else { + return makeUniformDistribution(currentSet); + } + } +}; \ No newline at end of file diff --git a/common/web/lm-worker/src/main/models/index.ts b/common/web/lm-worker/src/main/models/index.ts new file mode 100644 index 0000000000..14f77edc49 --- /dev/null +++ b/common/web/lm-worker/src/main/models/index.ts @@ -0,0 +1,2 @@ +export * from '@keymanapp/models-templates'; +export { default as DummyModel } from './dummy-model.js'; diff --git a/common/web/lm-worker/src/transformUtils.ts b/common/web/lm-worker/src/main/transformUtils.ts similarity index 94% rename from common/web/lm-worker/src/transformUtils.ts rename to common/web/lm-worker/src/main/transformUtils.ts index cbb2c151fe..f9d8d8a63f 100644 --- a/common/web/lm-worker/src/transformUtils.ts +++ b/common/web/lm-worker/src/main/transformUtils.ts @@ -1,4 +1,4 @@ -class TransformUtils { +export default class TransformUtils { static isWhitespace(transform: Transform): boolean { // Matches a string that is entirely one or more characters with Unicode general property Z* or the following: CR, LF, and Tab. const whitespaceRemover = /^[\u0009\u000A\u000D\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b\u2028\u2029\u202f\u205f\u3000]+$/i; diff --git a/common/web/lm-worker/src/worker-interfaces.ts b/common/web/lm-worker/src/main/worker-interfaces.ts similarity index 82% rename from common/web/lm-worker/src/worker-interfaces.ts rename to common/web/lm-worker/src/main/worker-interfaces.ts index de8f20a1a2..bcde9b56fd 100644 --- a/common/web/lm-worker/src/worker-interfaces.ts +++ b/common/web/lm-worker/src/main/worker-interfaces.ts @@ -28,24 +28,26 @@ /// +import type ModelCompositor from './model-compositor.js'; + /** * The signature of self.postMessage(), so that unit tests can mock it. */ -type PostMessage = typeof DedicatedWorkerGlobalScope.prototype.postMessage; -type ImportScripts = typeof DedicatedWorkerGlobalScope.prototype.importScripts; +export type PostMessage = typeof DedicatedWorkerGlobalScope.prototype.postMessage; +export type ImportScripts = typeof DedicatedWorkerGlobalScope.prototype.importScripts; /** * The valid incoming message kinds. */ -type IncomingMessageKind = 'config' | 'load' | 'predict' | 'unload' | 'wordbreak' | 'accept' | 'revert' | 'reset-context'; -type IncomingMessage = ConfigMessage | LoadMessage | PredictMessage | UnloadMessage | WordbreakMessage | AcceptMessage | RevertMessage | ResetContextMessage; +export type IncomingMessageKind = 'config' | 'load' | 'predict' | 'unload' | 'wordbreak' | 'accept' | 'revert' | 'reset-context'; +export type IncomingMessage = ConfigMessage | LoadMessage | PredictMessage | UnloadMessage | WordbreakMessage | AcceptMessage | RevertMessage | ResetContextMessage; /** * The structure of a config message. It should include the platform's supported * capabilities. */ -interface ConfigMessage { +export interface ConfigMessage { message: 'config'; /** @@ -61,7 +63,7 @@ interface ConfigMessage { testMode?: boolean } -interface ModelFile { +export interface ModelFile { type: 'file'; /** @@ -70,7 +72,7 @@ interface ModelFile { file: string; } -interface ModelEval { +export interface ModelEval { type: 'raw'; /** @@ -80,13 +82,13 @@ interface ModelEval { code: string; } -type ModelSourceSpec = ModelFile | ModelEval; +export type ModelSourceSpec = ModelFile | ModelEval; /** * The structure of an initialization message. It should include the model (either in * source code or parameter form), as well as the keyboard's capabilities. */ -interface LoadMessage { +export interface LoadMessage { message: 'load'; /** @@ -98,7 +100,7 @@ interface LoadMessage { /** * Message to suggestion text. */ -interface PredictMessage { +export interface PredictMessage { message: 'predict'; /** @@ -124,14 +126,14 @@ interface PredictMessage { context: Context; } -interface UnloadMessage { +export interface UnloadMessage { message: 'unload' } /** * Message used to request the last pre-cursor word in the context. */ -interface WordbreakMessage { +export interface WordbreakMessage { message: 'wordbreak'; /** @@ -146,7 +148,7 @@ interface WordbreakMessage { context: Context; } -interface AcceptMessage { +export interface AcceptMessage { message: 'accept'; /** @@ -177,7 +179,7 @@ interface AcceptMessage { postTransform?: Transform; } -interface RevertMessage { +export interface RevertMessage { message: 'revert'; /** @@ -198,7 +200,7 @@ interface RevertMessage { context: Context; } -interface ResetContextMessage { +export interface ResetContextMessage { message: 'reset-context'; /** @@ -210,12 +212,12 @@ interface ResetContextMessage { /** * The LMLayer can be in one of the following states. The LMLayer can only produce predictions in the 'ready' state. */ -type LMLayerWorkerState = LMLayerWorkerUnconfiguredState | LMLayerWorkerModellessState | LMLayerWorkerReadyState; + export type LMLayerWorkerState = LMLayerWorkerUnconfiguredState | LMLayerWorkerModellessState | LMLayerWorkerReadyState; /** * Represents the unconfigured state of the LMLayer. */ -interface LMLayerWorkerUnconfiguredState { +export interface LMLayerWorkerUnconfiguredState { name: 'unconfigured'; handleMessage(payload: IncomingMessage): void; } @@ -223,7 +225,7 @@ interface LMLayerWorkerUnconfiguredState { /** * Represents the pre-model-load state of the LMLayer. */ -interface LMLayerWorkerModellessState { +export interface LMLayerWorkerModellessState { name: 'modelless'; handleMessage(payload: IncomingMessage): void; } @@ -231,7 +233,7 @@ interface LMLayerWorkerModellessState { /** * Represents the 'ready' state of the LMLayer. */ -interface LMLayerWorkerReadyState { +export interface LMLayerWorkerReadyState { name: 'ready'; handleMessage(payload: IncomingMessage): void; compositor: ModelCompositor; @@ -240,7 +242,7 @@ interface LMLayerWorkerReadyState { /** * Constructors that return worker internal models. */ -interface WorkerInternalModelConstructor { +export interface WorkerInternalModelConstructor { /** * LexicalModel instances are all given the keyboard's * capabilities, plus any parameters they require. diff --git a/common/predictive-text/embedded_worker.d.ts b/common/web/lm-worker/src/main/worker-main.ts similarity index 67% rename from common/predictive-text/embedded_worker.d.ts rename to common/web/lm-worker/src/main/worker-main.ts index 243339f134..665943f66f 100644 --- a/common/predictive-text/embedded_worker.d.ts +++ b/common/web/lm-worker/src/main/worker-main.ts @@ -20,17 +20,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// Include the code intended to run WITHIN the Web Worker. -// The worker code MUST be compiled before this file is compiled. -// If you see a 'File: embedded_worker.js not found.' error message, please -// compile the worker first (stage one). -/*/// */ +import LMLayerWorker from './index.js'; -/** - * The body of this function contains code that should run - * inside a Web Worker context. - * - * Convert this code into a blob URI with LMLayer.asBlobURI(), - * and pass the URI into the new Worker() constructor. - */ - declare function LMLayerWorkerCode(): void; +// Let LMLayerWorker be available both in the browser and in Node. +if (typeof self !== 'undefined' && 'postMessage' in self && 'importScripts' in self) { + // Automatically install if we're in a Web Worker. + LMLayerWorker.install(self as any); // really, 'as typeof globalThis', but we're currently getting TS errors from use of that. +} else { + //@ts-ignore + window.LMLayerWorker = LMLayerWorker; +} diff --git a/common/web/lm-worker/src/models/dummy-model.ts b/common/web/lm-worker/src/models/dummy-model.ts deleted file mode 100644 index e077aff534..0000000000 --- a/common/web/lm-worker/src/models/dummy-model.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2018 National Research Council Canada (author: Eddie A. Santos) - * Copyright (c) 2018 SIL International - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -namespace models { - /** - * @file dummy-model.ts - * - * Defines the Dummy model, which is used for testing the - * prediction API exclusively. - */ - - /** - * The Dummy Model that returns nonsensical, but predictable results. - */ - export class DummyModel implements LexicalModel { - configuration: Configuration; - punctuation?: LexicalModelPunctuation; - private _futureSuggestions: Suggestion[][]; - - constructor(options?: any) { - options = options || {}; - // Create a shallow copy of the suggestions; - // this class mutates the array. - this._futureSuggestions = options.futureSuggestions - ? options.futureSuggestions.slice() : []; - - if (options.punctuation) { - this.punctuation = options.punctuation; - } - } - - configure(capabilities: Capabilities): Configuration { - this.configuration = { - leftContextCodePoints: capabilities.maxLeftContextCodePoints, - rightContextCodePoints: capabilities.maxRightContextCodePoints - }; - - return this.configuration; - } - - predict(transform: Transform, context: Context, injectedSuggestions?: Suggestion[]): Distribution { - let makeUniformDistribution = function(suggestions: Suggestion[]): Distribution { - let distribution: Distribution = []; - let n = suggestions.length; - - for(let s of suggestions) { - distribution.push({sample: s, p: 1}); // For a dummy model, this is sufficient. The uniformness is all that matters. - } - - return distribution; - } - - if (injectedSuggestions) { - return makeUniformDistribution(injectedSuggestions); - } - - let currentSet = this._futureSuggestions.shift(); - - if(!currentSet) { - return []; - } else { - return makeUniformDistribution(currentSet); - } - } - }; -} \ No newline at end of file diff --git a/common/predictive-text/unit_tests/headless/edit-distance/classical-calculation.js b/common/web/lm-worker/src/test/cases/edit-distance/classical-calculation.js similarity index 98% rename from common/predictive-text/unit_tests/headless/edit-distance/classical-calculation.js rename to common/web/lm-worker/src/test/cases/edit-distance/classical-calculation.js index 12159d06b0..1833e9f4d4 100644 --- a/common/predictive-text/unit_tests/headless/edit-distance/classical-calculation.js +++ b/common/web/lm-worker/src/test/cases/edit-distance/classical-calculation.js @@ -1,6 +1,5 @@ -var assert = require('chai').assert; -const LMLayerWorker = require('../../../../web/lm-worker/build/intermediate.js'); -var ClassicalDistanceCalculation = LMLayerWorker.correction.ClassicalDistanceCalculation; +import { assert } from 'chai'; +import { ClassicalDistanceCalculation } from '#./correction/classical-calculation.js'; function prettyPrintMatrix(matrix) { for(let r = 0; r < matrix.length; r++) { @@ -261,7 +260,7 @@ describe('Classical Damerau-Levenshtein edit-distance calculation', function() { // Two transpositions: da <- abcd, wxyz -> zw and one deletion ('g') it("'daefghiwxyz' -> 'abcdefhizw' (width 2) = 7", function() { - buffer = compute("daefghiwxyz", "abcdefhizw", "InputThenMatch", 2); + let buffer = compute("daefghiwxyz", "abcdefhizw", "InputThenMatch", 2); assert.equal(buffer.getHeuristicFinalCost(), 7); }); }); diff --git a/common/predictive-text/unit_tests/headless/edit-distance/context-tracker.js b/common/web/lm-worker/src/test/cases/edit-distance/context-tracker.js similarity index 95% rename from common/predictive-text/unit_tests/headless/edit-distance/context-tracker.js rename to common/web/lm-worker/src/test/cases/edit-distance/context-tracker.js index fa09ca20de..f8b715b43f 100644 --- a/common/predictive-text/unit_tests/headless/edit-distance/context-tracker.js +++ b/common/web/lm-worker/src/test/cases/edit-distance/context-tracker.js @@ -1,8 +1,10 @@ -var assert = require('chai').assert; -const LMLayerWorker = require('../../../../web/lm-worker/build/intermediate.js'); -var ContextTracker = LMLayerWorker.correction.ContextTracker; -var ModelCompositor = LMLayerWorker.ModelCompositor; -var models = LMLayerWorker.models; +import { assert } from 'chai'; + +import { ContextTracker } from '#./correction/context-tracker.js'; +import ModelCompositor from '#./model-compositor.js'; +import * as models from '#./models/index.js'; + +import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; describe('ContextTracker', function() { function toWrapperDistribution(transform) { @@ -158,7 +160,7 @@ describe('ContextTracker', function() { punctuation: englishPunctuation }; - let model = new models.TrieModel(jsonFixture('tries/english-1000'), options); + let model = new models.TrieModel(jsonFixture('models/tries/english-1000'), options); let compositor = new ModelCompositor(model); let baseContextState = compositor.contextTracker.analyzeState(model, baseContext); diff --git a/common/predictive-text/unit_tests/headless/edit-distance/distance-modeler.js b/common/web/lm-worker/src/test/cases/edit-distance/distance-modeler.js similarity index 97% rename from common/predictive-text/unit_tests/headless/edit-distance/distance-modeler.js rename to common/web/lm-worker/src/test/cases/edit-distance/distance-modeler.js index b893df826c..c2ed18bbc6 100644 --- a/common/predictive-text/unit_tests/headless/edit-distance/distance-modeler.js +++ b/common/web/lm-worker/src/test/cases/edit-distance/distance-modeler.js @@ -1,7 +1,8 @@ -var assert = require('chai').assert; -const LMLayerWorker = require('../../../../web/lm-worker/build/intermediate.js'); -let models = LMLayerWorker.models; -let correction = LMLayerWorker.correction; +import { assert } from 'chai'; +import * as models from '#./models/index.js'; +import * as correction from '#./correction/index.js'; + +import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; function assertEdgeChars(edge, input, match) { assert.isTrue(edgeHasChars(edge, input, match)); @@ -29,7 +30,7 @@ describe('Correction Distance Modeler', function() { var testModel; before(function() { - testModel = new models.TrieModel(jsonFixture('tries/english-1000')); + testModel = new models.TrieModel(jsonFixture('models/tries/english-1000')); }); it('SearchNode.buildInsertionEdges() - from root', function() { @@ -244,7 +245,7 @@ describe('Correction Distance Modeler', function() { var testModel; before(function() { - testModel = new models.TrieModel(jsonFixture('tries/english-1000')); + testModel = new models.TrieModel(jsonFixture('models/tries/english-1000')); }); let checkResults_teh = function(iter) { diff --git a/common/predictive-text/unit_tests/headless/transform-utils.js b/common/web/lm-worker/src/test/cases/transform-utils.js similarity index 86% rename from common/predictive-text/unit_tests/headless/transform-utils.js rename to common/web/lm-worker/src/test/cases/transform-utils.js index b00a24a123..d4da4501f4 100644 --- a/common/predictive-text/unit_tests/headless/transform-utils.js +++ b/common/web/lm-worker/src/test/cases/transform-utils.js @@ -1,6 +1,6 @@ -var assert = require('chai').assert; +import { assert } from 'chai'; -let TransformUtils = require('../../../web/lm-worker/build/intermediate.js').TransformUtils; +import TransformUtils from '#./transformUtils.js'; describe('TransformUtils', function () { describe('isWhitespace', function () { @@ -20,7 +20,7 @@ describe('TransformUtils', function () { }); it("should match a simple ' ' transform", function() { - transform = { + let transform = { insert: " ", deleteLeft: 0 }; @@ -29,7 +29,7 @@ describe('TransformUtils', function () { }); it("should match a simple ' ' transform with delete-left", function() { - transform = { + let transform = { insert: " ", deleteLeft: 1 }; @@ -38,7 +38,7 @@ describe('TransformUtils', function () { }); it("should match a transform consisting of multiple characters of only whitespace", function() { - transform = { + let transform = { insert: " \n\r\u00a0\t\u2000 ", deleteLeft: 0 }; @@ -47,7 +47,7 @@ describe('TransformUtils', function () { }); it("stress tests", function() { - transform = { + let transform = { insert: " \n\r\u00a0\ta\u2000 ", // the 'a' should cause failure. deleteLeft: 0 }; diff --git a/common/predictive-text/unit_tests/headless/worker-custom-punctuation.js b/common/web/lm-worker/src/test/cases/worker-custom-punctuation.js similarity index 92% rename from common/predictive-text/unit_tests/headless/worker-custom-punctuation.js rename to common/web/lm-worker/src/test/cases/worker-custom-punctuation.js index 2e6e477a77..e0ff73d1c9 100644 --- a/common/predictive-text/unit_tests/headless/worker-custom-punctuation.js +++ b/common/web/lm-worker/src/test/cases/worker-custom-punctuation.js @@ -2,10 +2,10 @@ * Unit tests for the Dummy prediction model. */ -var assert = require('chai').assert; -const LMLayerWorker = require('../../../web/lm-worker/build/intermediate.js'); -var DummyModel = LMLayerWorker.models.DummyModel; -var ModelCompositor = LMLayerWorker.ModelCompositor; +import { assert } from 'chai'; + +import DummyModel from '#./models/dummy-model.js'; +import ModelCompositor from '#./model-compositor.js'; describe('Custom Punctuation', function () { it('appears in the keep suggestion', function () { diff --git a/common/predictive-text/unit_tests/headless/worker-initialization.js b/common/web/lm-worker/src/test/cases/worker-initialization.js similarity index 89% rename from common/predictive-text/unit_tests/headless/worker-initialization.js rename to common/web/lm-worker/src/test/cases/worker-initialization.js index 49316ae273..3c15543bce 100644 --- a/common/predictive-text/unit_tests/headless/worker-initialization.js +++ b/common/web/lm-worker/src/test/cases/worker-initialization.js @@ -1,12 +1,17 @@ -var assert = require('chai').assert; -var sinon = require('sinon'); -var fs = require('fs'); +import { assert } from 'chai'; +import sinon from 'sinon'; +import fs from 'fs'; -let LMLayerWorker = require('../../../web/lm-worker/build/intermediate.js'); +import LMLayerWorker from '#./index.js'; + +import { configWorker, createMessageEventWithData, importScriptsWith } from '@keymanapp/common-test-resources/model-helpers.mjs'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); // Unit tests for instantiating and initializing the LMLayer Worker in isolation. // -// Although the LMLayerWorker expected to be used inside a DedicatedWorkerGlobalScope, +// Although the LMLayerWorker expects to be used inside a DedicatedWorkerGlobalScope, // these unit tests DO NOT run inside a Worker, and instead use Sinon fakes to assert // behavior. describe('LMLayerWorker', function() { @@ -149,7 +154,7 @@ describe('LMLayerWorker', function() { message: 'load', source: { type: 'file', - file: "./unit_tests/in_browser/resources/models/simple-dummy.js" + file: require.resolve("@keymanapp/common-test-resources/models/simple-dummy.js") } })); @@ -168,7 +173,7 @@ describe('LMLayerWorker', function() { var worker = LMLayerWorker.install(context); configWorker(worker); - let modelCode = fs.readFileSync("./unit_tests/in_browser/resources/models/simple-dummy.js").toString(); + let modelCode = fs.readFileSync(require.resolve("@keymanapp/common-test-resources/models/simple-dummy.js")).toString(); worker.onMessage(createMessageEventWithData({ message: 'load', @@ -199,7 +204,7 @@ describe('LMLayerWorker', function() { message: 'load', source: { type: 'file', - file: "./unit_tests/in_browser/resources/models/simple-dummy.js" + file: require.resolve("@keymanapp/common-test-resources/models/simple-dummy.js") } })); diff --git a/common/predictive-text/unit_tests/headless/worker-model-compositor.js b/common/web/lm-worker/src/test/cases/worker-model-compositor.js similarity index 97% rename from common/predictive-text/unit_tests/headless/worker-model-compositor.js rename to common/web/lm-worker/src/test/cases/worker-model-compositor.js index 8080b48f47..c642dfc94c 100644 --- a/common/predictive-text/unit_tests/headless/worker-model-compositor.js +++ b/common/web/lm-worker/src/test/cases/worker-model-compositor.js @@ -2,15 +2,20 @@ * Integration tests for the model compositor with the trie model. */ -const { models, wordBreakers, ModelCompositor } = require('../../../web/lm-worker/build/intermediate.js'); +import ModelCompositor from '#./model-compositor.js'; +import * as models from '#./models/index.js'; +import * as wordBreakers from '@keymanapp/models-wordbreakers/lib'; + +import { assert } from 'chai'; + +import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; -var assert = require('chai').assert; var TrieModel = models.TrieModel; describe('ModelCompositor', function() { describe('Prediction with 14.0+ models', function() { describe('Basic suggestion generation', function() { - var plainModel = new TrieModel(jsonFixture('tries/english-1000'), + var plainModel = new TrieModel(jsonFixture('models/tries/english-1000'), {wordBreaker: wordBreakers.default} ); @@ -169,7 +174,7 @@ describe('ModelCompositor', function() { }; var plainCasedModel = new TrieModel( - jsonFixture('tries/english-1000'), { + jsonFixture('models/tries/english-1000'), { languageUsesCasing: true, applyCasing: plainApplyCasing, wordBreaker: wordBreakers.default, @@ -304,7 +309,7 @@ describe('ModelCompositor', function() { describe('Model uses default-style keying, no casing', function () { var uncasedModel = new TrieModel( - jsonFixture('tries/english-1000'), { + jsonFixture('models/tries/english-1000'), { languageUsesCasing: false, wordBreaker: wordBreakers.default, searchTermToKey: function(text) { @@ -379,7 +384,7 @@ describe('ModelCompositor', function() { }; var casedModel = new TrieModel( - jsonFixture('tries/english-1000'), { + jsonFixture('models/tries/english-1000'), { languageUsesCasing: true, applyCasing: applyCasing, wordBreaker: wordBreakers.default, @@ -505,7 +510,7 @@ describe('ModelCompositor', function() { describe('Prediction with legacy Models (12.0 / 13.0)', function() { it('should compose suggestions from a fat-fingered keypress (no keying needed)', function () { var model = new TrieModel( - jsonFixture('tries/english-1000') + jsonFixture('models/tries/english-1000') ); var composite = new ModelCompositor(model); @@ -535,7 +540,7 @@ describe('ModelCompositor', function() { it('should compose suggestions from a fat-fingered keypress (keying needed)', function () { var model = new TrieModel( - jsonFixture('tries/english-1000') + jsonFixture('models/tries/english-1000') ); var composite = new ModelCompositor(model); @@ -903,7 +908,7 @@ describe('ModelCompositor', function() { id: 13 } - let model = new models.TrieModel(jsonFixture('tries/english-1000'), {punctuation: englishPunctuation}); + let model = new models.TrieModel(jsonFixture('models/tries/english-1000'), {punctuation: englishPunctuation}); let compositor = new ModelCompositor(model); let initialSuggestions = compositor.predict(postTransform, baseContext); @@ -938,7 +943,7 @@ describe('ModelCompositor', function() { id: 13 } - let model = new models.TrieModel(jsonFixture('tries/english-1000'), {punctuation: englishPunctuation}); + let model = new models.TrieModel(jsonFixture('models/tries/english-1000'), {punctuation: englishPunctuation}); let compositor = new ModelCompositor(model); let initialSuggestions = compositor.predict(postTransform, baseContext); diff --git a/common/predictive-text/unit_tests/headless/worker-predict-dummy.js b/common/web/lm-worker/src/test/cases/worker-predict-dummy.js similarity index 93% rename from common/predictive-text/unit_tests/headless/worker-predict-dummy.js rename to common/web/lm-worker/src/test/cases/worker-predict-dummy.js index 7275eeef40..f552f6e29d 100644 --- a/common/predictive-text/unit_tests/headless/worker-predict-dummy.js +++ b/common/web/lm-worker/src/test/cases/worker-predict-dummy.js @@ -2,9 +2,10 @@ * Unit tests for the Dummy prediction model. */ -var assert = require('chai').assert; -const LMLayerWorker = require('../../../web/lm-worker/build/intermediate.js'); -var DummyModel = LMLayerWorker.models.DummyModel; +import { assert } from 'chai'; +import DummyModel from '#./models/dummy-model.js'; + +import { emptyContext, iGotDistractedByHazel, zeroTransform } from '@keymanapp/common-test-resources/model-helpers.mjs'; describe('LMLayerWorker dummy model', function() { describe('instantiation', function () { diff --git a/common/predictive-text/unit_tests/headless/worker-predict.js b/common/web/lm-worker/src/test/cases/worker-predict.js similarity index 79% rename from common/predictive-text/unit_tests/headless/worker-predict.js rename to common/web/lm-worker/src/test/cases/worker-predict.js index 2a2fd40e15..f050998e87 100644 --- a/common/predictive-text/unit_tests/headless/worker-predict.js +++ b/common/web/lm-worker/src/test/cases/worker-predict.js @@ -1,8 +1,13 @@ -var assert = require('chai').assert; -var sinon = require('sinon'); +import { assert } from 'chai'; +import sinon from 'sinon'; -const LMLayerWorker = require('../../../web/lm-worker/build/intermediate.js'); +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); +import LMLayerWorker from '#./index.js'; + +import { configWorker, createMessageEventWithData, emptyContext, iGotDistractedByHazel, + importScriptsWith, randomToken, zeroTransform } from '@keymanapp/common-test-resources/model-helpers.mjs'; describe('LMLayerWorker', function () { describe('#predict()', function () { @@ -41,7 +46,7 @@ describe('LMLayerWorker', function () { message: 'load', source: { type: 'file', - file: "./unit_tests/in_browser/resources/models/simple-dummy.js" + file: require.resolve("@keymanapp/common-test-resources/models/simple-dummy.js") } })); sinon.assert.calledWithMatch(fakePostMessage.lastCall, { diff --git a/common/web/lm-worker/tsconfig.json b/common/web/lm-worker/tsconfig.json index 43c77edf0c..551f1645a9 100644 --- a/common/web/lm-worker/tsconfig.json +++ b/common/web/lm-worker/tsconfig.json @@ -4,24 +4,31 @@ "compilerOptions": { "allowJs": false, "declaration": true, - "module": "none", - "outFile": "./build/index.js", - "sourceMap": true, + "module": "es6", + "moduleResolution": "node", + "inlineSourceMap": true, + "inlineSources": true, + "sourceRoot": "/common/web/lm-worker/src", "lib": ["webworker", "es6"], "target": "es5", + "types": ["node"], "downlevelIteration": true, + "baseUrl": "./", + "outDir": "build/obj", + "tsBuildInfoFile": "build/obj/tsconfig.tsbuildinfo", + "rootDir": "./src/main" }, "references": [ // types { "path": "../../models/types" }, { "path": "../lm-message-types" }, // modules - { "path": "../keyman-version", "prepend": true }, - { "path": "../utils", "prepend": true }, - { "path": "../../models/templates", "prepend": true }, - { "path": "../../models/wordbreakers", "prepend": true }, + { "path": "../keyman-version" }, + { "path": "../utils" }, + { "path": "../../models/templates" }, + { "path": "../../models/wordbreakers" }, ], "include": [ - "src/**/*.ts" + "src/main/**/*.ts" ] } diff --git a/common/web/recorder/build.sh b/common/web/recorder/build.sh index b9e578e054..73951544b0 100755 --- a/common/web/recorder/build.sh +++ b/common/web/recorder/build.sh @@ -22,16 +22,11 @@ builder_describe \ "@/common/web/keyman-version" \ configure \ clean \ - build \ - ":module Builds recorder-core module" \ - ":proctor Builds headless-testing, node-oriented 'proctor' component" + build builder_describe_outputs \ - configure "/node_modules" \ - configure:module "/node_modules" \ - configure:proctor "/node_modules" \ - build:module "/common/web/recorder/build/index.js" \ - build:proctor "/common/web/recorder/build/nodeProctor/index.js" + configure "/node_modules" \ + build "/common/web/recorder/build/obj/index.js" builder_parse "$@" @@ -40,22 +35,12 @@ if builder_start_action configure; then builder_finish_action success configure fi -if builder_start_action clean:module; then - tsc -b --clean "$THIS_SCRIPT_PATH/src/tsconfig.json" - builder_finish_action success clean:module +if builder_start_action clean; then + tsc --build --clean "$THIS_SCRIPT_PATH/tsconfig.json" + builder_finish_action success clean fi -if builder_start_action clean:proctor; then - tsc -b --clean "$THIS_SCRIPT_PATH/src/nodeProctor.tsconfig.json" - builder_finish_action success clean:proctor -fi - -if builder_start_action build:module; then - tsc --build "$THIS_SCRIPT_PATH/src/tsconfig.json" - builder_finish_action success build:module -fi - -if builder_start_action build:proctor; then - tsc --build "$THIS_SCRIPT_PATH/src/nodeProctor.tsconfig.json" - builder_finish_action success build:proctor +if builder_start_action build; then + tsc --build "$THIS_SCRIPT_PATH/tsconfig.json" + builder_finish_action success build fi \ No newline at end of file diff --git a/common/web/recorder/package.json b/common/web/recorder/package.json index ecc8c43818..20ff012f59 100644 --- a/common/web/recorder/package.json +++ b/common/web/recorder/package.json @@ -1,7 +1,9 @@ { "name": "@keymanapp/recorder-core", "description": "Core classes used to develop KeymanWeb test cases based on keystrokes", - "main": "index.js", + "main": "build/obj/index.js", + "types": "build/obj/index.d.ts", + "type": "module", "scripts": { "tsc": "tsc", "clean": "tsc -b --clean src/tsconfig.json && tsc -b --clean src/nodeProctor.tsconfig.json" diff --git a/common/web/recorder/src/index.ts b/common/web/recorder/src/index.ts index d01dcad71b..940f663b68 100644 --- a/common/web/recorder/src/index.ts +++ b/common/web/recorder/src/index.ts @@ -1,774 +1,780 @@ -/// - -namespace KMWRecorder { - //#region Defines the InputEventSpec set, used to reconstruct DOM-based events for browser-based simulation - export abstract class InputEventSpec { - type: "key" | "osk"; - static fromJSONObject(obj: any): InputEventSpec { - if(obj && obj.type) { - if(obj.type == "key") { - return new PhysicalInputEventSpec(obj); - } else if(obj.type == "osk") { - return new OSKInputEventSpec(obj); - } - } else { - throw new SyntaxError("Error in JSON format corresponding to an InputEventSpec!"); - } - } +import { type OutputTarget } from "@keymanapp/keyboard-processor"; +import { KeyDistribution, KeyEvent, Mock } from "@keymanapp/keyboard-processor"; - toPrettyJSON(): string { - // We want the default, non-spaced JSON for this class, even when otherwise adding whitespace. - var str = JSON.stringify(this); - return str; - } - } - - export class PhysicalInputEventSpec extends InputEventSpec { - static readonly modifierCodes: { [mod:string]: number } = { - "Shift":0x0001, - "Control":0x0002, - "Alt":0x0004, - "Meta":0x0008, - "CapsLock":0x0010, - "NumLock":0x0020, - "ScrollLock":0x0040 - }; - - // KeyboardEvent properties - type: "key" = "key"; - key: string; - code: string; - keyCode: number; - modifierSet: number; - location: number; - - constructor(e?: PhysicalInputEventSpec) { // parameter is used to reconstruct from JSON. - super(); - - if(e) { - this.key = e.key; - this.code = e.code; - this.keyCode = e.keyCode; - this.modifierSet = e.modifierSet; - this.location = e.location; - } - } +import Proctor from "./proctor.js"; - getModifierState(key: string): boolean { - return (PhysicalInputEventSpec.modifierCodes[key] & this.modifierSet) != 0; - } +export { default as Proctor } from "./proctor.js"; +export { default as NodeProctor } from "./nodeProctor.js"; - generateModifierString(): string { - var list: string = ""; +import * as utils from "@keymanapp/web-utils"; - for(var key in PhysicalInputEventSpec.modifierCodes) { - if(this.getModifierState(key)) { - list += ((list != "" ? " " : "") + key); - } +//#region Defines the InputEventSpec set, used to reconstruct DOM-based events for browser-based simulation +export abstract class InputEventSpec { + type: "key" | "osk"; + static fromJSONObject(obj: any): InputEventSpec { + if(obj && obj.type) { + if(obj.type == "key") { + return new PhysicalInputEventSpec(obj); + } else if(obj.type == "osk") { + return new OSKInputEventSpec(obj); } - - return list; + } else { + throw new SyntaxError("Error in JSON format corresponding to an InputEventSpec!"); } } - export class OSKInputEventSpec extends InputEventSpec { - type: "osk" = "osk"; - keyID: string; - - // The parameter may be used to reconstruct the item from raw JSON. - constructor(e?: OSKInputEventSpec) { - super(); - if(e) { - this.keyID = e.keyID; - } + toPrettyJSON(): string { + // We want the default, non-spaced JSON for this class, even when otherwise adding whitespace. + var str = JSON.stringify(this); + return str; + } +} + +export class PhysicalInputEventSpec extends InputEventSpec { + static readonly modifierCodes: { [mod:string]: number } = { + "Shift":0x0001, + "Control":0x0002, + "Alt":0x0004, + "Meta":0x0008, + "CapsLock":0x0010, + "NumLock":0x0020, + "ScrollLock":0x0040 + }; + + // KeyboardEvent properties + type: "key" = "key"; + key: string; + code: string; + keyCode: number; + modifierSet: number; + location: number; + + constructor(e?: PhysicalInputEventSpec) { // parameter is used to reconstruct from JSON. + super(); + + if(e) { + this.key = e.key; + this.code = e.code; + this.keyCode = e.keyCode; + this.modifierSet = e.modifierSet; + this.location = e.location; } } - //#endregion - export abstract class RecordedKeystroke { - type: "key" | "osk"; + getModifierState(key: string): boolean { + return (PhysicalInputEventSpec.modifierCodes[key] & this.modifierSet) != 0; + } - static fromJSONObject(obj: any): RecordedKeystroke { - if(obj && obj.type) { - if(obj.type == "key") { - return new RecordedPhysicalKeystroke(obj as RecordedPhysicalKeystroke); - } else if(obj && obj.type) { - return new RecordedSyntheticKeystroke(obj as RecordedSyntheticKeystroke); - } - } else { - throw new SyntaxError("Error in JSON format corresponding to a RecordedKeystroke!"); - } - } + generateModifierString(): string { + var list: string = ""; - toPrettyJSON(): string { - // We want the default, non-spaced JSON for this class, even when otherwise adding whitespace. - var str = JSON.stringify(this); - return str; + for(var key in PhysicalInputEventSpec.modifierCodes) { + if(this.getModifierState(key)) { + list += ((list != "" ? " " : "") + key); + } } - /** - * Returns an InputEventSpec that may be used to simulate the keystroke within a browser-based environment. - */ - abstract get inputEventSpec(): InputEventSpec; + return list; } +} - export class RecordedPhysicalKeystroke extends RecordedKeystroke { - // KeyboardEvent properties - type: "key" = "key"; - - keyCode: number; // may be different from eventSpec's value b/c keymapping - states: number; - modifiers: number; - modifierChanged: boolean; - isVirtualKey: boolean; - vkCode: number; // may be possible to eliminate; differences arise from mnemonics. +export class OSKInputEventSpec extends InputEventSpec { + type: "osk" = "osk"; + keyID: string; - eventSpec: PhysicalInputEventSpec; - - constructor(keystroke: RecordedPhysicalKeystroke) - constructor(keystroke: com.keyman.text.KeyEvent, eventSpec: PhysicalInputEventSpec) - constructor(keystroke: RecordedPhysicalKeystroke|com.keyman.text.KeyEvent, eventSpec?: PhysicalInputEventSpec) { - super(); + // The parameter may be used to reconstruct the item from raw JSON. + constructor(e?: OSKInputEventSpec) { + super(); + if(e) { + this.keyID = e.keyID; + } + } +} +//#endregion - if(keystroke instanceof com.keyman.text.KeyEvent || typeof keystroke.type === 'undefined') { - // Store what is necessary for headless event reconstruction. - keystroke = keystroke as com.keyman.text.KeyEvent; - this.keyCode = keystroke.Lcode; - this.states = keystroke.Lstates; - this.modifiers = keystroke.Lmodifiers; - this.modifierChanged = !!keystroke.LmodifierChange; - this.isVirtualKey = keystroke.LisVirtualKey; - this.vkCode = keystroke.vkCode; +export abstract class RecordedKeystroke { + type: "key" | "osk"; - // Also store the DOM-based event spec for use in integrated testing. - this.eventSpec = eventSpec; - } else { - // It might be a raw object, from JSON. - this.keyCode = keystroke.keyCode; - this.states = keystroke.states; - this.modifiers = keystroke.modifiers; - this.modifierChanged = keystroke.modifierChanged; - this.isVirtualKey = keystroke.isVirtualKey; - this.vkCode = keystroke.vkCode; - - this.eventSpec = new PhysicalInputEventSpec(keystroke.eventSpec); // must also be reconstructed. + static fromJSONObject(obj: any): RecordedKeystroke { + if(obj && obj.type) { + if(obj.type == "key") { + return new RecordedPhysicalKeystroke(obj as RecordedPhysicalKeystroke); + } else if(obj && obj.type) { + return new RecordedSyntheticKeystroke(obj as RecordedSyntheticKeystroke); } + } else { + throw new SyntaxError("Error in JSON format corresponding to a RecordedKeystroke!"); } + } + + toPrettyJSON(): string { + // We want the default, non-spaced JSON for this class, even when otherwise adding whitespace. + var str = JSON.stringify(this); + return str; + } - get inputEventSpec(): InputEventSpec { - return this.eventSpec; + /** + * Returns an InputEventSpec that may be used to simulate the keystroke within a browser-based environment. + */ + abstract get inputEventSpec(): InputEventSpec; +} + +export class RecordedPhysicalKeystroke extends RecordedKeystroke { + // KeyboardEvent properties + type: "key" = "key"; + + keyCode: number; // may be different from eventSpec's value b/c keymapping + states: number; + modifiers: number; + modifierChanged: boolean; + isVirtualKey: boolean; + vkCode: number; // may be possible to eliminate; differences arise from mnemonics. + + eventSpec: PhysicalInputEventSpec; + + constructor(keystroke: RecordedPhysicalKeystroke) + constructor(keystroke: KeyEvent, eventSpec: PhysicalInputEventSpec) + constructor(keystroke: RecordedPhysicalKeystroke|KeyEvent, eventSpec?: PhysicalInputEventSpec) { + super(); + + if(keystroke instanceof KeyEvent || typeof keystroke.type === 'undefined') { + // Store what is necessary for headless event reconstruction. + keystroke = keystroke as KeyEvent; + this.keyCode = keystroke.Lcode; + this.states = keystroke.Lstates; + this.modifiers = keystroke.Lmodifiers; + this.modifierChanged = !!keystroke.LmodifierChange; + this.isVirtualKey = keystroke.LisVirtualKey; + this.vkCode = keystroke.vkCode; + + // Also store the DOM-based event spec for use in integrated testing. + this.eventSpec = eventSpec; + } else { + // It might be a raw object, from JSON. + this.keyCode = keystroke.keyCode; + this.states = keystroke.states; + this.modifiers = keystroke.modifiers; + this.modifierChanged = keystroke.modifierChanged; + this.isVirtualKey = keystroke.isVirtualKey; + this.vkCode = keystroke.vkCode; + + this.eventSpec = new PhysicalInputEventSpec(keystroke.eventSpec); // must also be reconstructed. } } - export class RecordedSyntheticKeystroke extends RecordedKeystroke { - // KeyboardEvent properties - type: "osk" = "osk"; + get inputEventSpec(): InputEventSpec { + return this.eventSpec; + } +} - keyName: string; - layer: string; +export class RecordedSyntheticKeystroke extends RecordedKeystroke { + // KeyboardEvent properties + type: "osk" = "osk"; - keyDistribution?: com.keyman.text.KeyDistribution; + keyName: string; + layer: string; - constructor(keystroke: RecordedSyntheticKeystroke) - constructor(keystroke: com.keyman.text.KeyEvent) - constructor(keystroke: RecordedSyntheticKeystroke|com.keyman.text.KeyEvent) { - super(); + keyDistribution?: KeyDistribution; - if(keystroke instanceof com.keyman.text.KeyEvent || typeof keystroke.type === 'undefined') { - keystroke = keystroke as com.keyman.text.KeyEvent; - // Store what is necessary for headless event reconstruction. + constructor(keystroke: RecordedSyntheticKeystroke) + constructor(keystroke: KeyEvent) + constructor(keystroke: RecordedSyntheticKeystroke|KeyEvent) { + super(); - // Also store the DOM-based event spec for use in integrated testing. - this.layer = keystroke.kbdLayer; - this.keyName = keystroke.kName; - this.keyDistribution = keystroke.keyDistribution; - } else { - // It might be a raw object, from JSON. - this.layer = keystroke.layer; - this.keyName = keystroke.keyName; - this.keyDistribution = keystroke.keyDistribution; - } + if(keystroke instanceof KeyEvent || typeof keystroke.type === 'undefined') { + keystroke = keystroke as KeyEvent; + // Store what is necessary for headless event reconstruction. + + // Also store the DOM-based event spec for use in integrated testing. + this.layer = keystroke.kbdLayer; + this.keyName = keystroke.kName; + this.keyDistribution = keystroke.keyDistribution; + } else { + // It might be a raw object, from JSON. + this.layer = keystroke.layer; + this.keyName = keystroke.keyName; + this.keyDistribution = keystroke.keyDistribution; } + } - get inputEventSpec(): InputEventSpec { - let eventSpec = new OSKInputEventSpec(); - eventSpec.keyID = this.layer + '-' + this.keyName; + get inputEventSpec(): InputEventSpec { + let eventSpec = new OSKInputEventSpec(); + eventSpec.keyID = this.layer + '-' + this.keyName; - return eventSpec; - } + return eventSpec; } +} - export abstract class TestSequence { - inputs: KeyRecord[]; - output: string; - msg?: string; +export abstract class TestSequence { + inputs: KeyRecord[]; + output: string; + msg?: string; - abstract hasOSKInteraction(): boolean; + abstract hasOSKInteraction(): boolean; - test(proctor: Proctor, target?: com.keyman.text.OutputTarget): {success: boolean, result: string} { - // Start with an empty OutputTarget and a fresh KeyboardProcessor. - if(!target) { - target = new com.keyman.text.Mock(); - } + test(proctor: Proctor, target?: OutputTarget): {success: boolean, result: string} { + // Start with an empty OutputTarget and a fresh KeyboardProcessor. + if(!target) { + target = new Mock(); + } - proctor.before(); + proctor.before(); - let result = proctor.simulateSequence(this, target); - proctor.assertEquals(result, this.output, this.msg); + let result = proctor.simulateSequence(this, target); + proctor.assertEquals(result, this.output, this.msg); - return {success: (result == this.output), result: result}; - } + return {success: (result == this.output), result: result}; + } - toPrettyJSON(): string { - var str = "{ "; - if(this.output) { - str += "\"output\": \"" + this.output + "\", "; - } - str += "\"inputs\": [\n"; - for(var i = 0; i < this.inputs.length; i++) { - str += " " + this.inputs[i].toPrettyJSON() + ((i == this.inputs.length-1) ? "\n" : ",\n"); - } - if(this.msg) { - str += "], \"message\": \"" + this.msg + "\" }"; - } else { - str += "]}"; - } - return str; + toPrettyJSON(): string { + var str = "{ "; + if(this.output) { + str += "\"output\": \"" + this.output + "\", "; + } + str += "\"inputs\": [\n"; + for(var i = 0; i < this.inputs.length; i++) { + str += " " + this.inputs[i].toPrettyJSON() + ((i == this.inputs.length-1) ? "\n" : ",\n"); + } + if(this.msg) { + str += "], \"message\": \"" + this.msg + "\" }"; + } else { + str += "]}"; } + return str; } +} - export class InputEventSpecSequence extends TestSequence { - inputs: InputEventSpec[]; - output: string; - msg?: string; +export class InputEventSpecSequence extends TestSequence { + inputs: InputEventSpec[]; + output: string; + msg?: string; - constructor(ins?: InputEventSpec[] | InputEventSpecSequence, outs?: string, msg?: string) { - super(); + constructor(ins?: InputEventSpec[] | InputEventSpecSequence, outs?: string, msg?: string) { + super(); - if(ins) { - if(ins instanceof Array) { - this.inputs = [].concat(ins); - } else { - // We're constructing from existing JSON. - this.inputs = []; - - for(var ie=0; ie < ins.inputs.length; ie++) { - this.inputs.push(InputEventSpec.fromJSONObject(ins.inputs[ie])); - } - - this.output = ins.output; - this.msg = ins.msg; - return; - } + if(ins) { + if(ins instanceof Array) { + this.inputs = [].concat(ins); } else { + // We're constructing from existing JSON. this.inputs = []; - } - if(outs) { - this.output = outs; - } + for(var ie=0; ie < ins.inputs.length; ie++) { + this.inputs.push(InputEventSpec.fromJSONObject(ins.inputs[ie])); + } - if(msg) { - this.msg = msg; + this.output = ins.output; + this.msg = ins.msg; + return; } + } else { + this.inputs = []; } - addInput(event: InputEventSpec, output: string) { - this.inputs.push(event); - this.output = output; + if(outs) { + this.output = outs; } - hasOSKInteraction(): boolean { - for(var i=0; i < this.inputs.length; i++) { - if(this.inputs[i] instanceof OSKInputEventSpec) { - return true; - } - } - - return false; + if(msg) { + this.msg = msg; } } - export class RecordedKeystrokeSequence extends TestSequence { - inputs: RecordedKeystroke[]; - output: string; - msg?: string; + addInput(event: InputEventSpec, output: string) { + this.inputs.push(event); + this.output = output; + } - constructor(ins?: RecordedKeystroke[], outs?: string, msg?: string) - constructor(sequence: RecordedKeystrokeSequence) - constructor(ins?: RecordedKeystroke[] | RecordedKeystrokeSequence, outs?: string, msg?: string) { - super(); + hasOSKInteraction(): boolean { + for(var i=0; i < this.inputs.length; i++) { + if(this.inputs[i] instanceof OSKInputEventSpec) { + return true; + } + } - if(ins) { - if(ins instanceof Array) { - this.inputs = [].concat(ins); - } else { - // We're constructing from existing JSON. - this.inputs = []; + return false; + } +} - for(var ie=0; ie < ins.inputs.length; ie++) { - this.inputs.push(RecordedKeystroke.fromJSONObject(ins.inputs[ie])); - } +export class RecordedKeystrokeSequence extends TestSequence { + inputs: RecordedKeystroke[]; + output: string; + msg?: string; - this.output = ins.output; - this.msg = ins.msg; - return; - } + constructor(ins?: RecordedKeystroke[], outs?: string, msg?: string) + constructor(sequence: RecordedKeystrokeSequence) + constructor(ins?: RecordedKeystroke[] | RecordedKeystrokeSequence, outs?: string, msg?: string) { + super(); + + if(ins) { + if(ins instanceof Array) { + this.inputs = [].concat(ins); } else { + // We're constructing from existing JSON. this.inputs = []; - } - if(outs) { - this.output = outs; - } + for(var ie=0; ie < ins.inputs.length; ie++) { + this.inputs.push(RecordedKeystroke.fromJSONObject(ins.inputs[ie])); + } - if(msg) { - this.msg = msg; + this.output = ins.output; + this.msg = ins.msg; + return; } + } else { + this.inputs = []; } - addInput(event: RecordedKeystroke, output: string) { - this.inputs.push(event); - this.output = output; + if(outs) { + this.output = outs; } - hasOSKInteraction(): boolean { - for(var i=0; i < this.inputs.length; i++) { - if(this.inputs[i] instanceof RecordedSyntheticKeystroke) { - return true; - } - } - - return false; + if(msg) { + this.msg = msg; } } - class FontStubForLanguage { - family: string; - source: string[]; - - constructor(activeStubEntry: any) { - this.family = activeStubEntry.family; - - var src = activeStubEntry.files; - if(!(src instanceof Array)) { - src = [ src ]; - } + addInput(event: RecordedKeystroke, output: string) { + this.inputs.push(event); + this.output = output; + } - this.source = []; - for(var i=0; i < src.length; i++) { - this.source.push(activeStubEntry.path + src[i]); + hasOSKInteraction(): boolean { + for(var i=0; i < this.inputs.length; i++) { + if(this.inputs[i] instanceof RecordedSyntheticKeystroke) { + return true; } } + + return false; } +} - export class LanguageStubForKeyboard { - id: string; - name: string; - region: string; - font?: FontStubForLanguage; - oskFont?: FontStubForLanguage; +class FontStubForLanguage { + family: string; + source: string[]; - constructor(activeStub: any) { - if(activeStub.KLC) { - this.id = activeStub.KLC; - this.name = activeStub.KL; - this.region = activeStub.KR; + constructor(activeStubEntry: any) { + this.family = activeStubEntry.family; - // Fonts. - if(activeStub.KFont) { - this.font = new FontStubForLanguage(activeStub.KFont); - } - if(activeStub.KOskFont) { - this.oskFont = new FontStubForLanguage(activeStub.KOskFont); - } - } else { - this.id = activeStub.id; - this.name = activeStub.name; - this.region = activeStub.region; + var src = activeStubEntry.files; + if(!(src instanceof Array)) { + src = [ src ]; + } - // If we end up adding functionality to FontStubForLanguage, we'll need to properly reconstruct these. - this.font = activeStub.font; - this.oskFont = activeStub.oskFont; - } + this.source = []; + for(var i=0; i < src.length; i++) { + this.source.push(activeStubEntry.path + src[i]); } } +} - export class KeyboardStub { - id: string; - name: string; - filename: string; - languages: LanguageStubForKeyboard | LanguageStubForKeyboard[]; - - // Constructs a stub usable with KeymanWeb's addKeyboards() API function from - // the internally-tracked ActiveStub value for that keyboard. - constructor(json?: KeyboardStub) { - if(json) { - this.id = json.id; - this.name = json.name; - this.filename = json.filename; - - if(!Array.isArray(json.languages)) { - this.languages = new LanguageStubForKeyboard(json.languages); - } else { - this.languages = []; - for(var i=0; i < json.languages.length; i++) { - this.languages.push(new LanguageStubForKeyboard(json.languages[i])); - } - } +export class LanguageStubForKeyboard { + id: string; + name: string; + region: string; + font?: FontStubForLanguage; + oskFont?: FontStubForLanguage; + + constructor(activeStub: any) { + if(activeStub.KLC) { + this.id = activeStub.KLC; + this.name = activeStub.KL; + this.region = activeStub.KR; + + // Fonts. + if(activeStub.KFont) { + this.font = new FontStubForLanguage(activeStub.KFont); } - } + if(activeStub.KOskFont) { + this.oskFont = new FontStubForLanguage(activeStub.KOskFont); + } + } else { + this.id = activeStub.id; + this.name = activeStub.name; + this.region = activeStub.region; - getFirstLanguage() { - if(this.languages instanceof LanguageStubForKeyboard) { - return this.languages.id; + // If we end up adding functionality to FontStubForLanguage, we'll need to properly reconstruct these. + this.font = activeStub.font; + this.oskFont = activeStub.oskFont; + } + } +} + +export class KeyboardStub { + id: string; + name: string; + filename: string; + languages: LanguageStubForKeyboard | LanguageStubForKeyboard[]; + + // Constructs a stub usable with KeymanWeb's addKeyboards() API function from + // the internally-tracked ActiveStub value for that keyboard. + constructor(json?: KeyboardStub) { + if(json) { + this.id = json.id; + this.name = json.name; + this.filename = json.filename; + + if(!Array.isArray(json.languages)) { + this.languages = new LanguageStubForKeyboard(json.languages); } else { - return this.languages[0].id; + this.languages = []; + for(var i=0; i < json.languages.length; i++) { + this.languages.push(new LanguageStubForKeyboard(json.languages[i])); + } } } } - type TARGET = 'hardware'|'desktop'|'phone'|'tablet'; - type OS = 'windows'|'android'|'ios'|'macosx'|'linux'; - type BROWSER = 'chrome'|'firefox'|'safari'|'opera'; // ! no 'edge' detection in KMW! - - export class Constraint { - target: TARGET; - validOSList?: OS[]; - validBrowsers?: BROWSER[]; - - constructor(target: TARGET|Constraint, validOSList?: OS[], validBrowsers?: BROWSER[]) { - if(typeof(target) == 'string') { - this.target = target; - this.validOSList = validOSList; - this.validBrowsers = validBrowsers; - } else { - var json = target; - this.target = json.target; - this.validOSList = json.validOSList; - this.validBrowsers = json.validBrowsers; - } + getFirstLanguage() { + if(this.languages instanceof LanguageStubForKeyboard) { + return this.languages.id; + } else { + return this.languages[0].id; + } + } +} + +type TARGET = 'hardware'|'desktop'|'phone'|'tablet'; +type OS = 'windows'|'android'|'ios'|'macosx'|'linux'; +type BROWSER = 'chrome'|'firefox'|'safari'|'opera'; // ! no 'edge' detection in KMW! + +export class Constraint { + target: TARGET; + validOSList?: OS[]; + validBrowsers?: BROWSER[]; + + constructor(target: TARGET|Constraint, validOSList?: OS[], validBrowsers?: BROWSER[]) { + if(typeof(target) == 'string') { + this.target = target; + this.validOSList = validOSList; + this.validBrowsers = validBrowsers; + } else { + var json = target; + this.target = json.target; + this.validOSList = json.validOSList; + this.validBrowsers = json.validBrowsers; } + } - matchesClient(device: com.keyman.utils.DeviceSpec, usingOSK?: boolean) { - // #1: Platform check. - if(usingOSK === true) { - if(this.target != device.formFactor) { - return false; - } - } else if(usingOSK === false) { - if(this.target != 'hardware') { - return false; - } - } else if(this.target != device.formFactor && this.target != 'hardware') { + matchesClient(device: utils.DeviceSpec, usingOSK?: boolean) { + // #1: Platform check. + if(usingOSK === true) { + if(this.target != device.formFactor) { return false; } - - if(this.validOSList) { - if(this.validOSList.indexOf(device.OS as OS) == -1) { - return false; - } - } - - if(this.validBrowsers) { - if(this.validBrowsers.indexOf(device.browser as BROWSER) == -1) { - return false; - } + } else if(usingOSK === false) { + if(this.target != 'hardware') { + return false; } - - return true; + } else if(this.target != device.formFactor && this.target != 'hardware') { + return false; } - // Checks if another Constraint instance is functionally identical to this one. - equals(other: Constraint) { - if(this.target != other.target) { + if(this.validOSList) { + if(this.validOSList.indexOf(device.OS as OS) == -1) { return false; } + } - var list1 = this.validOSList ? this.validOSList : ['any']; - var list2 = other.validOSList ? other.validOSList : ['any']; - - if(list1.sort().join(',') != list2.sort().join(',')) { + if(this.validBrowsers) { + if(this.validBrowsers.indexOf(device.browser as BROWSER) == -1) { return false; } + } - list1 = this.validBrowsers ? this.validBrowsers : ['web']; - list2 = other.validBrowsers ? other.validBrowsers : ['web']; + return true; + } - if(list1.sort().join(',') != list2.sort().join(',')) { - return false; - } + // Checks if another Constraint instance is functionally identical to this one. + equals(other: Constraint) { + if(this.target != other.target) { + return false; + } - return true; + var list1 = this.validOSList ? this.validOSList : ['any']; + var list2 = other.validOSList ? other.validOSList : ['any']; + + if(list1.sort().join(',') != list2.sort().join(',')) { + return false; } - } - export class TestFailure { - constraint: Constraint; - test: InputEventSpecSequence; - result: string; + list1 = this.validBrowsers ? this.validBrowsers : ['web']; + list2 = other.validBrowsers ? other.validBrowsers : ['web']; - constructor(constraint: Constraint, test: InputEventSpecSequence, output: string) { - this.constraint = constraint; - this.test = test; - this.result = output; + if(list1.sort().join(',') != list2.sort().join(',')) { + return false; } + + return true; } +} - export interface TestSet> { - constraint: Constraint; +export class TestFailure { + constraint: Constraint; + test: InputEventSpecSequence; + result: string; - addTest(seq: Sequence): void; - isValidForDevice(device: com.keyman.utils.DeviceSpec, usingOSK?: boolean): boolean; - test(proctor: Proctor): TestFailure[]; + constructor(constraint: Constraint, test: InputEventSpecSequence, output: string) { + this.constraint = constraint; + this.test = test; + this.result = output; } +} - /** - * The core constraint-specific test set definition used for testing versions 10.0 to 13.0. - */ - export class EventSpecTestSet implements TestSet { - constraint: Constraint; - testSet: InputEventSpecSequence[]; - - constructor(constraint: Constraint|EventSpecTestSet) { - if("target" in constraint) { - this.constraint = constraint as Constraint; - this.testSet = []; - } else { - var json = constraint as EventSpecTestSet; - this.constraint = new Constraint(json.constraint); - this.testSet = []; +export interface TestSet> { + constraint: Constraint; - // Clone each test sequence / reconstruct from methodless JSON object. - for(var i=0; i < json.testSet.length; i++) { - this.testSet.push(new InputEventSpecSequence(json.testSet[i])); - } + addTest(seq: Sequence): void; + isValidForDevice(device: utils.DeviceSpec, usingOSK?: boolean): boolean; + test(proctor: Proctor): TestFailure[]; +} + +/** + * The core constraint-specific test set definition used for testing versions 10.0 to 13.0. + */ +export class EventSpecTestSet implements TestSet { + constraint: Constraint; + testSet: InputEventSpecSequence[]; + + constructor(constraint: Constraint|EventSpecTestSet) { + if("target" in constraint) { + this.constraint = constraint as Constraint; + this.testSet = []; + } else { + var json = constraint as EventSpecTestSet; + this.constraint = new Constraint(json.constraint); + this.testSet = []; + + // Clone each test sequence / reconstruct from methodless JSON object. + for(var i=0; i < json.testSet.length; i++) { + this.testSet.push(new InputEventSpecSequence(json.testSet[i])); } } + } - addTest(seq: InputEventSpecSequence) { - this.testSet.push(seq); - } + addTest(seq: InputEventSpecSequence) { + this.testSet.push(seq); + } - // Used to determine if the current EventSpecTestSet is applicable to be run on a device. - isValidForDevice(device: com.keyman.utils.DeviceSpec, usingOSK?: boolean) { - return this.constraint.matchesClient(device, usingOSK); - } + // Used to determine if the current EventSpecTestSet is applicable to be run on a device. + isValidForDevice(device: utils.DeviceSpec, usingOSK?: boolean) { + return this.constraint.matchesClient(device, usingOSK); + } - // Validity should be checked before calling this method. - test(proctor: Proctor): TestFailure[] { - var failures: TestFailure[] = []; - let testSet = this.testSet; + // Validity should be checked before calling this method. + test(proctor: Proctor): TestFailure[] { + var failures: TestFailure[] = []; + let testSet = this.testSet; - for(var i=0; i < testSet.length; i++) { - var testSeq = this[i]; - var simResult = testSet[i].test(proctor); - if(!simResult.success) { - // Failed test! - failures.push(new TestFailure(this.constraint, testSeq, simResult.result)); - } + for(var i=0; i < testSet.length; i++) { + var testSeq = this[i]; + var simResult = testSet[i].test(proctor); + if(!simResult.success) { + // Failed test! + failures.push(new TestFailure(this.constraint, testSeq, simResult.result)); } - - return failures.length > 0 ? failures : null; } + + return failures.length > 0 ? failures : null; } +} - /** - * The core constraint-specific test set definition used for testing versions 10.0 to 13.0. - */ - export class RecordedSequenceTestSet implements TestSet { - constraint: Constraint; - testSet: RecordedKeystrokeSequence[]; - - constructor(constraint: Constraint|RecordedSequenceTestSet) { - if("target" in constraint) { - this.constraint = constraint as Constraint; - this.testSet = []; - } else { - var json = constraint as RecordedSequenceTestSet; - this.constraint = new Constraint(json.constraint); - this.testSet = []; +/** + * The core constraint-specific test set definition used for testing versions 10.0 to 13.0. + */ +export class RecordedSequenceTestSet implements TestSet { + constraint: Constraint; + testSet: RecordedKeystrokeSequence[]; - // Clone each test sequence / reconstruct from methodless JSON object. - for(var i=0; i < json.testSet.length; i++) { - this.testSet.push(new RecordedKeystrokeSequence(json.testSet[i])); - } + constructor(constraint: Constraint|RecordedSequenceTestSet) { + if("target" in constraint) { + this.constraint = constraint as Constraint; + this.testSet = []; + } else { + var json = constraint as RecordedSequenceTestSet; + this.constraint = new Constraint(json.constraint); + this.testSet = []; + + // Clone each test sequence / reconstruct from methodless JSON object. + for(var i=0; i < json.testSet.length; i++) { + this.testSet.push(new RecordedKeystrokeSequence(json.testSet[i])); } } + } - addTest(seq: RecordedKeystrokeSequence) { - this.testSet.push(seq); - } + addTest(seq: RecordedKeystrokeSequence) { + this.testSet.push(seq); + } - // Used to determine if the current EventSpecTestSet is applicable to be run on a device. - isValidForDevice(device: com.keyman.utils.DeviceSpec, usingOSK?: boolean) { - return this.constraint.matchesClient(device, usingOSK); - } + // Used to determine if the current EventSpecTestSet is applicable to be run on a device. + isValidForDevice(device: utils.DeviceSpec, usingOSK?: boolean) { + return this.constraint.matchesClient(device, usingOSK); + } - // Validity should be checked before calling this method. - test(proctor: Proctor): TestFailure[] { - var failures: TestFailure[] = []; - let testSet = this.testSet; + // Validity should be checked before calling this method. + test(proctor: Proctor): TestFailure[] { + var failures: TestFailure[] = []; + let testSet = this.testSet; - for(var i=0; i < testSet.length; i++) { - var testSeq = this[i]; - var simResult = testSet[i].test(proctor); - if(!simResult.success) { - // Failed test! - failures.push(new TestFailure(this.constraint, testSeq, simResult.result)); - } + for(var i=0; i < testSet.length; i++) { + var testSeq = this[i]; + var simResult = testSet[i].test(proctor); + if(!simResult.success) { + // Failed test! + failures.push(new TestFailure(this.constraint, testSeq, simResult.result)); } + } + + return failures.length > 0 ? failures : null; + } - return failures.length > 0 ? failures : null; + toTestName(): string { + let name = "constraint: for " + this.constraint.target; + + if(this.constraint.target == 'hardware') { + name += " keyboard"; + } else { + name += " OSK"; + } + if(this.constraint.validOSList) { + name += " on OS of " + JSON.stringify(this.constraint.validOSList); + } + if(this.constraint.validBrowsers) { + name += " in browser of " + JSON.stringify(this.constraint.validBrowsers); } - toTestName(): string { - let name = "constraint: for " + this.constraint.target; + return name; + } +} - if(this.constraint.target == 'hardware') { - name += " keyboard"; - } else { - name += " OSK"; - } - if(this.constraint.validOSList) { - name += " on OS of " + JSON.stringify(this.constraint.validOSList); - } - if(this.constraint.validBrowsers) { - name += " in browser of " + JSON.stringify(this.constraint.validBrowsers); - } +export class KeyboardTest { + /** + * Indicates what version of KMW's recorder the spec conforms to. + */ + public specVersion: utils.Version = KeyboardTest.CURRENT_VERSION; - return name; - } - } - - export class KeyboardTest { - /** - * Indicates what version of KMW's recorder the spec conforms to. - */ - public specVersion: com.keyman.utils.Version = KeyboardTest.CURRENT_VERSION; - - /** - * The version of KMW in which the Recorder was first written. Worked from 10.0 to 13.0 with - * only backward-compatible changes and minor tweaks to conform to internal API shifts. - */ - public static readonly FALLBACK_VERSION = new com.keyman.utils.Version("10.0"); - public static readonly CURRENT_VERSION = new com.keyman.utils.Version("14.0"); - - /** - * The stub information to be passed into keyman.addKeyboards() in order to run the test. - */ - keyboard: KeyboardStub; - - /** - * The master array of test sets, each of which specifies constraints a client must fulfill for - * the tests contained therein to be valid. - */ - inputTestSets: TestSet[]; - - /** - * Reconstructs a KeyboardTest object from its JSON representation, restoring its methods. - * @param fromJSON - */ - constructor(fromJSON?: string|KeyboardStub|KeyboardTest) { - if(!fromJSON) { - this.keyboard = null; - this.inputTestSets = []; - return; - } else if(typeof(fromJSON) == 'string') { - fromJSON = JSON.parse(fromJSON) as KeyboardTest; - } else if(fromJSON instanceof KeyboardStub) { - this.keyboard = fromJSON; - this.inputTestSets = []; - return; - } + /** + * The version of KMW in which the Recorder was first written. Worked from 10.0 to 13.0 with + * only backward-compatible changes and minor tweaks to conform to internal API shifts. + */ + public static readonly FALLBACK_VERSION = new utils.Version("10.0"); + public static readonly CURRENT_VERSION = new utils.Version("14.0"); - if(!fromJSON.specVersion) { - fromJSON.specVersion = KeyboardTest.FALLBACK_VERSION; - } else { - // Is serialized to a String when saved. - fromJSON.specVersion = new com.keyman.utils.Version(fromJSON.specVersion as unknown as string); - } + /** + * The stub information to be passed into keyman.addKeyboards() in order to run the test. + */ + keyboard: KeyboardStub; + + /** + * The master array of test sets, each of which specifies constraints a client must fulfill for + * the tests contained therein to be valid. + */ + inputTestSets: TestSet[]; - this.keyboard = new KeyboardStub(fromJSON.keyboard); + /** + * Reconstructs a KeyboardTest object from its JSON representation, restoring its methods. + * @param fromJSON + */ + constructor(fromJSON?: string|KeyboardStub|KeyboardTest) { + if(!fromJSON) { + this.keyboard = null; + this.inputTestSets = []; + return; + } else if(typeof(fromJSON) == 'string') { + fromJSON = JSON.parse(fromJSON) as KeyboardTest; + } else if(fromJSON instanceof KeyboardStub) { + this.keyboard = fromJSON; this.inputTestSets = []; - this.specVersion = fromJSON.specVersion; + return; + } - if(this.specVersion.equals(KeyboardTest.FALLBACK_VERSION)) { - // Top-level test spec: EventSpecTestSet, based entirely on browser events. - for(var i=0; i < fromJSON.inputTestSets.length; i++) { - this.inputTestSets[i] = new EventSpecTestSet(fromJSON.inputTestSets[i] as EventSpecTestSet); - } - } else { - for(var i=0; i < fromJSON.inputTestSets.length; i++) { - this.inputTestSets[i] = new RecordedSequenceTestSet(fromJSON.inputTestSets[i] as RecordedSequenceTestSet); - } - } + if(!fromJSON.specVersion) { + fromJSON.specVersion = KeyboardTest.FALLBACK_VERSION; + } else { + // Is serialized to a String when saved. + fromJSON.specVersion = new utils.Version(fromJSON.specVersion as unknown as string); } - addTest(constraint: Constraint, seq: RecordedKeystrokeSequence) { - if(!this.specVersion.equals(KeyboardTest.CURRENT_VERSION)) { - throw new Error("The currently-loaded test was built to an outdated specification and may not be altered."); - } + this.keyboard = new KeyboardStub(fromJSON.keyboard); + this.inputTestSets = []; + this.specVersion = fromJSON.specVersion; - for(var i=0; i < this.inputTestSets.length; i++) { - if(this.inputTestSets[i].constraint.equals(constraint)) { - this.inputTestSets[i].addTest(seq); - return; - } + if(this.specVersion.equals(KeyboardTest.FALLBACK_VERSION)) { + // Top-level test spec: EventSpecTestSet, based entirely on browser events. + for(var i=0; i < fromJSON.inputTestSets.length; i++) { + this.inputTestSets[i] = new EventSpecTestSet(fromJSON.inputTestSets[i] as EventSpecTestSet); + } + } else { + for(var i=0; i < fromJSON.inputTestSets.length; i++) { + this.inputTestSets[i] = new RecordedSequenceTestSet(fromJSON.inputTestSets[i] as RecordedSequenceTestSet); } + } + } - var newSet = new RecordedSequenceTestSet(new Constraint(constraint)); - this.inputTestSets.push(newSet); - newSet.addTest(seq); + addTest(constraint: Constraint, seq: RecordedKeystrokeSequence) { + if(!this.specVersion.equals(KeyboardTest.CURRENT_VERSION)) { + throw new Error("The currently-loaded test was built to an outdated specification and may not be altered."); } - test(proctor: Proctor) { - var setHasRun = false; - var failures: TestFailure[] = []; + for(var i=0; i < this.inputTestSets.length; i++) { + if(this.inputTestSets[i].constraint.equals(constraint)) { + this.inputTestSets[i].addTest(seq); + return; + } + } - proctor.beforeAll(); + var newSet = new RecordedSequenceTestSet(new Constraint(constraint)); + this.inputTestSets.push(newSet); + newSet.addTest(seq); + } - // The original test spec requires a browser environment and thus requires its own `.run` implementation. - if(!(proctor.compatibleWithSuite(this))) { - throw Error("Cannot perform version " + KeyboardTest.FALLBACK_VERSION + "-based testing outside of browser-based environments."); - } + test(proctor: Proctor) { + var setHasRun = false; + var failures: TestFailure[] = []; - // Otherwise, the test spec instances will know how to run in any currently-supported environment. - for(var i = 0; i < this.inputTestSets.length; i++) { - var testSet = this.inputTestSets[i]; + proctor.beforeAll(); - if(proctor.matchesTestSet(testSet)) { - var testFailures = testSet.test(proctor); - if(testFailures) { - failures = failures.concat(testFailures); - } - setHasRun = true; - } - } + // The original test spec requires a browser environment and thus requires its own `.run` implementation. + if(!(proctor.compatibleWithSuite(this))) { + throw Error("Cannot perform version " + KeyboardTest.FALLBACK_VERSION + "-based testing outside of browser-based environments."); + } - if(!setHasRun) { - // The sets CAN be empty, allowing silent failure if/when we actually want that. - console.warn("No test sets for this keyboard were applicable for this device!"); - } + // Otherwise, the test spec instances will know how to run in any currently-supported environment. + for(var i = 0; i < this.inputTestSets.length; i++) { + var testSet = this.inputTestSets[i]; - // Allow the method's caller to trigger a 'fail'. - if(failures.length > 0) { - return failures; - } else { - return null; + if(proctor.matchesTestSet(testSet)) { + var testFailures = testSet.test(proctor); + if(testFailures) { + failures = failures.concat(testFailures); + } + setHasRun = true; } } - isEmpty() { - return this.inputTestSets.length == 0; + if(!setHasRun) { + // The sets CAN be empty, allowing silent failure if/when we actually want that. + console.warn("No test sets for this keyboard were applicable for this device!"); } - toPrettyJSON() { - return JSON.stringify(this, null, ' '); + // Allow the method's caller to trigger a 'fail'. + if(failures.length > 0) { + return failures; + } else { + return null; } + } - get isLegacy(): boolean { - return !this.specVersion.equals(KeyboardTest.CURRENT_VERSION); - } + isEmpty() { + return this.inputTestSets.length == 0; + } + + toPrettyJSON() { + return JSON.stringify(this, null, ' '); + } + + get isLegacy(): boolean { + return !this.specVersion.equals(KeyboardTest.CURRENT_VERSION); } } \ No newline at end of file diff --git a/common/web/recorder/src/nodeProctor.ts b/common/web/recorder/src/nodeProctor.ts index 0ae4b5adcc..5548905e6e 100644 --- a/common/web/recorder/src/nodeProctor.ts +++ b/common/web/recorder/src/nodeProctor.ts @@ -1,94 +1,101 @@ +import Proctor, { AssertCallback } from "./proctor.js"; +import { + KeyboardTest, + TestSet, + TestSequence, + RecordedKeystrokeSequence, + RecordedPhysicalKeystroke, + RecordedSyntheticKeystroke +} from "./index.js"; + +import { KeyboardInterface, KeyEvent, KeyEventSpec, KeyboardProcessor, Mock, type OutputTarget, KeyboardHarness } from "@keymanapp/keyboard-processor"; +import { DeviceSpec } from "@keymanapp/web-utils"; + +export default class NodeProctor extends Proctor { + private keyboardWithHarness: KeyboardHarness; + public __debug = false; + + constructor(kbdHarness: KeyboardHarness, device: DeviceSpec, assert: AssertCallback) { + super(device, assert); + + this.keyboardWithHarness = kbdHarness; + } -namespace KMWRecorder { - export class NodeProctor extends Proctor { - private keyboard: com.keyman.keyboards.Keyboard; - public __debug = false; - - constructor(keyboard: com.keyman.keyboards.Keyboard, device: com.keyman.utils.DeviceSpec, assert: AssertCallback) { - super(device, assert); - - this.keyboard = keyboard; - } + beforeAll() { + // + } - beforeAll() { - // - } + before() { + // + } - before() { - // - } + compatibleWithSuite(testSuite: KeyboardTest): boolean { + // Original-version tests did not supply core-compatible KeyEvent data. + return !testSuite.specVersion.equals(KeyboardTest.FALLBACK_VERSION); + } - compatibleWithSuite(testSuite: KeyboardTest): boolean { - // Original-version tests did not supply core-compatible KeyEvent data. - return !testSuite.specVersion.equals(KeyboardTest.FALLBACK_VERSION); - } + get debugMode(): boolean { + return this.__debug; + } - get debugMode(): boolean { - return this.__debug; - } + set debugMode(value: boolean) { + this.__debug = value; + } - set debugMode(value: boolean) { - this.__debug = value; - } + matchesTestSet(testSet: TestSet) { + // KeyboardProcessor is abstract enough to run tests aimed at any platform. + return true; + } - matchesTestSet(testSet: TestSet) { - // KeyboardProcessor is abstract enough to run tests aimed at any platform. - return true; + simulateSequence(sequence: TestSequence, target?: OutputTarget): string { + // Start with an empty OutputTarget and a fresh KeyboardProcessor. + if(!target) { + target = new Mock(); } - simulateSequence(sequence: TestSequence, target?: com.keyman.text.OutputTarget): string { - // Start with an empty OutputTarget and a fresh KeyboardProcessor. - if(!target) { - target = new com.keyman.text.Mock(); - } - - // Establish a fresh processor, setting its keyboard appropriately for the test. - let processor = new com.keyman.text.KeyboardProcessor(this.device); - processor.activeKeyboard = this.keyboard; - - if(sequence instanceof RecordedKeystrokeSequence) { - for(let keystroke of sequence.inputs) { - let keyEvent: com.keyman.text.KeyEvent; - if(keystroke instanceof RecordedPhysicalKeystroke) { - // Use the keystroke's stored data to reconstruct the KeyEvent. - keyEvent = { - Lcode: keystroke.keyCode, - Lmodifiers: keystroke.modifiers, - LmodifierChange: keystroke.modifierChanged, - vkCode: keystroke.vkCode, - Lstates: keystroke.states, - kName: '', - device: this.device, - isSynthetic: false, - LisVirtualKey: this.keyboard.definesPositionalOrMnemonic // Only false for 1.0 keyboards. - } - } else if(keystroke instanceof RecordedSyntheticKeystroke) { - let key = this.keyboard.layout(this.device.formFactor).getLayer(keystroke.layer).getKey(keystroke.keyName); - keyEvent = key.constructKeyEvent(processor, this.device); + // Establish a fresh processor, setting its keyboard appropriately for the test. + let processor = new KeyboardProcessor(this.device); + processor.keyboardInterface = this.keyboardWithHarness as KeyboardInterface; + const keyboard = processor.activeKeyboard; + + if(sequence instanceof RecordedKeystrokeSequence) { + for(let keystroke of sequence.inputs) { + let keyEvent: KeyEventSpec; + if(keystroke instanceof RecordedPhysicalKeystroke) { + // Use the keystroke's stored data to reconstruct the KeyEvent. + keyEvent = { + Lcode: keystroke.keyCode, + Lmodifiers: keystroke.modifiers, + LmodifierChange: keystroke.modifierChanged, + vkCode: keystroke.vkCode, + Lstates: keystroke.states, + kName: '', + device: this.device, + isSynthetic: false, + LisVirtualKey: keyboard.definesPositionalOrMnemonic // Only false for 1.0 keyboards. } + } else if(keystroke instanceof RecordedSyntheticKeystroke) { + let key = keyboard.layout(this.device.formFactor).getLayer(keystroke.layer).getKey(keystroke.keyName); + keyEvent = keyboard.constructKeyEvent(key, this.device, processor.stateKeys); + } - // Fill in the final details of the KeyEvent... - keyEvent.device = this.device; + // Fill in the final details of the KeyEvent... + keyEvent.device = this.device; - // And now, execute the keystroke! - // We don't care too much about particularities of per-keystroke behavior yet. - // ... we _could_ if we wanted to, though. The framework is mostly in place; - // it's a matter of actually adding the feature. - let ruleBehavior = processor.processKeystroke(keyEvent, target); + // And now, execute the keystroke! + // We don't care too much about particularities of per-keystroke behavior yet. + // ... we _could_ if we wanted to, though. The framework is mostly in place; + // it's a matter of actually adding the feature. + let ruleBehavior = processor.processKeystroke(new KeyEvent(keyEvent), target); - if(this.debugMode) { - console.log(JSON.stringify(target, null, ' ')); - console.log(JSON.stringify(ruleBehavior, null, ' ')); - } + if(this.debugMode) { + console.log(JSON.stringify(target, null, ' ')); + console.log(JSON.stringify(ruleBehavior, null, ' ')); } - } else { - throw new Error("NodeProctor only supports RecordedKeystrokeSequences for testing at present."); } - return target.getText(); + } else { + throw new Error("NodeProctor only supports RecordedKeystrokeSequences for testing at present."); } - + return target.getText(); } -} - -// Export the namespace itself, giving access to all contained classes. -module.exports = KMWRecorder; \ No newline at end of file +} \ No newline at end of file diff --git a/common/web/recorder/src/nodeProctor.tsconfig.json b/common/web/recorder/src/nodeProctor.tsconfig.json deleted file mode 100644 index 5b80993dc6..0000000000 --- a/common/web/recorder/src/nodeProctor.tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "extends": "../../../../tsconfig-base.json", - - "compilerOptions": { - "allowJs": true, - "module": "none", - "outDir": "../build/nodeProctor/", - "outFile": "../build/nodeProctor/index.js", - "inlineSources": true, - "inlineSourceMap": true, - "target": "es5", - "types": ["node"], - "lib": ["es6"] - }, - - "files": [ - "index.ts", - "proctor.ts", - "nodeProctor.ts" - ], - - "references": [ - { "path": "../../keyman-version" }, - { "path": "../../utils" }, - { "path": "../../keyboard-processor/src" }, - { "path": "../../lm-message-types" } - ] -} diff --git a/common/web/recorder/src/proctor.ts b/common/web/recorder/src/proctor.ts index 38ffd9997a..50b07032b6 100644 --- a/common/web/recorder/src/proctor.ts +++ b/common/web/recorder/src/proctor.ts @@ -1,50 +1,53 @@ -namespace KMWRecorder { - export type AssertCallback = (s1: any, s2: any, msg?: string) => void; +import { type DeviceSpec } from "@keymanapp/web-utils"; +import { type OutputTarget } from "@keymanapp/keyboard-processor"; - /** - * Facilitates running Recorder-generated tests on various platforms. - * - * Note that DOM-aware KeymanWeb will implement a Browser-based version, while - * keyboard-processor and input-processor will use a Node-based version instead. - */ - export abstract class Proctor { - device: com.keyman.utils.DeviceSpec; +import type { KeyboardTest, TestSet, TestSequence } from "./index.js"; - _assert: AssertCallback; +export type AssertCallback = (s1: any, s2: any, msg?: string) => void; - constructor(device: com.keyman.utils.DeviceSpec, assert: AssertCallback) { - this.device = device; +/** + * Facilitates running Recorder-generated tests on various platforms. + * + * Note that DOM-aware KeymanWeb will implement a Browser-based version, while + * keyboard-processor and input-processor will use a Node-based version instead. + */ +export default abstract class Proctor { + device: DeviceSpec; - this._assert = assert; - } + _assert: AssertCallback; + + constructor(device: DeviceSpec, assert: AssertCallback) { + this.device = device; - assertEquals(s1: unknown, s2: unknown, msg?: string) { - if(this._assert) { - this._assert(s1, s2, msg); - } + this._assert = assert; + } + + assertEquals(s1: unknown, s2: unknown, msg?: string) { + if(this._assert) { + this._assert(s1, s2, msg); } + } - // Performs global test prep. - abstract beforeAll(); + // Performs global test prep. + abstract beforeAll(); - // Performs per-test setup - abstract before(); + // Performs per-test setup + abstract before(); - /** - * Allows the proctor to indicate if is capable of executing a suite of tests or not. - * @param testSuite - */ - abstract compatibleWithSuite(testSuite: KeyboardTest): boolean; + /** + * Allows the proctor to indicate if is capable of executing a suite of tests or not. + * @param testSuite + */ + abstract compatibleWithSuite(testSuite: KeyboardTest): boolean; - /** - * Indicates whether or not this Proctor is capable of running the specified set of tests. - */ - abstract matchesTestSet(testSet: TestSet); + /** + * Indicates whether or not this Proctor is capable of running the specified set of tests. + */ + abstract matchesTestSet(testSet: TestSet); - /** - * Simulates the specified test sequence for use in testing. - * @param sequence The recorded sequence, generally provided by a test set. - */ - abstract simulateSequence(sequence: TestSequence, target?: com.keyman.text.OutputTarget); - } + /** + * Simulates the specified test sequence for use in testing. + * @param sequence The recorded sequence, generally provided by a test set. + */ + abstract simulateSequence(sequence: TestSequence, target?: OutputTarget); } \ No newline at end of file diff --git a/common/web/recorder/src/tsconfig.json b/common/web/recorder/src/tsconfig.json deleted file mode 100644 index 00cb00a482..0000000000 --- a/common/web/recorder/src/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "extends": "../../../../tsconfig-base.json", - - "compilerOptions": { - "allowJs": true, - "module": "none", - "outDir": "../build/", - "inlineSources": true, - "inlineSourceMap": true, - "target": "es5", - "types": ["node"], - "lib": ["es6"], - "outFile": "../build/index.js" - }, - - "files": [ - "index.ts", - "proctor.ts" - ], - - "references": [ - { "path": "../../keyman-version" }, - { "path": "../../utils" }, - { "path": "../../keyboard-processor/src" }, - { "path": "../../lm-message-types" } - ] -} diff --git a/common/web/recorder/tsconfig.json b/common/web/recorder/tsconfig.json new file mode 100644 index 0000000000..3e2b801a83 --- /dev/null +++ b/common/web/recorder/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../tsconfig-base.json", + + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "module": "es6", + "moduleResolution": "Node", + "declaration": true, + "inlineSources": true, + "inlineSourceMap": true, + "target": "es5", + "types": ["node"], + "lib": ["es6"], + "baseUrl": "./", + "outDir": "build/obj/", + "tsBuildInfoFile": "build/obj/tsconfig.tsbuildinfo", + "rootDir": "./src" + }, + + "include": [ + "src/**/*.ts" + ], + + "references": [ + { "path": "../keyman-version" }, + { "path": "../utils/" }, + { "path": "../keyboard-processor/" }, + { "path": "../lm-message-types" } + ], +} diff --git a/common/web/types/test/tsconfig.json b/common/web/types/test/tsconfig.json index 9c97b463af..c522129c99 100644 --- a/common/web/types/test/tsconfig.json +++ b/common/web/types/test/tsconfig.json @@ -14,7 +14,7 @@ "./helpers/*.ts", ], "references": [ - { "path": "../../keyman-version/tsconfig.esm.json" }, + { "path": "../../keyman-version" }, { "path": "../../../../core/include/ldml/"}, { "path": "../" }, ] diff --git a/common/web/types/tsconfig.json b/common/web/types/tsconfig.json index a32b8e1ed7..983a09fda6 100644 --- a/common/web/types/tsconfig.json +++ b/common/web/types/tsconfig.json @@ -11,7 +11,7 @@ "src/**/*.ts" ], "references": [ - { "path": "../keyman-version/tsconfig.esm.json" }, + { "path": "../keyman-version" }, { "path": "../../../core/include/ldml/tsconfig.json"}, ] } diff --git a/common/web/utils/build-bundler.js b/common/web/utils/build-bundler.js new file mode 100644 index 0000000000..72df8e0ae1 --- /dev/null +++ b/common/web/utils/build-bundler.js @@ -0,0 +1,38 @@ +/* + * Bundles @keymanapp/web-utils as single-file modules based upon the export list in src/index.ts. + */ + +import esbuild from 'esbuild'; +import { spawn } from 'child_process'; + +// Bundles to a compact ESModule +esbuild.buildSync({ + entryPoints: ['build/obj/index.js'], + bundle: true, + sourcemap: true, + //minify: true, // No need to minify a module. + //keepNames: true, + format: "esm", + // Sets 'common/web' as a root folder for module resolution; + // this allows the keyman-version import to resolve. + nodePaths: ['..'], + outfile: "build/lib/index.mjs", + tsconfig: 'tsconfig.json', + target: "es5" +}); + +// Bundles to a compact CommonJS (classic Node) module +esbuild.buildSync({ + entryPoints: ['build/obj/index.js'], + bundle: true, + sourcemap: true, + //minify: true, // No need to minify a module. + //keepNames: true, + format: "cjs", + // Sets 'common/web' as a root folder for module resolution; + // this allows the keyman-version import to resolve. + nodePaths: ['..'], + outfile: "build/lib/index.cjs", + tsconfig: 'tsconfig.json', + target: "es5" +}); \ No newline at end of file diff --git a/common/web/utils/build.sh b/common/web/utils/build.sh index 012f6ad41e..f8daffb4a3 100755 --- a/common/web/utils/build.sh +++ b/common/web/utils/build.sh @@ -19,11 +19,12 @@ cd "$THIS_SCRIPT_PATH" builder_describe \ "Compiles the web-oriented utility function module." \ "@/common/web/keyman-version" \ - clean configure build test + clean configure build test \ + "--ci For use with action ${BUILDER_TERM_START}test${BUILDER_TERM_END} - emits CI-friendly test reports" builder_describe_outputs \ configure "/node_modules" \ - build "/common/web/utils/build/index.js" + build "/common/web/utils/build/lib/index.mjs" builder_parse "$@" @@ -43,6 +44,24 @@ if builder_start_action build; then builder_echo "skipping tsc -b; will be completed by $builder_dep_parent" else tsc --build "$THIS_SCRIPT_PATH/tsconfig.json" + node build-bundler.js + + # So... tsc does declaration-bundling on its own pretty well, at least for local development. + tsc --emitDeclarationOnly --outFile ./build/lib/index.d.ts fi builder_finish_action success build +fi + +if builder_start_action test; then + builder_heading "Running web-utils test suite" + + FLAGS= + if builder_has_option --ci; then + echo "Replacing user-friendly test reports with CI-friendly versions." + FLAGS="$FLAGS --reporter mocha-teamcity-reporter" + fi + + mocha --recursive $FLAGS ./src/test/ + + builder_finish_action success test fi \ No newline at end of file diff --git a/common/web/utils/package.json b/common/web/utils/package.json index fbe518e2b8..31e8388bb7 100644 --- a/common/web/utils/package.json +++ b/common/web/utils/package.json @@ -1,11 +1,26 @@ { "name": "@keymanapp/web-utils", "description": "Common utility functions used throughout other Keyman packages", - "main": "./build/index.js", - "types": "./build/index.d.ts", + "main": "./build/obj/index.js", + "exports": { + ".": { + "types": "./build/obj/index.d.ts", + "import": "./build/obj/index.js" + }, + "./lib": { + "types": "./build/lib/index.d.ts", + "import": "./build/lib/index.mjs", + "require": "./build/lib/index.cjs" + }, + "./obj/*.js": { + "types": "./build/obj/*.js.map", + "import": "./build/obj/*.js" + } + }, "scripts": { "build": "gosh ./build.sh", "clean": "tsc -b --clean", + "mocha": "mocha", "tsc": "tsc" }, "repository": { @@ -21,7 +36,14 @@ "devDependencies": { "@keymanapp/resources-gosh": "*", "@keymanapp/keyman-version": "*", + "chai": "^4.3.4", + "mocha": "^10.0.0", + "mocha-teamcity-reporter": "^4.0.0", "@types/node": "^14.0.5", "typescript": "^4.9.5" + }, + "type": "module", + "paths": { + "@keymanapp/keyman-version": "*" } } diff --git a/common/web/utils/src/deepCopy.ts b/common/web/utils/src/deepCopy.ts index ce344391c4..ce05270365 100644 --- a/common/web/utils/src/deepCopy.ts +++ b/common/web/utils/src/deepCopy.ts @@ -1,25 +1,23 @@ -namespace com.keyman.utils { - /** - * Function deepCopy - * Scope Private - * @param {Object} p object to copy - * @param {Array=} c0 array member being copied - * @return {Object} clone ('deep copy') of object - * Description Makes an actual copy (not a reference) of an object, copying simple members, - * arrays and member objects but not functions, so use with care! - */ - export function deepCopy(p:T, c0?): T { - var c = c0 || {}; - for (var i in p) { - if(typeof p[i] === 'object' && p[i] != null) { - c[i] = (p[i].constructor === Array ) ? [] : {}; - deepCopy(p[i],c[i]); - } - else { - c[i] = p[i]; - } +/** + * Function deepCopy + * Scope Private + * @param {Object} p object to copy + * @param {Array=} c0 array member being copied + * @return {Object} clone ('deep copy') of object + * Description Makes an actual copy (not a reference) of an object, copying simple members, + * arrays and member objects but not functions, so use with care! + */ +export default function deepCopy(p:T, c0?): T { + var c = c0 || {}; + for (var i in p) { + if(typeof p[i] === 'object' && p[i] != null) { + c[i] = (p[i].constructor === Array ) ? [] : {}; + deepCopy(p[i],c[i]); + } + else { + c[i] = p[i]; } - - return c; } + + return c; } \ No newline at end of file diff --git a/common/web/utils/src/deviceSpec.ts b/common/web/utils/src/deviceSpec.ts index 7e2932e471..3ab0352fc4 100644 --- a/common/web/utils/src/deviceSpec.ts +++ b/common/web/utils/src/deviceSpec.ts @@ -1,4 +1,56 @@ -namespace com.keyman.utils { +/** + * This class provides an abstract version of com.keyman.Device that is core-friendly, + * containing only the information needed by web-core for text processing use, devoid + * of any direct references to the DOM. + */ +export class DeviceSpec { + readonly browser: DeviceSpec.Browser; + readonly formFactor: DeviceSpec.FormFactor; + readonly OS: DeviceSpec.OperatingSystem; + readonly touchable: boolean; + + constructor(browser: string, formFactor: string, OS: string, touchable: boolean) { + switch(browser.toLowerCase() as DeviceSpec.Browser) { + case DeviceSpec.Browser.Chrome: + case DeviceSpec.Browser.Edge: + case DeviceSpec.Browser.Firefox: + case DeviceSpec.Browser.Native: + case DeviceSpec.Browser.Opera: + case DeviceSpec.Browser.Safari: + this.browser = browser.toLowerCase() as DeviceSpec.Browser; + break; + default: + this.browser = DeviceSpec.Browser.Other; + } + + switch(formFactor.toLowerCase() as DeviceSpec.FormFactor) { + case DeviceSpec.FormFactor.Desktop: + case DeviceSpec.FormFactor.Phone: + case DeviceSpec.FormFactor.Tablet: + this.formFactor = formFactor.toLowerCase() as DeviceSpec.FormFactor; + break; + default: + throw ("Invalid form factor specified for device: " + formFactor); + } + + switch(OS.toLowerCase() as DeviceSpec.OperatingSystem) { + case DeviceSpec.OperatingSystem.Windows.toLowerCase(): + case DeviceSpec.OperatingSystem.macOS.toLowerCase(): + case DeviceSpec.OperatingSystem.Linux.toLowerCase(): + case DeviceSpec.OperatingSystem.Android.toLowerCase(): + case DeviceSpec.OperatingSystem.iOS.toLowerCase(): + this.OS = OS.toLowerCase() as DeviceSpec.OperatingSystem; + break; + default: + this.OS = DeviceSpec.OperatingSystem.Other; + } + + this.touchable = touchable; + } +} + +// Namespaces these under DeviceSpec, as each is primarily used with it. +export namespace DeviceSpec { export enum Browser { Chrome = 'chrome', Edge = 'edge', @@ -23,55 +75,10 @@ namespace com.keyman.utils { Phone = 'phone', Tablet = 'tablet' } +} - /** - * This class provides an abstract version of com.keyman.Device that is core-friendly, - * containing only the information needed by web-core for text processing use, devoid - * of any direct references to the DOM. - */ - export class DeviceSpec { - readonly browser: Browser; - readonly formFactor: FormFactor; - readonly OS: OperatingSystem; - readonly touchable: boolean; - - constructor(browser: string, formFactor: string, OS: string, touchable: boolean) { - switch(browser.toLowerCase() as Browser) { - case Browser.Chrome: - case Browser.Edge: - case Browser.Firefox: - case Browser.Native: - case Browser.Opera: - case Browser.Safari: - this.browser = browser.toLowerCase() as Browser; - break; - default: - this.browser = Browser.Other; - } +export function physicalKeyDeviceAlias(device: DeviceSpec) { + return new DeviceSpec(device.browser, DeviceSpec.FormFactor.Desktop, device.OS, false); +} - switch(formFactor.toLowerCase() as FormFactor) { - case FormFactor.Desktop: - case FormFactor.Phone: - case FormFactor.Tablet: - this.formFactor = formFactor.toLowerCase() as FormFactor; - break; - default: - throw ("Invalid form factor specified for device: " + formFactor); - } - - switch(OS.toLowerCase() as OperatingSystem) { - case OperatingSystem.Windows.toLowerCase(): - case OperatingSystem.macOS.toLowerCase(): - case OperatingSystem.Linux.toLowerCase(): - case OperatingSystem.Android.toLowerCase(): - case OperatingSystem.iOS.toLowerCase(): - this.OS = OS.toLowerCase() as OperatingSystem; - break; - default: - this.OS = OperatingSystem.Other; - } - - this.touchable = touchable; - } - } -} \ No newline at end of file +export default DeviceSpec; \ No newline at end of file diff --git a/common/web/utils/src/globalObject.ts b/common/web/utils/src/globalObject.ts index 08eb08df60..b268687216 100644 --- a/common/web/utils/src/globalObject.ts +++ b/common/web/utils/src/globalObject.ts @@ -1,32 +1,30 @@ -namespace com.keyman.utils { - /** - * Returns the base global object available to the current JS platform. - * - In browsers, returns `window`. - * - In WebWorkers, returns `self`. - * - In Node, returns `global`. - */ - export function getGlobalObject(): typeof globalThis { - // Evergreen browsers have started defining 'globalThis'. - // Refer to https://devblogs.microsoft.com/typescript/announcing-typescript-3-4/#type-checking-for-globalthis - // and its referenced polyfill. Said polyfill is very complex, so we opt for this far leaner variant. - if(typeof globalThis != 'undefined') { - return globalThis; // Not available in IE or older Edge versions - // @ts-ignore (TS will throw errors for whatever platform we're not compiling for.) - } else if(typeof window != 'undefined') { - // @ts-ignore - return window; // The browser-based classic - // @ts-ignore - } else if(typeof self != 'undefined') { - // @ts-ignore - return self; // WebWorker global - } else { - // Assumption - if neither of the above exist, we're in Node, for unit-testing. - // Node doesn't have as many methods and properties as the other two, but what - // matters for us is that it's the base global. - // - // Some other headless JS solutions use 'this' instead, but Node's enough for our needs. - // @ts-ignore - return (global as any) as typeof globalThis; - } +/** + * Returns the base global object available to the current JS platform. + * - In browsers, returns `window`. + * - In WebWorkers, returns `self`. + * - In Node, returns `global`. + */ +export default function getGlobalObject(): typeof globalThis { + // Evergreen browsers have started defining 'globalThis'. + // Refer to https://devblogs.microsoft.com/typescript/announcing-typescript-3-4/#type-checking-for-globalthis + // and its referenced polyfill. Said polyfill is very complex, so we opt for this far leaner variant. + if(typeof globalThis != 'undefined') { + return globalThis; // Not available in IE or older Edge versions + // @ts-ignore (TS will throw errors for whatever platform we're not compiling for.) + } else if(typeof window != 'undefined') { + // @ts-ignore + return window; // The browser-based classic + // @ts-ignore + } else if(typeof self != 'undefined') { + // @ts-ignore + return self; // WebWorker global + } else { + // Assumption - if neither of the above exist, we're in Node, for unit-testing. + // Node doesn't have as many methods and properties as the other two, but what + // matters for us is that it's the base global. + // + // Some other headless JS solutions use 'this' instead, but Node's enough for our needs. + // @ts-ignore + return (global as any) as typeof globalThis; } } \ No newline at end of file diff --git a/common/web/utils/src/index.ts b/common/web/utils/src/index.ts index 8d7a2e7773..dc3f868973 100644 --- a/common/web/utils/src/index.ts +++ b/common/web/utils/src/index.ts @@ -1,6 +1,27 @@ // References all utility includes from a single file, making import/export simple. -/// -/// -/// -/// -/// \ No newline at end of file + +export { default as deepCopy } from "./deepCopy.js"; + +export { default as DeviceSpec, physicalKeyDeviceAlias } from "./deviceSpec.js"; + +/* + // An example valid use, post-import: + let testSpec = new DeviceSpec(DeviceSpec.Browser.Chrome, + DeviceSpec.FormFactor.Tablet, + DeviceSpec.OperatingSystem.Android, + true); + */ + +export { default as Version } from "./version.js"; + +export { default as globalObject } from "./globalObject.js"; + +export { default as extendString } from "./kmwstring.js"; + + + +export { default as ManagedPromise } from "./managedPromise.js"; + +// // Uncomment the following line and run the bundled output to verify successful +// // esbuild bundling of this submodule: +// console.log(Version.CURRENT.toString()); \ No newline at end of file diff --git a/common/web/utils/src/kmwstring.ts b/common/web/utils/src/kmwstring.ts index 397b9041a5..21d165e8ff 100644 --- a/common/web/utils/src/kmwstring.ts +++ b/common/web/utils/src/kmwstring.ts @@ -3,468 +3,479 @@ Copyright 2020 SIL International ***/ + /* * TODO: Remove this file as part of addressing https://github.com/keymanapp/keyman/issues/2492. */ -interface StringConstructor { - kmwFromCharCode(cp0: number): string, - _kmwFromCharCode(cp0: number): string, - kmwEnableSupplementaryPlane(bEnable: boolean) -} +declare global { + interface StringConstructor { + kmwFromCharCode(cp0: number): string, + _kmwFromCharCode(cp0: number): string, + kmwEnableSupplementaryPlane(bEnable: boolean) + } -interface String { - kmwCharCodeAt(codePointIndex: number): number, - kmwCharAt(codePointIndex: number) : string, - kmwIndexOf(searchValue: string, fromIndex?: number) : number, - kmwLastIndexOf(searchValue: string, fromIndex?: number) : number, - kmwSlice(beginSlice: number, endSlice: number) : string, - kmwSubstring(start: number, length: number) : string, - kmwSubstr(start: number, length?: number) : string, - kmwBMPSubstr(start: number, length?: number) : string, - kmwLength(): number, - kmwBMPLength(): number, - kmwNextChar(codeUnitIndex: number): number, - kmwBMPNextChar(codeUnitIndex: number): number, - kmwPrevChar(codeUnitIndex: number): number, - kmwBMPPrevChar(codeUnitIndex: number): number, - kmwCodePointToCodeUnit(codePointIndex: number) : number, - kmwBMPCodePointToCodeUnit(codePointIndex: number) : number, - kmwCodeUnitToCodePoint(codeUnitIndex: number) : number, - kmwBMPCodeUnitToCodePoint(codeUnitIndex: number) : number, - _kmwCharCodeAt(codePointIndex: number): number, - _kmwCharAt(codePointIndex: number) : string, - _kmwIndexOf(searchValue: string, fromIndex?: number) : number, - _kmwLastIndexOf(searchValue: string, fromIndex?: number) : number, - _kmwSlice(beginSlice: number, endSlice: number) : string, - _kmwSubstring(start: number, length?: number) : string, - _kmwSubstr(start: number, length?: number) : string, - _kmwLength(): number, - _kmwNextChar(codeUnitIndex: number): number, - _kmwPrevChar(codeUnitIndex: number): number, - _kmwCodePointToCodeUnit(codePointIndex: number) : number, - _kmwCodeUnitToCodePoint(codeUnitIndex: number) : number, + interface String { + kmwCharCodeAt(codePointIndex: number): number, + kmwCharAt(codePointIndex: number) : string, + kmwIndexOf(searchValue: string, fromIndex?: number) : number, + kmwLastIndexOf(searchValue: string, fromIndex?: number) : number, + kmwSlice(beginSlice: number, endSlice: number) : string, + kmwSubstring(start: number, length: number) : string, + kmwSubstr(start: number, length?: number) : string, + kmwBMPSubstr(start: number, length?: number) : string, + kmwLength(): number, + kmwBMPLength(): number, + kmwNextChar(codeUnitIndex: number): number, + kmwBMPNextChar(codeUnitIndex: number): number, + kmwPrevChar(codeUnitIndex: number): number, + kmwBMPPrevChar(codeUnitIndex: number): number, + kmwCodePointToCodeUnit(codePointIndex: number) : number, + kmwBMPCodePointToCodeUnit(codePointIndex: number) : number, + kmwCodeUnitToCodePoint(codeUnitIndex: number) : number, + kmwBMPCodeUnitToCodePoint(codeUnitIndex: number) : number, + _kmwCharCodeAt(codePointIndex: number): number, + _kmwCharAt(codePointIndex: number) : string, + _kmwIndexOf(searchValue: string, fromIndex?: number) : number, + _kmwLastIndexOf(searchValue: string, fromIndex?: number) : number, + _kmwSlice(beginSlice: number, endSlice: number) : string, + _kmwSubstring(start: number, length?: number) : string, + _kmwSubstr(start: number, length?: number) : string, + _kmwLength(): number, + _kmwNextChar(codeUnitIndex: number): number, + _kmwPrevChar(codeUnitIndex: number): number, + _kmwCodePointToCodeUnit(codePointIndex: number) : number, + _kmwCodeUnitToCodePoint(codeUnitIndex: number) : number + } } -/** - * Constructs a string from one or more Unicode character codepoint values - * passed as integer parameters. - * - * @param {number} cp0,... 1 or more Unicode codepoints, e.g. 0x0065, 0x10000 - * @return {string|null} The new String object. - */ -String.kmwFromCharCode = function(cp0) { - var chars = [], i; - for (i = 0; i < arguments.length; i++) { - var c = Number(arguments[i]); - if (!isFinite(c) || c < 0 || c > 0x10FFFF || Math.floor(c) !== c) { - throw new RangeError("Invalid code point " + c); - } - if (c < 0x10000) { - chars.push(c); - } else { - c -= 0x10000; - chars.push((c >> 10) + 0xD800); - chars.push((c % 0x400) + 0xDC00); +export default function extendString() { + /** + * Constructs a string from one or more Unicode character codepoint values + * passed as integer parameters. + * + * @param {number} cp0,... 1 or more Unicode codepoints, e.g. 0x0065, 0x10000 + * @return {string|null} The new String object. + */ + String.kmwFromCharCode = function(cp0) { + var chars = [], i; + for (i = 0; i < arguments.length; i++) { + var c = Number(arguments[i]); + if (!isFinite(c) || c < 0 || c > 0x10FFFF || Math.floor(c) !== c) { + throw new RangeError("Invalid code point " + c); + } + if (c < 0x10000) { + chars.push(c); + } else { + c -= 0x10000; + chars.push((c >> 10) + 0xD800); + chars.push((c % 0x400) + 0xDC00); + } } + return String.fromCharCode.apply(undefined, chars); } - return String.fromCharCode.apply(undefined, chars); -} -/** - * Returns a number indicating the Unicode value of the character at the given - * code point index, with support for supplementary plane characters. - * - * @param {number} codePointIndex The code point index into the string (not - the code unit index) to return - * @return {number} The Unicode character value - */ -String.prototype.kmwCharCodeAt = function(codePointIndex) { - var str = String(this); - var codeUnitIndex = 0; - - if (codePointIndex < 0 || codePointIndex >= str.length) { - return NaN; - } + /** + * Returns a number indicating the Unicode value of the character at the given + * code point index, with support for supplementary plane characters. + * + * @param {number} codePointIndex The code point index into the string (not + the code unit index) to return + * @return {number} The Unicode character value + */ + String.prototype.kmwCharCodeAt = function(codePointIndex) { + var str = String(this); + var codeUnitIndex = 0; - for(var i = 0; i < codePointIndex; i++) { - codeUnitIndex = str.kmwNextChar(codeUnitIndex); - if(codeUnitIndex === null) return NaN; - } - - var first = str.charCodeAt(codeUnitIndex); - if (first >= 0xD800 && first <= 0xDBFF && str.length > codeUnitIndex + 1) { - var second = str.charCodeAt(codeUnitIndex + 1); - if (second >= 0xDC00 && second <= 0xDFFF) { - return ((first - 0xD800) << 10) + (second - 0xDC00) + 0x10000; + if (codePointIndex < 0 || codePointIndex >= str.length) { + return NaN; } + + for(var i = 0; i < codePointIndex; i++) { + codeUnitIndex = str.kmwNextChar(codeUnitIndex); + if(codeUnitIndex === null) return NaN; + } + + var first = str.charCodeAt(codeUnitIndex); + if (first >= 0xD800 && first <= 0xDBFF && str.length > codeUnitIndex + 1) { + var second = str.charCodeAt(codeUnitIndex + 1); + if (second >= 0xDC00 && second <= 0xDFFF) { + return ((first - 0xD800) << 10) + (second - 0xDC00) + 0x10000; + } + } + return first; } - return first; -} -/** - * Returns the code point index within the calling String object of the first occurrence - * of the specified value, or -1 if not found. - * - * @param {string} searchValue The value to search for - * @param {number} [fromIndex] Optional code point index to start searching from - * @return {number} The code point index of the specified search value - */ -String.prototype.kmwIndexOf = function(searchValue, fromIndex) { - var str = String(this); - var codeUnitIndex = str.indexOf(searchValue, fromIndex); - - if(codeUnitIndex < 0) { - return codeUnitIndex; + /** + * Returns the code point index within the calling String object of the first occurrence + * of the specified value, or -1 if not found. + * + * @param {string} searchValue The value to search for + * @param {number} [fromIndex] Optional code point index to start searching from + * @return {number} The code point index of the specified search value + */ + String.prototype.kmwIndexOf = function(searchValue, fromIndex) { + var str = String(this); + var codeUnitIndex = str.indexOf(searchValue, fromIndex); + + if(codeUnitIndex < 0) { + return codeUnitIndex; + } + + var codePointIndex = 0; + for(var i = 0; i !== null && i < codeUnitIndex; i = str.kmwNextChar(i)) codePointIndex++; + return codePointIndex; } - - var codePointIndex = 0; - for(var i = 0; i !== null && i < codeUnitIndex; i = str.kmwNextChar(i)) codePointIndex++; - return codePointIndex; -} -/** - * Returns the code point index within the calling String object of the last occurrence - * of the specified value, or -1 if not found. - * - * @param {string} searchValue The value to search for - * @param {number} fromIndex Optional code point index to start searching from - * @return {number} The code point index of the specified search value - */ -String.prototype.kmwLastIndexOf = function(searchValue, fromIndex) -{ - var str = String(this); - var codeUnitIndex = str.lastIndexOf(searchValue, fromIndex); - - if(codeUnitIndex < 0) { - return codeUnitIndex; + /** + * Returns the code point index within the calling String object of the last occurrence + * of the specified value, or -1 if not found. + * + * @param {string} searchValue The value to search for + * @param {number} fromIndex Optional code point index to start searching from + * @return {number} The code point index of the specified search value + */ + String.prototype.kmwLastIndexOf = function(searchValue, fromIndex) + { + var str = String(this); + var codeUnitIndex = str.lastIndexOf(searchValue, fromIndex); + + if(codeUnitIndex < 0) { + return codeUnitIndex; + } + + var codePointIndex = 0; + for(var i = 0; i !== null && i < codeUnitIndex; i = str.kmwNextChar(i)) codePointIndex++; + return codePointIndex; } - - var codePointIndex = 0; - for(var i = 0; i !== null && i < codeUnitIndex; i = str.kmwNextChar(i)) codePointIndex++; - return codePointIndex; -} -/** - * Returns the length of the string in code points, as opposed to code units. - * - * @return {number} The length of the string in code points - */ -String.prototype.kmwLength = function() { - var str = String(this); - - if(str.length == 0) return 0; - - for(var i = 0, codeUnitIndex = 0; codeUnitIndex !== null; i++) - codeUnitIndex = str.kmwNextChar(codeUnitIndex); - return i; -} + /** + * Returns the length of the string in code points, as opposed to code units. + * + * @return {number} The length of the string in code points + */ + String.prototype.kmwLength = function() { + var str = String(this); -/** - * Extracts a section of a string and returns a new string. - * - * @param {number} beginSlice The start code point index in the string to - * extract from - * @param {number} endSlice Optional end code point index in the string - * to extract to - * @return {string} The substring as selected by beginSlice and - * endSlice - */ -String.prototype.kmwSlice = function(beginSlice, endSlice) { - var str = String(this); - var beginSliceCodeUnit = str.kmwCodePointToCodeUnit(beginSlice); - var endSliceCodeUnit = str.kmwCodePointToCodeUnit(endSlice); - if(beginSliceCodeUnit === null || endSliceCodeUnit === null) - return ''; - else - return str.slice(beginSliceCodeUnit, endSliceCodeUnit); -} + if(str.length == 0) return 0; -/** - * Returns the characters in a string beginning at the specified location through - * the specified number of characters. - * - * @param {number} start The start code point index in the string to - * extract from - * @param {number=} length Optional length to extract - * @return {string} The substring as selected by start and length - */ -String.prototype.kmwSubstr = function(start, length?) -{ - var str = String(this); - if(start < 0) - { - start = str.kmwLength() + start; + for(var i = 0, codeUnitIndex = 0; codeUnitIndex !== null; i++) + codeUnitIndex = str.kmwNextChar(codeUnitIndex); + return i; } - if(start < 0) start = 0; - var startCodeUnit = str.kmwCodePointToCodeUnit(start); - var endCodeUnit = startCodeUnit; - - if(startCodeUnit === null) return ''; - - if(arguments.length < 2) { - endCodeUnit = str.length; - } else { - for(var i = 0; i < length; i++) endCodeUnit = str.kmwNextChar(endCodeUnit); + + /** + * Extracts a section of a string and returns a new string. + * + * @param {number} beginSlice The start code point index in the string to + * extract from + * @param {number} endSlice Optional end code point index in the string + * to extract to + * @return {string} The substring as selected by beginSlice and + * endSlice + */ + String.prototype.kmwSlice = function(beginSlice, endSlice) { + var str = String(this); + var beginSliceCodeUnit = str.kmwCodePointToCodeUnit(beginSlice); + var endSliceCodeUnit = str.kmwCodePointToCodeUnit(endSlice); + if(beginSliceCodeUnit === null || endSliceCodeUnit === null) + return ''; + else + return str.slice(beginSliceCodeUnit, endSliceCodeUnit); } - if(endCodeUnit === null) - return str.substring(startCodeUnit); - else - return str.substring(startCodeUnit, endCodeUnit); -} -/** - * Returns the characters in a string between two indexes into the string. - * - * @param {number} indexA The start code point index in the string to - * extract from - * @param {number} indexB The end code point index in the string to - * extract to - * @return {string} The substring as selected by indexA and indexB - */ -String.prototype.kmwSubstring = function(indexA, indexB) -{ - var str = String(this),indexACodeUnit,indexBCodeUnit; - - if(typeof(indexB) == 'undefined') + /** + * Returns the characters in a string beginning at the specified location through + * the specified number of characters. + * + * @param {number} start The start code point index in the string to + * extract from + * @param {number=} length Optional length to extract + * @return {string} The substring as selected by start and length + */ + String.prototype.kmwSubstr = function(start, length?) { - indexACodeUnit = str.kmwCodePointToCodeUnit(indexA); - indexBCodeUnit = str.length; - } - else - { - if(indexA > indexB) { var c = indexA; indexA = indexB; indexB = c; } - - indexACodeUnit = str.kmwCodePointToCodeUnit(indexA); - indexBCodeUnit = str.kmwCodePointToCodeUnit(indexB); + var str = String(this); + if(start < 0) + { + start = str.kmwLength() + start; + } + if(start < 0) start = 0; + var startCodeUnit = str.kmwCodePointToCodeUnit(start); + var endCodeUnit = startCodeUnit; + + if(startCodeUnit === null) return ''; + + if(arguments.length < 2) { + endCodeUnit = str.length; + } else { + for(var i = 0; i < length; i++) endCodeUnit = str.kmwNextChar(endCodeUnit); + } + if(endCodeUnit === null) + return str.substring(startCodeUnit); + else + return str.substring(startCodeUnit, endCodeUnit); } - if(isNaN(indexACodeUnit) || indexACodeUnit === null) indexACodeUnit = 0; - if(isNaN(indexBCodeUnit) || indexBCodeUnit === null) indexBCodeUnit = str.length; - return str.substring(indexACodeUnit, indexBCodeUnit); -} + /** + * Returns the characters in a string between two indexes into the string. + * + * @param {number} indexA The start code point index in the string to + * extract from + * @param {number} indexB The end code point index in the string to + * extract to + * @return {string} The substring as selected by indexA and indexB + */ + String.prototype.kmwSubstring = function(indexA, indexB) + { + var str = String(this),indexACodeUnit,indexBCodeUnit; -/* - Helper functions -*/ - -/** - * Returns the code unit index for the next code point in the string, accounting for - * supplementary pairs - * - * @param {number|null} codeUnitIndex The code unit position to increment - * @return {number|null} The index of the next code point in the string, - * in code units - */ -String.prototype.kmwNextChar = function(codeUnitIndex) { - var str = String(this); + if(typeof(indexB) == 'undefined') + { + indexACodeUnit = str.kmwCodePointToCodeUnit(indexA); + indexBCodeUnit = str.length; + } + else + { + if(indexA > indexB) { var c = indexA; indexA = indexB; indexB = c; } + + indexACodeUnit = str.kmwCodePointToCodeUnit(indexA); + indexBCodeUnit = str.kmwCodePointToCodeUnit(indexB); + } + if(isNaN(indexACodeUnit) || indexACodeUnit === null) indexACodeUnit = 0; + if(isNaN(indexBCodeUnit) || indexBCodeUnit === null) indexBCodeUnit = str.length; - if(codeUnitIndex === null || codeUnitIndex < 0 || codeUnitIndex >= str.length - 1) { - return null; + return str.substring(indexACodeUnit, indexBCodeUnit); } - - var first = str.charCodeAt(codeUnitIndex); - if (first >= 0xD800 && first <= 0xDBFF && str.length > codeUnitIndex + 1) { - var second = str.charCodeAt(codeUnitIndex + 1); - if (second >= 0xDC00 && second <= 0xDFFF) { - if(codeUnitIndex == str.length - 2) { - return null; + + /* + Helper functions + */ + + /** + * Returns the code unit index for the next code point in the string, accounting for + * supplementary pairs + * + * @param {number|null} codeUnitIndex The code unit position to increment + * @return {number|null} The index of the next code point in the string, + * in code units + */ + String.prototype.kmwNextChar = function(codeUnitIndex) { + var str = String(this); + + if(codeUnitIndex === null || codeUnitIndex < 0 || codeUnitIndex >= str.length - 1) { + return null; + } + + var first = str.charCodeAt(codeUnitIndex); + if (first >= 0xD800 && first <= 0xDBFF && str.length > codeUnitIndex + 1) { + var second = str.charCodeAt(codeUnitIndex + 1); + if (second >= 0xDC00 && second <= 0xDFFF) { + if(codeUnitIndex == str.length - 2) { + return null; + } + return codeUnitIndex + 2; } - return codeUnitIndex + 2; } + return codeUnitIndex + 1; } - return codeUnitIndex + 1; -} -/** - * Returns the code unit index for the previous code point in the string, accounting - * for supplementary pairs - * - * @param {number|null} codeUnitIndex The code unit position to decrement - * @return {number|null} The index of the previous code point in the - * string, in code units -*/ -String.prototype.kmwPrevChar = function(codeUnitIndex) { - var str = String(this); - - if(codeUnitIndex == null || codeUnitIndex <= 0 || codeUnitIndex > str.length) { - return null; - } - - var second = str.charCodeAt(codeUnitIndex - 1); - if (second >= 0xDC00 && second <= 0xDFFF && codeUnitIndex > 1) { - var first = str.charCodeAt(codeUnitIndex - 2); - if(first >= 0xD800 && first <= 0xDBFF) { - return codeUnitIndex - 2; + /** + * Returns the code unit index for the previous code point in the string, accounting + * for supplementary pairs + * + * @param {number|null} codeUnitIndex The code unit position to decrement + * @return {number|null} The index of the previous code point in the + * string, in code units + */ + String.prototype.kmwPrevChar = function(codeUnitIndex) { + var str = String(this); + + if(codeUnitIndex == null || codeUnitIndex <= 0 || codeUnitIndex > str.length) { + return null; + } + + var second = str.charCodeAt(codeUnitIndex - 1); + if (second >= 0xDC00 && second <= 0xDFFF && codeUnitIndex > 1) { + var first = str.charCodeAt(codeUnitIndex - 2); + if(first >= 0xD800 && first <= 0xDBFF) { + return codeUnitIndex - 2; + } } + return codeUnitIndex - 1; } - return codeUnitIndex - 1; -} -/** - * Returns the corresponding code unit index to the code point index passed - * - * @param {number|null} codePointIndex A code point index in the string - * @return {number|null} The corresponding code unit index - */ -String.prototype.kmwCodePointToCodeUnit = function(codePointIndex) { - - if(codePointIndex === null) return null; - - var str = String(this); - var codeUnitIndex = 0; - - if(codePointIndex < 0) { - codeUnitIndex = str.length; - for(var i = 0; i > codePointIndex; i--) - codeUnitIndex = str.kmwPrevChar(codeUnitIndex); + /** + * Returns the corresponding code unit index to the code point index passed + * + * @param {number|null} codePointIndex A code point index in the string + * @return {number|null} The corresponding code unit index + */ + String.prototype.kmwCodePointToCodeUnit = function(codePointIndex) { + + if(codePointIndex === null) return null; + + var str = String(this); + var codeUnitIndex = 0; + + if(codePointIndex < 0) { + codeUnitIndex = str.length; + for(var i = 0; i > codePointIndex; i--) + codeUnitIndex = str.kmwPrevChar(codeUnitIndex); + return codeUnitIndex; + } + + if(codePointIndex == str.kmwLength()) return str.length; + + for(var i = 0; i < codePointIndex; i++) + codeUnitIndex = str.kmwNextChar(codeUnitIndex); return codeUnitIndex; } - - if(codePointIndex == str.kmwLength()) return str.length; - - for(var i = 0; i < codePointIndex; i++) - codeUnitIndex = str.kmwNextChar(codeUnitIndex); - return codeUnitIndex; -} -/** - * Returns the corresponding code point index to the code unit index passed - * - * @param {number|null} codeUnitIndex A code unit index in the string - * @return {number|null} The corresponding code point index - */ -String.prototype.kmwCodeUnitToCodePoint = function(codeUnitIndex) { - var str = String(this); - - if(codeUnitIndex === null) - return null; - else if(codeUnitIndex == 0) - return 0; - else if(codeUnitIndex < 0) - return str.substr(codeUnitIndex).kmwLength(); - else - return str.substr(0,codeUnitIndex).kmwLength(); -} + /** + * Returns the corresponding code point index to the code unit index passed + * + * @param {number|null} codeUnitIndex A code unit index in the string + * @return {number|null} The corresponding code point index + */ + String.prototype.kmwCodeUnitToCodePoint = function(codeUnitIndex) { + var str = String(this); -/** - * Returns the character at a the code point index passed - * - * @param {number} codePointIndex A code point index in the string - * @return {string} The corresponding character - */ -String.prototype.kmwCharAt = function(codePointIndex) { - var str = String(this); - - if(codePointIndex >= 0) return str.kmwSubstr(codePointIndex,1); else return ''; -} + if(codeUnitIndex === null) + return null; + else if(codeUnitIndex == 0) + return 0; + else if(codeUnitIndex < 0) + return str.substr(codeUnitIndex).kmwLength(); + else + return str.substr(0,codeUnitIndex).kmwLength(); + } -/** - * String prototype library extensions for basic plane characters, - * to simplify enabling or disabling supplementary plane functionality (I3319) - */ - -/** - * Returns the code unit index for the next code point in the string - * - * @param {number} codeUnitIndex A code point index in the string - * @return {number|null} The corresponding character - */ -String.prototype.kmwBMPNextChar = function(codeUnitIndex) -{ - var str = String(this); - if(codeUnitIndex < 0 || codeUnitIndex >= str.length - 1) { - return null; + /** + * Returns the character at a the code point index passed + * + * @param {number} codePointIndex A code point index in the string + * @return {string} The corresponding character + */ + String.prototype.kmwCharAt = function(codePointIndex) { + var str = String(this); + + if(codePointIndex >= 0) return str.kmwSubstr(codePointIndex,1); else return ''; } - return codeUnitIndex + 1; -} - -/** - * Returns the code unit index for the previous code point in the string - * - * @param {number} codeUnitIndex A code unit index in the string - * @return {number|null} The corresponding character - */ -String.prototype.kmwBMPPrevChar = function(codeUnitIndex) -{ - var str = String(this); - - if(codeUnitIndex <= 0 || codeUnitIndex > str.length) { - return null; + + /** + * String prototype library extensions for basic plane characters, + * to simplify enabling or disabling supplementary plane functionality (I3319) + */ + + /** + * Returns the code unit index for the next code point in the string + * + * @param {number} codeUnitIndex A code point index in the string + * @return {number|null} The corresponding character + */ + String.prototype.kmwBMPNextChar = function(codeUnitIndex) + { + var str = String(this); + if(codeUnitIndex < 0 || codeUnitIndex >= str.length - 1) { + return null; + } + return codeUnitIndex + 1; } - return codeUnitIndex - 1; -} - -/** - * Returns the code unit index for a code point index - * - * @param {number} codePointIndex A code point index in the string - * @return {number} The corresponding character - */ -String.prototype.kmwBMPCodePointToCodeUnit = function(codePointIndex) -{ - return codePointIndex; -} -/** - * Returns the code point index for a code unit index - * - * @param {number} codeUnitIndex A code point index in the string - * @return {number} The corresponding character - */ -String.prototype.kmwBMPCodeUnitToCodePoint = function(codeUnitIndex) -{ - return codeUnitIndex; -} + /** + * Returns the code unit index for the previous code point in the string + * + * @param {number} codeUnitIndex A code unit index in the string + * @return {number|null} The corresponding character + */ + String.prototype.kmwBMPPrevChar = function(codeUnitIndex) + { + var str = String(this); -/** - * Returns the length of a BMP string - * - * @return {number} The length in code points - */ -String.prototype.kmwBMPLength = function() -{ - var str = String(this); - return str.length; -} + if(codeUnitIndex <= 0 || codeUnitIndex > str.length) { + return null; + } + return codeUnitIndex - 1; + } + /** + * Returns the code unit index for a code point index + * + * @param {number} codePointIndex A code point index in the string + * @return {number} The corresponding character + */ + String.prototype.kmwBMPCodePointToCodeUnit = function(codePointIndex) + { + return codePointIndex; + } -/** - * Returns a substring - * - * @param {number} n - * @param {number=} ln - * @return {string} - */ -String.prototype.kmwBMPSubstr = function(n,ln?) -{ - var str=String(this); - if(n > -1) - return str.substr(n,ln); - else - return str.substr(str.length+n,-n); -} + /** + * Returns the code point index for a code unit index + * + * @param {number} codeUnitIndex A code point index in the string + * @return {number} The corresponding character + */ + String.prototype.kmwBMPCodeUnitToCodePoint = function(codeUnitIndex) + { + return codeUnitIndex; + } -/** - * Enable or disable supplementary plane string handling - * - * @param {boolean} bEnable - */ -String.kmwEnableSupplementaryPlane = function(bEnable) -{ - var p=String.prototype; - String._kmwFromCharCode = bEnable ? String.kmwFromCharCode : String.fromCharCode; - p._kmwCharAt = bEnable ? p.kmwCharAt : p.charAt; - p._kmwCharCodeAt = bEnable ? p.kmwCharCodeAt : p.charCodeAt; - p._kmwIndexOf = bEnable ? p.kmwIndexOf :p.indexOf; - p._kmwLastIndexOf = bEnable ? p.kmwLastIndexOf : p.lastIndexOf ; - p._kmwSlice = bEnable ? p.kmwSlice : p.slice; - p._kmwSubstring = bEnable ? p.kmwSubstring : p.substring; - p._kmwSubstr = bEnable ? p.kmwSubstr : p.kmwBMPSubstr; - p._kmwLength = bEnable ? p.kmwLength : p.kmwBMPLength; - p._kmwNextChar = bEnable ? p.kmwNextChar : p.kmwBMPNextChar; - p._kmwPrevChar = bEnable ? p.kmwPrevChar : p.kmwBMPPrevChar; - p._kmwCodePointToCodeUnit = bEnable ? p.kmwCodePointToCodeUnit : p.kmwBMPCodePointToCodeUnit; - p._kmwCodeUnitToCodePoint = bEnable ? p.kmwCodeUnitToCodePoint : p.kmwBMPCodeUnitToCodePoint; + /** + * Returns the length of a BMP string + * + * @return {number} The length in code points + */ + String.prototype.kmwBMPLength = function() + { + var str = String(this); + return str.length; + } + + /** + * Returns a substring + * + * @param {number} n + * @param {number=} ln + * @return {string} + */ + String.prototype.kmwBMPSubstr = function(n,ln?) + { + var str=String(this); + if(n > -1) + return str.substr(n,ln); + else + return str.substr(str.length+n,-n); + } + + /** + * Enable or disable supplementary plane string handling + * + * @param {boolean} bEnable + */ + String.kmwEnableSupplementaryPlane = function(bEnable) + { + var p=String.prototype; + String._kmwFromCharCode = bEnable ? String.kmwFromCharCode : String.fromCharCode; + p._kmwCharAt = bEnable ? p.kmwCharAt : p.charAt; + p._kmwCharCodeAt = bEnable ? p.kmwCharCodeAt : p.charCodeAt; + p._kmwIndexOf = bEnable ? p.kmwIndexOf :p.indexOf; + p._kmwLastIndexOf = bEnable ? p.kmwLastIndexOf : p.lastIndexOf ; + p._kmwSlice = bEnable ? p.kmwSlice : p.slice; + p._kmwSubstring = bEnable ? p.kmwSubstring : p.substring; + p._kmwSubstr = bEnable ? p.kmwSubstr : p.kmwBMPSubstr; + p._kmwLength = bEnable ? p.kmwLength : p.kmwBMPLength; + p._kmwNextChar = bEnable ? p.kmwNextChar : p.kmwBMPNextChar; + p._kmwPrevChar = bEnable ? p.kmwPrevChar : p.kmwBMPPrevChar; + p._kmwCodePointToCodeUnit = bEnable ? p.kmwCodePointToCodeUnit : p.kmwBMPCodePointToCodeUnit; + p._kmwCodeUnitToCodePoint = bEnable ? p.kmwCodeUnitToCodePoint : p.kmwBMPCodeUnitToCodePoint; + } + + // Ensure that _all_ String extensions are established, even if disabled by default. + if(!String._kmwFromCharCode) { + String.kmwEnableSupplementaryPlane(false); + } } +// For side-effect imports: +extendString(); \ No newline at end of file diff --git a/common/web/utils/src/managedPromise.ts b/common/web/utils/src/managedPromise.ts new file mode 100644 index 0000000000..2bee3eaa17 --- /dev/null +++ b/common/web/utils/src/managedPromise.ts @@ -0,0 +1,64 @@ +type ResolveSignature = (value: Type | PromiseLike) => void; +type RejectSignature = (reason?: any) => void; + +export default class ManagedPromise { + public resolve: ResolveSignature; + public reject: RejectSignature; + + private _hasResolved: boolean = false; + private _hasRejected: boolean = false; + + public get hasResolved(): boolean { + return this._hasResolved; + } + + public get hasRejected(): boolean { + return this._hasRejected; + } + + public get hasFinalized(): boolean { + return this.hasResolved || this.hasRejected; + } + + private _promise: Promise; + + constructor(); + constructor(executor: (resolve: ResolveSignature, reject: RejectSignature) => Type); + constructor(executor?: (resolve: ResolveSignature, reject: RejectSignature) => Type) { + this._promise = new Promise((resolve, reject) => { + this.resolve = (value) => { + this._hasResolved = true; + resolve(value); + }; + + this.reject = (reason) => { + this._hasRejected = true; + reject(reason); + }; + + if(executor) { + executor(this.resolve, this.reject); + } + }); + } + + // Cannot actually extend the Promise class in ES5; attempt to use it will throw errors. + // So, we just implement a Promise-like interface. + + then(onfulfilled?: (value: Type) => Type | PromiseLike, onrejected?: (reason: any) => PromiseLike): Promise { + return this._promise.then(onfulfilled, onrejected); + } + + catch(onrejected?: (reason: any) => PromiseLike): Promise { + return this._promise.catch(onrejected); + } + + finally(onfinally?: () => void): Promise { + return this._promise.finally(onfinally); + } + + // And for things that actually need to provide something typed to Promise... well... + get corePromise(): Promise { + return this._promise; + } +} \ No newline at end of file diff --git a/common/web/utils/src/test/managedPromise.js b/common/web/utils/src/test/managedPromise.js new file mode 100644 index 0000000000..c189303276 --- /dev/null +++ b/common/web/utils/src/test/managedPromise.js @@ -0,0 +1,49 @@ +import { assert } from 'chai'; + +import ManagedPromise from '../../build/obj/managedPromise.js'; + +describe("ManagedPromise", () => { + it(' resolution', async () => { + let promise = new ManagedPromise(); + + promise.resolve(); + + await promise.corePromise; + }); + + it(' resolution', async () => { + let promise = new ManagedPromise(); + + promise.resolve("foobar"); + + let text = await promise.corePromise; + + assert.equal(text, "foobar"); + }); + + it('rejection', async () => { + let promise = new ManagedPromise(); + + promise.reject(new Error("foobar")); + + try { + await promise.corePromise; + assert.fail(); + } catch (err) { + assert.equal(err.message, "foobar"); + } + }); + + it('then()', async () => { + let promise = new ManagedPromise(); + + let thenPromise = promise.then((val) => { + return {text: val + "bar"} + }); + + promise.resolve("foo"); + let val = await thenPromise; + + assert.deepEqual(val, {text: "foobar"}); + }); +}); \ No newline at end of file diff --git a/common/web/keyboard-processor/tests/cases/versions.js b/common/web/utils/src/test/versions.js similarity index 59% rename from common/web/keyboard-processor/tests/cases/versions.js rename to common/web/utils/src/test/versions.js index 2e7f6ae142..e6cf422dc4 100644 --- a/common/web/keyboard-processor/tests/cases/versions.js +++ b/common/web/utils/src/test/versions.js @@ -1,33 +1,31 @@ -var assert = require('chai').assert; -let KeyboardProcessor = require('../../build/index.bundled.js'); +import { assert } from 'chai'; -// Required initialization setup. -global.com = KeyboardProcessor.com; // exports all keyboard-processor namespacing. +import Version from '../../build/obj/version.js'; describe('Version Logic', function() { it('Should provide a default, fallback value when nothing is specified', function() { - var fallback = new com.keyman.utils.Version(undefined); - assert.isTrue(fallback.equals(com.keyman.utils.Version.DEVELOPER_VERSION_FALLBACK)); + var fallback = new Version(undefined); + assert.isTrue(fallback.equals(Version.DEVELOPER_VERSION_FALLBACK)); }); it('Should properly process a simple major.minor version string.', function() { - var version = new com.keyman.utils.Version("1.2"); + var version = new Version("1.2"); assert.equal(version.major, 1); assert.equal(version.minor, 2); }); it('Should handle long/deep version specifications.', function() { - var version = new com.keyman.utils.Version("1.2.3.4.5.6"); + var version = new Version("1.2.3.4.5.6"); assert.equal(version.components.length, 6); assert.equal(version.major, 1); assert.equal(version.minor, 2); }); it('Should properly compare two versions.', function() { - var v9_0_1 = new com.keyman.utils.Version("9.0.1"); - var v9_1_0 = new com.keyman.utils.Version("9.1.0"); - var v10_0 = new com.keyman.utils.Version("10.0"); - var v10_0_0 = new com.keyman.utils.Version("10.0.0"); + var v9_0_1 = new Version("9.0.1"); + var v9_1_0 = new Version("9.1.0"); + var v10_0 = new Version("10.0"); + var v10_0_0 = new Version("10.0.0"); // "Precede" checks assert.equal(v9_0_1.compareTo(v9_1_0), -1); diff --git a/common/web/utils/src/version.ts b/common/web/utils/src/version.ts index ab392938c1..27e865ee1e 100644 --- a/common/web/utils/src/version.ts +++ b/common/web/utils/src/version.ts @@ -1,112 +1,109 @@ -// Ensure that this class contains no reference into core KMW code - it is referenced -// by components intended to be modular and possible to separate from core KMW. - -namespace com.keyman.utils { - // Dotted-decimal version - export class Version { - public static readonly CURRENT = new Version(com.keyman.KEYMAN_VERSION.VERSION_RELEASE); - - // Represents a default version value for keyboards compiled before this was compiled into keyboards. - // The exact version is unknown at this point, but the value is "good enough" for what we need. - public static readonly DEVELOPER_VERSION_FALLBACK = new Version([9, 0, 0]); - - // For 12.0, the old default behavior of adding missing keycaps to the default layers was removed, - // as it results in unexpected, bug-like behavior for keyboard designers when it is unwanted. - public static readonly NO_DEFAULT_KEYCAPS = new Version([12, 0]); - - public static readonly MAC_POSSIBLE_IPAD_ALIAS = new Version([10, 15]); - - private readonly components: number[] - - /** - * Parses version information, preparing it for use in comparisons. - * @param text Either a string representing a version number (ex: "9.0.0") or an array representing - * its components (ex: [9, 0, 0]). - */ - constructor(text: String | number[]) { - // If a keyboard doesn't specify a version, use the DEVELOPER_VERSION_FALLBACK values. - if(text === undefined || text === null) { - this.components = [].concat(Version.DEVELOPER_VERSION_FALLBACK.components); +import KEYMAN_VERSION from "@keymanapp/keyman-version"; + +// Dotted-decimal version +export default class Version { + public static readonly CURRENT = new Version(KEYMAN_VERSION.VERSION_RELEASE); + + // Represents a default version value for keyboards compiled before this was compiled into keyboards. + // The exact version is unknown at this point, but the value is "good enough" for what we need. + public static readonly DEVELOPER_VERSION_FALLBACK = new Version([9, 0, 0]); + + // For 12.0, the old default behavior of adding missing keycaps to the default layers was removed, + // as it results in unexpected, bug-like behavior for keyboard designers when it is unwanted. + public static readonly NO_DEFAULT_KEYCAPS = new Version([12, 0]); + + public static readonly MAC_POSSIBLE_IPAD_ALIAS = new Version([10, 15]); + + private readonly components: number[] + + /** + * Parses version information, preparing it for use in comparisons. + * @param text Either a string representing a version number (ex: "9.0.0") or an array representing + * its components (ex: [9, 0, 0]). + */ + constructor(text: String | number[]) { + // If a keyboard doesn't specify a version, use the DEVELOPER_VERSION_FALLBACK values. + if(text === undefined || text === null) { + this.components = [].concat(Version.DEVELOPER_VERSION_FALLBACK.components); + return; + } + + if(Array.isArray(text)) { + let components = text as number[]; + if(components.length < 2) { + throw new Error("Version string must have at least a major and minor component!"); + } else { + this.components = [].concat(components); return; } + } - if(Array.isArray(text)) { - let components = text as number[]; - if(components.length < 2) { - throw new Error("Version string must have at least a major and minor component!"); - } else { - this.components = [].concat(components); - return; - } - } + // else, standard constructor path. + let parts = text.split('.'); + let componentArray: number[] = []; - // else, standard constructor path. - let parts = text.split('.'); - let componentArray: number[] = []; + if(parts.length < 2) { + throw new Error("Version string must have at least a major and minor component!"); + } - if(parts.length < 2) { - throw new Error("Version string must have at least a major and minor component!"); + for(let i=0; i < parts.length; i++) { + let value = parseInt(parts[i], 10); + if(isNaN(value)) { + throw new Error("Version string components must be numerical!"); } - for(let i=0; i < parts.length; i++) { - let value = parseInt(parts[i], 10); - if(isNaN(value)) { - throw new Error("Version string components must be numerical!"); - } + componentArray.push(value); + } - componentArray.push(value); - } + this.components = componentArray; + } - this.components = componentArray; - } + get major(): number { + return this.components[0]; + } - get major(): number { - return this.components[0]; - } + get minor(): number { + return this.components[1]; + } - get minor(): number { - return this.components[1]; - } + toString(): string { + return this.components.join('.'); + } - toString(): string { - return this.components.join('.'); - } + toJSON(): string { + return this.toString(); + } - toJSON(): string { - return this.toString(); - } + equals(other: Version): boolean { + return this.compareTo(other) == 0; + } - equals(other: Version): boolean { - return this.compareTo(other) == 0; - } + precedes(other: Version): boolean { + return this.compareTo(other) < 0; + } - precedes(other: Version): boolean { - return this.compareTo(other) < 0; - } + compareTo(other: Version): number { + // If the version info depth differs, we need a flag to indicate which instance is shorter. + var isShorter: boolean = this.components.length < other.components.length; + var maxDepth: number = (this.components.length < other.components.length) ? this.components.length : other.components.length; - compareTo(other: Version): number { - // If the version info depth differs, we need a flag to indicate which instance is shorter. - var isShorter: boolean = this.components.length < other.components.length; - var maxDepth: number = (this.components.length < other.components.length) ? this.components.length : other.components.length; - - var i: number; - for(i = 0; i < maxDepth; i++) { - let delta = this.components[i] - other.components[i]; - if(delta != 0) { - return delta; - } + var i: number; + for(i = 0; i < maxDepth; i++) { + let delta = this.components[i] - other.components[i]; + if(delta != 0) { + return delta; } + } - var longList = isShorter ? other.components : this.components; - do { - if(longList[i] > 0) { - return isShorter ? -1 : 1; - } - i++; - } while (i < longList.length); + var longList = isShorter ? other.components : this.components; + do { + if(longList[i] > 0) { + return isShorter ? -1 : 1; + } + i++; + } while (i < longList.length); - // Equal. - return 0; - } + // Equal. + return 0; } } \ No newline at end of file diff --git a/common/web/utils/tsconfig.json b/common/web/utils/tsconfig.json index 4bdd955c5f..838a704dfb 100644 --- a/common/web/utils/tsconfig.json +++ b/common/web/utils/tsconfig.json @@ -2,19 +2,26 @@ "extends": "../../../tsconfig-base.json", "compilerOptions": { "allowJs": true, - "module": "none", + "module": "es6", + "moduleResolution": "Node16", "inlineSources": true, "sourceMap": true, "declaration": true, "target": "es5", + "tsBuildInfoFile": "./build/obj/tsconfig.tsbuildinfo", "types": ["node"], "lib": ["es6"], - "outFile": "build/index.js" + "baseUrl": "./", + "outDir": "./build/obj/", + "rootDir": "./src" }, "references": [ { "path": "../keyman-version"} ], "include": [ "src/*.ts" + ], + "exclude": [ + "src/test/**/*.js" ] } diff --git a/developer/src/kmc-keyboard/test/tsconfig.json b/developer/src/kmc-keyboard/test/tsconfig.json index 263cdd0d94..81dbf53c58 100644 --- a/developer/src/kmc-keyboard/test/tsconfig.json +++ b/developer/src/kmc-keyboard/test/tsconfig.json @@ -18,7 +18,7 @@ "./helpers/index.ts" ], "references": [ - { "path": "../../../../common/web/keyman-version/tsconfig.esm.json" }, + { "path": "../../../../common/web/keyman-version" }, { "path": "../../../../common/web/types/" }, { "path": "../../../../common/tools/hextobin/" }, { "path": "../" } diff --git a/developer/src/kmc-keyboard/tsconfig.json b/developer/src/kmc-keyboard/tsconfig.json index a9dcdbbb2a..d7c0d4841e 100644 --- a/developer/src/kmc-keyboard/tsconfig.json +++ b/developer/src/kmc-keyboard/tsconfig.json @@ -18,7 +18,7 @@ "src/**/*.ts" ], "references": [ - { "path": "../../../common/web/keyman-version/tsconfig.esm.json" }, + { "path": "../../../common/web/keyman-version" }, { "path": "../../../common/web/types/" }, { "path": "../../../core/include/ldml/"}, ] diff --git a/developer/src/kmc-model/src/main.ts b/developer/src/kmc-model/src/main.ts index 5b1bb7fe7f..524154117b 100644 --- a/developer/src/kmc-model/src/main.ts +++ b/developer/src/kmc-model/src/main.ts @@ -5,6 +5,8 @@ import ts from 'typescript'; import LexicalModelCompiler from './lexical-model-compiler.js'; import { LexicalModelSource } from './lexical-model.js'; +export { default as LexicalModelCompiler } from './lexical-model-compiler.js'; + /** * Compiles a model.ts file, using paths relative to its location. * @@ -40,8 +42,8 @@ export function loadFromFilename(filename: string): LexicalModelSource { // It DOES NOT check for types! let compilationOutput = ts.transpile(sourceCode, { // Our runtime only supports ES3 with Node/CommonJS modules on Android 5.0. - // When we drop Android 5.0 support, we can update this to a `ScriptTarget` - // matrix against target version of Keyman, here and in + // When we drop Android 5.0 support, we can update this to a `ScriptTarget` + // matrix against target version of Keyman, here and in // lexical-model-compiler.ts. target: ts.ScriptTarget.ES3, module: ts.ModuleKind.CommonJS, diff --git a/developer/src/kmc-model/tsconfig.json b/developer/src/kmc-model/tsconfig.json index e48ec745f9..3f75653c5c 100644 --- a/developer/src/kmc-model/tsconfig.json +++ b/developer/src/kmc-model/tsconfig.json @@ -11,7 +11,7 @@ "src/**/*.ts" ], "references": [ - { "path": "../../../common/web/keyman-version/tsconfig.esm.json" }, + { "path": "../../../common/web/keyman-version" }, { "path": "../../../common/models/types" }, ] } diff --git a/developer/src/kmc-package/test/tsconfig.json b/developer/src/kmc-package/test/tsconfig.json index d785ebc019..b8b629e51c 100644 --- a/developer/src/kmc-package/test/tsconfig.json +++ b/developer/src/kmc-package/test/tsconfig.json @@ -14,6 +14,6 @@ ], "references": [ { "path": "../" }, - { "path": "../../../../common/web/keyman-version/tsconfig.esm.json" }, + { "path": "../../../../common/web/keyman-version" }, ] } \ No newline at end of file diff --git a/developer/src/kmc-package/tsconfig.json b/developer/src/kmc-package/tsconfig.json index b7044ed6ee..9ca4677c6f 100644 --- a/developer/src/kmc-package/tsconfig.json +++ b/developer/src/kmc-package/tsconfig.json @@ -10,6 +10,6 @@ "src/**/*.ts" ], "references": [ - { "path": "../../../common/web/keyman-version/tsconfig.esm.json" }, + { "path": "../../../common/web/keyman-version" }, ] } diff --git a/developer/src/kmc/tsconfig.json b/developer/src/kmc/tsconfig.json index 41d2ab3428..51ef89cff1 100644 --- a/developer/src/kmc/tsconfig.json +++ b/developer/src/kmc/tsconfig.json @@ -17,7 +17,7 @@ "src/**/*.ts" ], "references": [ - { "path": "../../../common/web/keyman-version/tsconfig.esm.json" }, + { "path": "../../../common/web/keyman-version" }, { "path": "../../../common/web/types" }, { "path": "../kmc-keyboard" }, { "path": "../kmc-model" }, diff --git a/docs/build/macos.md b/docs/build/macos.md index d8e4f61696..d670001e07 100644 --- a/docs/build/macos.md +++ b/docs/build/macos.md @@ -66,7 +66,7 @@ These dependencies are also listed below if you'd prefer to install manually. PATH="$HOMEBREW_PREFIX/opt/coreutils/libexec/gnubin:$PATH" ``` -* Web: node.js, emscripten, openjdk 8 +* Web: node.js 18+, emscripten, openjdk 8 ```shell brew install node emscripten openjdk@8 diff --git a/docs/build/windows.md b/docs/build/windows.md index 25889345cf..ef190dd96e 100644 --- a/docs/build/windows.md +++ b/docs/build/windows.md @@ -176,7 +176,7 @@ You can use Windows Settings to add these environment variables permanently: **Requirements**: * emscripten 2.0.23+ -* node.js 14+ +* node.js 18+ * openjdk 8+ ```ps1 diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 27495bd51e..62ce6836d0 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -397,7 +397,7 @@ extension KeymanWebViewController { webView!.evaluateJavaScript("enableSuggestions(\(stubString), \(predict), \(correct))") self.activeModel = predict } else { // We're registering a model in the background - don't change settings. - webView!.evaluateJavaScript("keyman.registerModel(\(stubString));", completionHandler: nil) + webView!.evaluateJavaScript("keyman.addModel(\(stubString));", completionHandler: nil) } setBannerHeight(to: Int(InputViewController.topBarHeight)) diff --git a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js index 63caa18869..49a3dd7216 100644 --- a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js +++ b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js @@ -45,8 +45,8 @@ function init() { kmw.init({'app':device,'fonts':'fonts/'});//.then(function() { kmw['util']['setOption']('attachType','manual'); kmw['oninserttext'] = insertText; - kmw['showKeyboardList'] = menuKeyDown; - kmw['hideKeyboard'] = hideKeyboard; + window['showKeyboardList'] = menuKeyDown; + window['hideKeyboard'] = hideKeyboard; kmw['getOskHeight'] = getOskHeight; kmw['getOskWidth'] = getOskWidth; kmw['beepKeyboard'] = beepKeyboard; @@ -129,9 +129,9 @@ function getOskHeight() { var keyboardOffset = 0; function setKeymanLanguage(stub) { var kmw = window.keyman; - + KeymanWeb.registerStub(stub); - + kmw.setActiveKeyboard(stub.KP + '::' + stub.KI, stub.KLC).then(function() { kmw.osk.show(true); doResetContext(); @@ -316,10 +316,8 @@ function toHex(theString) { function enableSuggestions(model, mayPredict, mayCorrect) { // Set the options first so that KMW's ModelManager can properly handle model enablement states // the moment we actually register the new model. - keyman.osk.banner.setOptions({ - 'mayPredict': mayPredict, - 'mayCorrect': mayCorrect - }); + keyman.core.languageProcessor.mayPredict = mayPredict; + keyman.core.languageProcessor.mayCorrect = mayCorrect; keyman.modelManager.register(model); } diff --git a/package-lock.json b/package-lock.json index f30d84dd33..fe82c29951 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "developer/src/kmc", "developer/src/server", "common/models/*", + "common/test/resources", "common/tools/*", "common/web/*", "common/predictive-text", @@ -30,6 +31,7 @@ }, "devDependencies": { "chai": "^4.3.4", + "esbuild": "^0.15.15", "mocha": "^10.0.0", "mocha-teamcity-reporter": "^4.0.0", "ts-node": "^10.9.1", @@ -40,7 +42,10 @@ "name": "@keymanapp/models-templates", "license": "MIT", "dependencies": { - "@keymanapp/models-wordbreakers": "*" + "@keymanapp/keyman-version": "*", + "@keymanapp/models-types": "*", + "@keymanapp/models-wordbreakers": "*", + "@keymanapp/resources-gosh": "*" }, "devDependencies": { "@keymanapp/models-types": "*", @@ -73,6 +78,7 @@ }, "devDependencies": { "@keymanapp/models-types": "*", + "@keymanapp/resources-gosh": "*", "@types/chai": "^4.2.11", "@types/mocha": "^7.0.2", "chai": "^4.3.4", @@ -127,6 +133,22 @@ "dev": true, "license": "MIT" }, + "common/test/resources": { + "name": "@keymanapp/common-test-resources", + "license": "MIT", + "devDependencies": { + "@keymanapp/resources-gosh": "*", + "@types/node": "^10.17.21", + "chai": "^4.3.4", + "typescript": "^4.9.5" + } + }, + "common/test/resources/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "dev": true + }, "common/tools/hextobin": { "name": "@keymanapp/hextobin", "license": "MIT", @@ -212,6 +234,12 @@ "devDependencies": { "@keymanapp/resources-gosh": "*", "chai": "^4.3.4", + "karma": "^6.4.1", + "karma-chai": "^0.1.0", + "karma-chrome-launcher": "^2.2.0", + "karma-mocha": "^2.0.1", + "karma-mocha-reporter": "^2.2.5", + "karma-teamcity-reporter": "^1.1.0", "mocha": "^10.0.0", "mocha-teamcity-reporter": "^4.0.0", "ts-node": "^10.9.1", @@ -245,16 +273,19 @@ "@keymanapp/keyman-version": "*", "@keymanapp/models-templates": "*", "@keymanapp/models-wordbreakers": "*", + "@keymanapp/sourcemap-path-remapper": "*", "@keymanapp/web-utils": "*", "es6-shim": "^0.35.5", "string.prototype.codepointat": "^0.2.1", "string.prototype.startswith": "^0.2.0" }, "devDependencies": { + "@keymanapp/common-test-resources": "*", "@keymanapp/models-types": "*", "@keymanapp/resources-gosh": "*", "@types/node": "^10.17.21", "chai": "^4.3.4", + "combine-source-map": "^0.8.0", "karma": "^6.4.1", "karma-browserstack-launcher": "^1.6.0", "karma-chai": "^0.1.0", @@ -554,6 +585,9 @@ "@keymanapp/keyman-version": "*", "@keymanapp/resources-gosh": "*", "@types/node": "^14.0.5", + "chai": "^4.3.4", + "mocha": "^10.0.0", + "mocha-teamcity-reporter": "^4.0.0", "typescript": "^4.9.5" } }, @@ -1691,6 +1725,38 @@ "node": ">=12" } }, + "node_modules/@esbuild/android-arm": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.15.tgz", + "integrity": "sha512-JJjZjJi2eBL01QJuWjfCdZxcIgot+VoK6Fq7eKF9w4YHm9hwl7nhBR1o2Wnt/WcANk5l9SkpvrldW1PLuXxcbw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.15.tgz", + "integrity": "sha512-lhz6UNPMDXUhtXSulw8XlFAtSYO26WmHQnCi2Lg2p+/TMiJKNLtZCYUxV4wG6rZMzXmr8InGpNwk+DLT2Hm0PA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -1731,6 +1797,10 @@ "resolved": "resources/build/version", "link": true }, + "node_modules/@keymanapp/common-test-resources": { + "resolved": "common/test/resources", + "link": true + }, "node_modules/@keymanapp/common-types": { "resolved": "common/web/types", "link": true @@ -3116,6 +3186,33 @@ "color-support": "bin.js" } }, + "node_modules/combine-source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.8.0.tgz", + "integrity": "sha512-UlxQ9Vw0b/Bt/KYwCFqdEwsQ1eL8d1gibiFb7lxQJFdvTgc2hIZi6ugsg+kyhzhPV+QEpUiEIwInIAIrgoEkrg==", + "dev": true, + "dependencies": { + "convert-source-map": "~1.1.0", + "inline-source-map": "~0.6.0", + "lodash.memoize": "~3.0.3", + "source-map": "~0.5.3" + } + }, + "node_modules/combine-source-map/node_modules/convert-source-map": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.1.3.tgz", + "integrity": "sha512-Y8L5rp6jo+g9VEPgvqNfEopjTR4OTYct8lXlS8iVQdmnjDvbdbzYe9rjtFCB9egC86JoNCU61WRY+ScjkZpnIg==", + "dev": true + }, + "node_modules/combine-source-map/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/component-emitter": { "version": "1.3.0", "dev": true, @@ -3564,10 +3661,11 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.15.18", + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.15.tgz", + "integrity": "sha512-TEw/lwK4Zzld9x3FedV6jy8onOUHqcEX3ADFk4k+gzPUwrxn8nWV62tH0udo8jOtjFodlEfc4ypsqX3e+WWO6w==", "dev": true, "hasInstallScript": true, - "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -3575,37 +3673,70 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/android-arm": "0.15.18", - "@esbuild/linux-loong64": "0.15.18", - "esbuild-android-64": "0.15.18", - "esbuild-android-arm64": "0.15.18", - "esbuild-darwin-64": "0.15.18", - "esbuild-darwin-arm64": "0.15.18", - "esbuild-freebsd-64": "0.15.18", - "esbuild-freebsd-arm64": "0.15.18", - "esbuild-linux-32": "0.15.18", - "esbuild-linux-64": "0.15.18", - "esbuild-linux-arm": "0.15.18", - "esbuild-linux-arm64": "0.15.18", - "esbuild-linux-mips64le": "0.15.18", - "esbuild-linux-ppc64le": "0.15.18", - "esbuild-linux-riscv64": "0.15.18", - "esbuild-linux-s390x": "0.15.18", - "esbuild-netbsd-64": "0.15.18", - "esbuild-openbsd-64": "0.15.18", - "esbuild-sunos-64": "0.15.18", - "esbuild-windows-32": "0.15.18", - "esbuild-windows-64": "0.15.18", - "esbuild-windows-arm64": "0.15.18" + "@esbuild/android-arm": "0.15.15", + "@esbuild/linux-loong64": "0.15.15", + "esbuild-android-64": "0.15.15", + "esbuild-android-arm64": "0.15.15", + "esbuild-darwin-64": "0.15.15", + "esbuild-darwin-arm64": "0.15.15", + "esbuild-freebsd-64": "0.15.15", + "esbuild-freebsd-arm64": "0.15.15", + "esbuild-linux-32": "0.15.15", + "esbuild-linux-64": "0.15.15", + "esbuild-linux-arm": "0.15.15", + "esbuild-linux-arm64": "0.15.15", + "esbuild-linux-mips64le": "0.15.15", + "esbuild-linux-ppc64le": "0.15.15", + "esbuild-linux-riscv64": "0.15.15", + "esbuild-linux-s390x": "0.15.15", + "esbuild-netbsd-64": "0.15.15", + "esbuild-openbsd-64": "0.15.15", + "esbuild-sunos-64": "0.15.15", + "esbuild-windows-32": "0.15.15", + "esbuild-windows-64": "0.15.15", + "esbuild-windows-arm64": "0.15.15" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.15.tgz", + "integrity": "sha512-F+WjjQxO+JQOva3tJWNdVjouFMLK6R6i5gjDvgUthLYJnIZJsp1HlF523k73hELY20WPyEO8xcz7aaYBVkeg5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.15.tgz", + "integrity": "sha512-attlyhD6Y22jNyQ0fIIQ7mnPvDWKw7k6FKnsXlBvQE6s3z6s6cuEHcSgoirquQc7TmZgVCK5fD/2uxmRN+ZpcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, "node_modules/esbuild-darwin-64": { - "version": "0.15.18", + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.15.tgz", + "integrity": "sha512-ohZtF8W1SHJ4JWldsPVdk8st0r9ExbAOSrBOh5L+Mq47i696GVwv1ab/KlmbUoikSTNoXEhDzVpxUR/WIO19FQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -3614,6 +3745,278 @@ "node": ">=12" } }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.15.tgz", + "integrity": "sha512-P8jOZ5zshCNIuGn+9KehKs/cq5uIniC+BeCykvdVhx/rBXSxmtj3CUIKZz4sDCuESMbitK54drf/2QX9QHG5Ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.15.tgz", + "integrity": "sha512-KkTg+AmDXz1IvA9S1gt8dE24C8Thx0X5oM0KGF322DuP+P3evwTL9YyusHAWNsh4qLsR80nvBr/EIYs29VSwuA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.15.tgz", + "integrity": "sha512-FUcML0DRsuyqCMfAC+HoeAqvWxMeq0qXvclZZ/lt2kLU6XBnDA5uKTLUd379WYEyVD4KKFctqWd9tTuk8C/96g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.15.tgz", + "integrity": "sha512-q28Qn5pZgHNqug02aTkzw5sW9OklSo96b5nm17Mq0pDXrdTBcQ+M6Q9A1B+dalFeynunwh/pvfrNucjzwDXj+Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.15.tgz", + "integrity": "sha512-217KPmWMirkf8liO+fj2qrPwbIbhNTGNVtvqI1TnOWJgcMjUWvd677Gq3fTzXEjilkx2yWypVnTswM2KbXgoAg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.15.tgz", + "integrity": "sha512-RYVW9o2yN8yM7SB1yaWr378CwrjvGCyGybX3SdzPHpikUHkME2AP55Ma20uNwkNyY2eSYFX9D55kDrfQmQBR4w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.15.tgz", + "integrity": "sha512-/ltmNFs0FivZkYsTzAsXIfLQX38lFnwJTWCJts0IbCqWZQe+jjj0vYBNbI0kmXLb3y5NljiM5USVAO1NVkdh2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.15.tgz", + "integrity": "sha512-PksEPb321/28GFFxtvL33yVPfnMZihxkEv5zME2zapXGp7fA1X2jYeiTUK+9tJ/EGgcNWuwvtawPxJG7Mmn86A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.15.tgz", + "integrity": "sha512-ek8gJBEIhcpGI327eAZigBOHl58QqrJrYYIZBWQCnH3UnXoeWMrMZLeeZL8BI2XMBhP+sQ6ERctD5X+ajL/AIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.15.tgz", + "integrity": "sha512-H5ilTZb33/GnUBrZMNJtBk7/OXzDHDXjIzoLXHSutwwsLxSNaLxzAaMoDGDd/keZoS+GDBqNVxdCkpuiRW4OSw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.15.tgz", + "integrity": "sha512-jKaLUg78mua3rrtrkpv4Or2dNTJU7bgHN4bEjT4OX4GR7nLBSA9dfJezQouTxMmIW7opwEC5/iR9mpC18utnxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.15.tgz", + "integrity": "sha512-aOvmF/UkjFuW6F36HbIlImJTTx45KUCHJndtKo+KdP8Dhq3mgLRKW9+6Ircpm8bX/RcS3zZMMmaBLkvGY06Gvw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.15.tgz", + "integrity": "sha512-HFFX+WYedx1w2yJ1VyR1Dfo8zyYGQZf1cA69bLdrHzu9svj6KH6ZLK0k3A1/LFPhcEY9idSOhsB2UyU0tHPxgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.15.tgz", + "integrity": "sha512-jOPBudffG4HN8yJXcK9rib/ZTFoTA5pvIKbRrt3IKAGMq1EpBi4xoVoSRrq/0d4OgZLaQbmkHp8RO9eZIn5atA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.15.tgz", + "integrity": "sha512-MDkJ3QkjnCetKF0fKxCyYNBnOq6dmidcwstBVeMtXSgGYTy8XSwBeIE4+HuKiSsG6I/mXEb++px3IGSmTN0XiA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.15.tgz", + "integrity": "sha512-xaAUIB2qllE888SsMU3j9nrqyLbkqqkpQyWVkfwSil6BBPgcPk3zOFitTTncEKCLTQy3XV9RuH7PDj3aJDljWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.15.tgz", + "integrity": "sha512-ttuoCYCIJAFx4UUKKWYnFdrVpoXa3+3WWkXVI6s09U+YjhnyM5h96ewTq/WgQj9LFSIlABQvadHSOQyAVjW5xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/escalade": { "version": "3.1.1", "dev": true, @@ -4450,6 +4853,24 @@ "version": "2.0.4", "license": "ISC" }, + "node_modules/inline-source-map": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.6.2.tgz", + "integrity": "sha512-0mVWSSbNDvedDWIN4wxLsdPM4a7cIPcpyMxj3QZ406QRwQ6ePGB1YIHxVPjqpcUGbWQ5C+nHTwGNWAGvt7ggVA==", + "dev": true, + "dependencies": { + "source-map": "~0.5.3" + } + }, + "node_modules/inline-source-map/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/interpret": { "version": "1.4.0", "dev": true, @@ -5009,6 +5430,12 @@ "version": "4.4.2", "license": "MIT" }, + "node_modules/lodash.memoize": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz", + "integrity": "sha512-eDn9kqrAmVUC1wmZvlQ6Uhde44n+tXpqPrN8olQJbttgh0oKclk+SF54P47VEGE9CEiMeRwAP8BaM7UHvBkz2A==", + "dev": true + }, "node_modules/lodash.set": { "version": "4.3.2", "license": "MIT" diff --git a/package.json b/package.json index f7892d6385..e920ca12d7 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "devDependencies": { "chai": "^4.3.4", + "esbuild": "^0.15.15", "mocha": "^10.0.0", "mocha-teamcity-reporter": "^4.0.0", "ts-node": "^10.9.1", @@ -20,6 +21,7 @@ "developer/src/kmc", "developer/src/server", "common/models/*", + "common/test/resources", "common/tools/*", "common/web/*", "common/predictive-text", diff --git a/resources/builder.inc.sh b/resources/builder.inc.sh index 65a8f929af..fe573fdf8c 100755 --- a/resources/builder.inc.sh +++ b/resources/builder.inc.sh @@ -346,12 +346,12 @@ _builder_failure_trap() { fi builder_finish_action failure $action$target - - # Make 100% sure that the exit code chains fully. - # Without this, nested scripts have failed to chain errors from npm calls past the script - # that directly executed the failed npm command. - exit $trappedExitCode fi + + # Make 100% sure that the exit code chains fully. + # Without this, nested scripts have failed to chain errors from npm calls past the script + # that directly executed the failed npm command. + exit $trappedExitCode } # @@ -668,6 +668,30 @@ _builder_expand_action_targets() { fi } +_builder_child_base= +# +# Describes the path from the build script's working directory to the common subfolder +# containing child scripts / projects without defined custom paths. +# +# This function must be called to set the child base path before builder_describe is +# called in order to work correctly. Furthermore, note that this setting will be +# ignored by targets with custom paths. +# +# ### Usage +# +# ```bash +# builder_set_child_base path +# ``` +# +# ### Parameters +# +# * `path` The relative path from the directory containing the calling script to +# the base folder to use for child-project detection and resolution +# +builder_set_child_base() { + _builder_child_base="$1/" +} + # # Describes a build script, defines available parameters and their meanings. Use # together with `builder_parse` to process input parameters. @@ -784,8 +808,8 @@ builder_describe() { else # If the target name matches a folder name, implicitly # make it available as a child project - if [[ -d "$THIS_SCRIPT_PATH/${value:1}" ]]; then - target_path="${value:1}" + if [[ -d "$THIS_SCRIPT_PATH/$_builder_child_base${value:1}" ]]; then + target_path="$_builder_child_base${value:1}" fi fi _builder_targets+=($value) @@ -902,8 +926,8 @@ builder_describe() { # # ```bash # builder_describe_outputs \ -# "configure" "/node_modules" \ -# "build" "build/index.js" +# configure /node_modules \ +# build build/index.js # ``` # function builder_describe_outputs() { @@ -1294,6 +1318,7 @@ _builder_parse_expanded_parameters() { _builder_report_dependencies ;; *) + # script does not recognize anything of action or target form at this point. _builder_parameter_error "$0" parameter "$key" esac fi diff --git a/tsconfig-base.json b/tsconfig-base.json index 8fb2142411..8e3eafb6cf 100644 --- a/tsconfig-base.json +++ b/tsconfig-base.json @@ -12,10 +12,11 @@ "paths": { "@keymanapp/input-processor": ["./common/web/input-processor/src"], "@keymanapp/keyboard-processor": ["./common/web/keyboard-processor/src"], + "@keymanapp/keyman": ["./web" ], "@keymanapp/models-types": ["./common/models/types"], "@keymanapp/models-templates": ["./common/models/templates"], "@keymanapp/models-wordbreakers": ["./common/models/wordbreakers"], - "@keymanapp/utils": ["./common/web/utils"], + "@keymanapp/web-utils": ["./common/web/utils"], "@keymanapp/lm-message-types": ["./common/web/lm-message-types"], "@keymanapp/keyman-version": ["./common/web/keyman-version"], } diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index 1a342edea0..87aadd5c9b 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -5,18 +5,15 @@ "files": [], "include": [], "references": [ - { "path": "./common/web/input-processor/src/tsconfig.json" }, - { "path": "./common/web/keyboard-processor/src/tsconfig.json" }, - { "path": "./common/web/recorder/src/nodeProctor.tsconfig.json" }, - { "path": "./common/web/recorder/src/tsconfig.json" }, + { "path": "./common/web/input-processor/tsconfig.json" }, + { "path": "./common/web/keyboard-processor/tsconfig.json" }, + { "path": "./common/web/recorder/tsconfig.json" }, { "path": "./common/web/sentry-manager/src/tsconfig.json" }, { "path": "./common/web/utils/tsconfig.json" }, { "path": "./common/models/templates/tsconfig.json" }, { "path": "./common/models/types/tsconfig.json" }, { "path": "./common/models/wordbreakers/tsconfig.json" }, - - { "path": "./common/predictive-text/browser.tsconfig.json" }, { "path": "./common/predictive-text/testing/one-stage-embedded-webworker/tsconfig.json" }, { "path": "./common/predictive-text/testing/two-stage-embedded-webworker/tsconfig.json" }, { "path": "./common/predictive-text/testing/two-stage-embedded-webworker/worker/tsconfig.json" }, @@ -26,12 +23,10 @@ { "path": "./resources/build/version/tsconfig.json" }, { "path": "./resources/build/version/tsconfig.production.json" }, - - { "path": "./web/bulk_rendering/tsconfig.json" }, - { "path": "./web/source/tsconfig.json" }, - { "path": "./web/tools/recorder/tsconfig.json" }, - { "path": "./web/tools/sourcemap-root/tsconfig.json" }, - + // { "path": "./web/bulk_rendering/tsconfig.json" }, + { "path": "./web/src/tsconfig.all.json" }, + // { "path": "./web/tools/recorder/tsconfig.json" }, + // { "path": "./web/tools/sourcemap-root/tsconfig.json" }, { "path": "./common/web/lm-message-types/" }, { "path": "./common/web/lm-worker/" }, { "path": "./common/web/keyman-version/" }, diff --git a/tsconfig.esm.json b/tsconfig.esm.json index 636b3ff0c1..412d6d4b50 100644 --- a/tsconfig.esm.json +++ b/tsconfig.esm.json @@ -19,7 +19,7 @@ { "path": "./developer/src/kmc-package/test/tsconfig.json" }, { "path": "./developer/src/kmc-package/tsconfig.json" }, - { "path": "./common/web/keyman-version/tsconfig.esm.json" }, + { "path": "./common/web/keyman-version" }, { "path": "./common/web/types/" }, ] } \ No newline at end of file diff --git a/web/README.md b/web/README.md index 4f49dbdf08..cf82d2132a 100644 --- a/web/README.md +++ b/web/README.md @@ -9,19 +9,18 @@ configure your build environment. The following folders contain the distribution for Keyman Engine for Web: - source Source code - source/resources/osk OSK resources for inclusion in mobile app builds; - keymanweb-osk.ttf is maintained at https://github.com/silnrsi/font-keymanweb-osk - unit_tests Automated testing resources + 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 - build/web/release Fully-compiled KeymanWeb modules for release - build/embed/release Fully-compiled KMEA/KMEI modules for inclusion in mobile app builds - build/web/debug Fully-compiled but non-minified KeymanWeb modules - build/embed/debug Fully-compiled but non-minified KMEA/KMEI modules + 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 - samples Sample pages demonstrating ways to link with KeymanWeb - testing Test-case web-pages for various aspects of KeymanWeb functionality - unit_tests 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 ********************************************************************** @@ -32,7 +31,7 @@ compile Keyman Engine for Web before viewing the pages. Refer to the samples for usage details. To view pages using compiled Keyman Engine for Web, -1. cd to **keyman/web/source** +1. cd to **keyman/web/** 2. Run `./build.sh` - Use `./build.sh --help` for the script's documentation. @@ -42,18 +41,18 @@ Before running unit tests on Keyman Engine for Web, first run `./build.sh` according to the instructions above. Once the build is complete, running `npm test` will run the unit testing suite -on your local machine in-browser. Alternatively, see `unit_tests/test.sh`, which +on your local machine in-browser. Alternatively, see `test.sh`, which the former command executes. ### Debugging Unit Tests 1. During development, to run a specific unit test, change the `it` to `it.only`. You can also run all tests under a specific group with `describe.only`. -2. From this directory, run `./unit_tests/test.sh -debug`. Alternatively, from +2. From this directory, run `./test.sh --debug`. Alternatively, from `web/` or any `web/` subdirectory, ``` - npm run test -- -debug + npm run test -- --debug ``` The `--` part tells `npm` to funnel anything to the script as the script's diff --git a/web/build.sh b/web/build.sh index e125e4712f..0eeba948f4 100755 --- a/web/build.sh +++ b/web/build.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Compiles the Keyman Engine for Web and its various end-products +# Compile keymanweb and copy compiled javascript and resources to output/embedded folder # set -eu @@ -18,496 +18,90 @@ cd "$THIS_SCRIPT_PATH" # ################################ Main script ################################ -# Definition of global compile constants - # Ensures that we rely first upon the local npm-based install of Typescript. # (Facilitates automated setup for build agents.) # TODO: this should be removeable given set_keyman_standard_build_path does this in build-utils.sh (and relative paths are dodgy in $PATH!) PATH="../node_modules/.bin:$PATH" -PREDICTIVE_TEXT_SOURCE="../common/predictive-text/unit_tests/in_browser/resources/models/simple-trie.js" -PREDICTIVE_TEXT_OUTPUT="src/test/manual/web/prediction-ui/simple-en-trie.js" - -builder_describe "Builds Keyman Engine for Web (KMW)." \ - "@/common/web/keyman-version build" \ - "@/common/web/input-processor build" \ - "@/web/src/tools/building/sourcemap-root build" \ +builder_set_child_base src +builder_describe "Builds engine modules for Keyman Engine for Web (KMW)." \ "clean" \ "configure" \ "build" \ - "test Runs unit tests. Only $(builder_term test:web) is currently defined" \ - ":embed Builds the configuration of KMW used within the Keyman mobile apps" \ - ":engine Builds all common code used by other targets" \ - ":web Builds the website-oriented configuration of Keyman Engine for Web" \ - ":ui Builds the desktop UI modules used by the $(builder_term :web) build product" \ - ":samples Builds only sample & test pages found under src/samples and src/test" \ - ":tools Builds related development + unit-test resources" \ - "--no-minify Skips any minification steps in the build" \ - "--all Sets action to run on KMW's submodules as well if appropriate (actions - $(builder_term test))" + "test" \ + ":app/webview A puppetable version of KMW designed for use in a host app's WebView" \ + ":engine/paths Subset used to configure KMW" \ + ":engine/device-detect Subset used for device-detection " \ + ":engine/dom-utils A common subset of function used for DOM calculations, layout, etc" \ + ":engine/element-wrappers Subset used to integrate with website elements" \ + ":engine/package-cache Subset used to collate keyboards and request them from the cloud" \ + ":engine/main Builds all common code used by KMW's app/-level targets" \ + ":engine/osk Builds the Web OSK module" + +# ":app/browser The website-integrating, browser-based version of KMW" \ # Possible TODO? -# "upload-symbols Uploads build product to Sentry for error report symbolification. Only defined for $(builder_term build:embed) and $(builder_term build:web)" \ +# "upload-symbols Uploads build product to Sentry for error report symbolification. Only defined for $DOC_BUILD_EMBED_WEB" \ builder_describe_outputs \ - configure /node_modules \ - configure:embed /node_modules \ - configure:engine /node_modules \ - configure:web /node_modules \ - configure:ui /node_modules \ - configure:samples /node_modules \ - configure:tools /node_modules \ - build:embed /web/build/app/embed/release/keyman.js \ - build:engine /web/build/engine/main/obj/keymanweb.js \ - build:web /web/build/app/web/release/keymanweb.js \ - build:ui /web/build/app/ui/release/kmwuibutton.js \ - build:samples /web/$PREDICTIVE_TEXT_OUTPUT \ - build:tools /web/build/tools/building/sourcemap-root/index.js - -builder_parse "$@" - -#### Build utility methods + definitions #### - -# Build products for each main target. -EMBED_TARGETS=( "keyman.js" ) -WEB_TARGETS=( "keymanweb.js" ) -UI_TARGETS=( "kmwuibutton.js" "kmwuifloat.js" "kmwuitoggle.js" "kmwuitoolbar.js" ) - -: ${CLOSURECOMPILERPATH:=../node_modules/google-closure-compiler-java} -: ${JAVA:=java} - -minifier="$CLOSURECOMPILERPATH/compiler.jar" - -# We'd love to add the argument --source_map_include_content for distribution in the future, -# but Closure doesn't include the TS sources properly at this time. -# -# `checkTypes` is blocked b/c TypeScript can perform our type checking... and it causes an error -# with TypeScript's `extend` implementation (it doesn't recognize a constructor without manual edits). -# We also get a global `this` warning from the same. -# -# `checkVars` is blocked b/c Closure will otherwise fail on TypeScript namespacing, as each original TS -# source file will redeclare the namespace variable, despite being merged into a single file post-compilation. -# -# `jsDocMissingType` prevents errors on type documentation Closure thinks is missing. TypeScript may not -# have the same requirements, and we trust TypeScript over Closure. -minifier_warnings="--jscomp_error=* --jscomp_off=lintChecks --jscomp_off=unusedLocalVariables --jscomp_off=globalThis --jscomp_off=checkTypes --jscomp_off=checkVars --jscomp_off=jsdocMissingType --jscomp_off=uselessCode --jscomp_off=missingRequire --jscomp_off=strictMissingRequire" - -# We use these to prevent Closure from auto-inserting its own polyfills. Turns out, they can break in the -# WebView used by Android API 19, which our app still supported when written. Unsure if it can still occur -# in Android API 21, our current minimum. -# -# Also, we currently apply all needed polyfills either manually or during TS compilation; we don't need the extra, -# excess code. -minifier_lang_specs="--language_in ECMASCRIPT5 --language_out ECMASCRIPT5" -minifycmd="$JAVA -jar $minifier --compilation_level WHITESPACE_ONLY $minifier_warnings --generate_exports $minifier_lang_specs" - -readonly minifier -readonly minifycmd - -minified_sourcemap_cleaner="build/tools/building/sourcemap-root/index.mjs" - -# Fails the build if a specified file does not exist. -assert_exists ( ) { - if [[ ! -f $1 ]]; then - builder_die "Build failed: expected file ${COLOR_GREY}$1${COLOR_RESET} is missing." - fi -} - -# $1 - base file name -# $2 - output path -# $3 - optimization level -# $4 - extra path info to add to minified sourcemap "sourceRoot" property. -# $5 - additional output wrapper -minify ( ) { - if [ $# -ge 4 ]; then - cleanerOptions="--clean --sourceRoot $4" - else - cleanerOptions="--clean" - fi - - if [ $# -ge 5 ]; then - wrapper=$5 - else - wrapper="%output%" - fi - - local INPUT="$1" - local INPUT_FILE="$(basename $1)" - local INPUT_DIR="$(dirname $1)" - local INPUT_SOURCEMAP="$INPUT_DIR/$INPUT_FILE.map" - local OUTPUT="$2" - local OUTPUT_FILE="$(basename $2)" - local OUTPUT_SOURCEMAP="$(dirname $2)/$OUTPUT_FILE.map" - - # --source_map_location_mapping - maps paths on INPUT source maps for consumption by Closure. - # ../../.. => keymanapp, ../.. => keymanapp/keyman. We have TS root sources on 'keyman'. - $minifycmd --source_map_input "$INPUT|$INPUT_SOURCEMAP" \ - --create_source_map "$OUTPUT_SOURCEMAP" --source_map_include_content \ - --source_map_location_mapping "$INPUT_DIR|../../.." \ - --js "$INPUT" --compilation_level $3 \ - --js_output_file "$OUTPUT" --warning_level VERBOSE --output_wrapper "$wrapper -//# sourceMappingURL=$INPUT_FILE.map" - - # Now to clean the source map. - assert_exists "$OUTPUT" - assert_exists "$OUTPUT_SOURCEMAP" - - # "Clean" the minified output sourcemaps. - node $minified_sourcemap_cleaner "$INPUT_SOURCEMAP" "$OUTPUT_SOURCEMAP" $cleanerOptions -} - -# Copies specified engine resources to the specified target's build output directories. -# -# ### Parameters -# -# * 1: `product` the product's source path under src/ -# * 2: `outputs` an array of resource types to copy over -# -# ### Example -# -# ```bash -# copy_resources app/web osk ui -# ``` -copy_resources ( ) { - local COMPILE_TARGET=$1 - shift - - local RESOURCES_TO_COPY=("$@") - - # We leave out obj here, as it's not a 'release' of any sort and - # thus doesn't need to publish sources or resources. - local CONFIGS=(debug) - - if ! builder_has_option --no-minify; then - CONFIGS+=(release) - fi - - builder_echo - - for CONFIG in "${CONFIGS[@]}"; - do - local CONFIG_OUT_PATH=build/$COMPILE_TARGET/$CONFIG - - builder_echo "Copying resources to $CONFIG_OUT_PATH/src" - - for RESOURCE in "${RESOURCES_TO_COPY[@]}"; - do - mkdir -p "$CONFIG_OUT_PATH/$RESOURCE" - mkdir -p "$CONFIG_OUT_PATH/src/resources/$RESOURCE" - - builder_echo "- src/resources/$RESOURCE/ => $CONFIG_OUT_PATH/$RESOURCE" - cp -Rf "src/resources/$RESOURCE" "$CONFIG_OUT_PATH/" >/dev/null - done - - builder_echo - done -} - -# Copies specified source folders corresponding to the specified target's build -# output directories. -# -# ### Parameters -# -# * 1: `product` the product's source path under src/ -# * 2: `outputs` an array of src/ subfolders to copy over -# -# ### Example -# -# ```bash -# copy_sources app/web engine resources/osk -# ``` -copy_sources ( ) { - local COMPILE_TARGET=$1 - shift - - local SOURCES_TO_COPY=("$@") - - # We leave out obj here, as it's not a 'release' of any sort and - # thus doesn't need to publish sources or resources. - CONFIGS=(debug) - - if ! builder_has_option --no-minify; then - CONFIGS+=(release) - fi - - for CONFIG in "${CONFIGS[@]}"; - do - local CONFIG_OUT_PATH=build/$COMPILE_TARGET/$CONFIG - builder_echo "Copying $COMPILE_TARGET sources to $CONFIG_OUT_PATH/src" - - rm -rf "$CONFIG_OUT_PATH/src" - mkdir -p "$CONFIG_OUT_PATH/src" - echo $VERSION_PATCH > "$CONFIG_OUT_PATH/src/version.txt" - - for SOURCE_FOLDER in "${SOURCES_TO_COPY[@]}"; - do - builder_echo "- src/$SOURCE_FOLDER/ => $CONFIG_OUT_PATH/src/$SOURCE_FOLDER/" - mkdir -p "$CONFIG_OUT_PATH/src/$SOURCE_FOLDER" - cp -Rf "src/$SOURCE_FOLDER/"* "$CONFIG_OUT_PATH/src/$SOURCE_FOLDER/" - done - - builder_echo - done -} - -# Compiles compiled scripts from the first folder specified to the second folder specified. -# -# ### Parameters -# -# * 1: `src` the folder containing scripts to be copied -# * 2: `dst` the destination folder for the copy operation -# * 3: `scripts` an array of filenames for expected compiled scripts -# -# ### Example -# -# ```bash -# compile_and_minify build/app/web/obj build/app/web/debug keymanweb.js -# ``` -copy_outputs ( ) { - local src="$1" - local dst="$2" - - shift - shift - - local BASE_SCRIPTS=("$@") - - mkdir -p "$dst" - - for SCRIPTJS in "${BASE_SCRIPTS[@]}"; - do - cp -Rf "$src/$SCRIPTJS" "$dst/" - cp -Rf "$src/$SCRIPTJS.map" "$dst/" - done -} - -# Compiles all build products corresponding to the specified target. -# -# ### Parameters -# -# * 1: `product` the product's source path under src/ -# -# ### Example -# -# ```bash -# compile app/embed -# ``` -compile ( ) { - local COMPILE_TARGET=$1 - tsc -b src/$COMPILE_TARGET -v || builder_die "Build command tsc -b src/$COMPILE_TARGET -v failed with exit code $?" - builder_echo "$COMPILE_TARGET TypeScript compiled under build/$COMPILE_TARGET/obj" -} - -# Finalizes all build products corresponding to the specified target. -# This should be called after `compile` for all `app/` targets. -# -# ### Parameters -# -# * 1: `product` the product's source path under src/ -# * 2: `outputs` an array of expected output script files for the build -# -# ### Example -# -# ```bash -# compile app/embed -# finalize app/embed keyman.js -# ``` -finalize ( ) { - if [ $# -lt 2 ]; then - builder_die "Scripting error: insufficient argument count!" - fi - - local COMPILE_TARGET=$1 - local COMPILED_INTERMEDIATE_PATH=build/$COMPILE_TARGET/obj - local DEBUG_OUT_PATH=build/$COMPILE_TARGET/debug - local RELEASE_OUT_PATH=build/$COMPILE_TARGET/release + configure /node_modules - shift +# build:app/webview build/app/webview/lib/index.js \ - local OUTPUT_SCRIPTS=("$@") + # TODO: app/ui linkage. - # START: debug output - - mkdir -p "$DEBUG_OUT_PATH" - copy_outputs "$COMPILED_INTERMEDIATE_PATH" "$DEBUG_OUT_PATH" "${OUTPUT_SCRIPTS[@]}" - - builder_echo "Compiled $COMPILE_TARGET debug version saved under $DEBUG_OUT_PATH: ${OUTPUT_SCRIPTS[*]}" - - # START: release output - if ! builder_has_option --no-minify; then - for SCRIPT in "${OUTPUT_SCRIPTS[@]}"; - do - minify "$COMPILED_INTERMEDIATE_PATH/$SCRIPT" "$RELEASE_OUT_PATH/$SCRIPT" SIMPLE_OPTIMIZATIONS - done - - builder_echo "Compiled $COMPILE_TARGET release version saved under $RELEASE_OUT_PATH: ${OUTPUT_SCRIPTS[*]}" - else - # The prior 'release' is now outdated: delete it. - rm -rf "$RELEASE_OUT_PATH" - fi -} +builder_parse "$@" #### Build action definitions #### -if builder_start_action configure; then - verify_npm_setup +##################### TODO: call child action, verify things work as expected! - if ! builder_has_option --no-minify; then - # NPM install is required for the file to be present. - if ! [ -f $minifier ]; - then - builder_die "File $minifier does not exist: have you set the environment variable \$CLOSURECOMPILERPATH?" - fi - fi +# We can run all clean & configure actions at once without much issue. - builder_finish_action success configure -fi +builder_run_child_actions clean ## Clean actions -# Possible issue: there's no clear rule to `clean` the engine, which is auto-built -# by build:embed and build:web. -# -# Some sort of command to run ONLY for a general `clean` (no target specified) would -# be perfect for that, I think. - -if builder_start_action clean:engine; then - src/engine/build.sh clean - builder_finish_action success clean:engine -fi - -if builder_start_action clean:embed; then - rm -rf build/app/embed - builder_finish_action success clean:embed -fi - -if builder_start_action clean:web; then - rm -rf build/app/web - builder_finish_action success clean:web -fi - -if builder_start_action clean:ui; then - rm -rf build/app/ui - builder_finish_action success clean:ui -fi - -if builder_start_action clean:samples; then - rm -f $PREDICTIVE_TEXT_OUTPUT - - builder_finish_action success clean:samples +# If a full-on general clean was requested, we can nuke the entire build folder. +if builder_start_action clean; then + rm -rf ./build + builder_finish_action success clean fi -if builder_start_action clean:tools; then - src/tools/build.sh clean - - builder_finish_action success clean:tools -fi +builder_run_child_actions configure ## Build actions -# Adds a simple version 'header' when there's a main engine build product. -if builder_has_action build:embed || \ - builder_has_action build:web || \ - builder_has_action build:ui; then - - builder_echo "" - builder_echo purple "Compiling version ${VERSION}" -fi - -builder_echo - - -if builder_start_action build:engine; then - src/engine/build.sh build - - builder_finish_action success build:engine -fi - -if builder_start_action build:embed; then - compile app/embed - finalize app/embed ${EMBED_TARGETS[@]} - - # The embedded version doesn't use UI modules. - copy_resources app/embed osk - copy_sources app/embed app/embed engine resources/osk - - builder_finish_action success build:embed - - # TODO: handle this block somehow. - - # if [ $UPLOAD_EMBED_SENTRY = true ]; then # upload-symbols:embed - # if [ $BUILD_DEBUG_EMBED = true ]; then - # ARTIFACT_FOLDER="release/unminified/embedded" - # pushd $EMBED_OUTPUT_NO_MINI - # else - # ARTIFACT_FOLDER="release/embedded" - # pushd $EMBED_OUTPUTs - # fi - # echo "Uploading to Sentry..." - # npm run sentry-cli -- releases files "$VERSION_GIT_TAG" upload-sourcemaps --strip-common-prefix $ARTIFACT_FOLDER --rewrite --ext js --ext map --ext ts || builder_die "Sentry upload failed." - # echo "Upload successful." - # popd - # fi -fi - -### -embed section complete. - -if builder_start_action build:web; then - compile app/web - finalize app/web ${WEB_TARGETS[@]} +builder_run_child_actions build:engine/device-detect +builder_run_child_actions build:engine/dom-utils +builder_run_child_actions build:engine/element-wrappers - # The testing pages need both osk & ui resources in the same place. - copy_resources app/web osk ui - copy_sources app/web app/web engine resources/osk +# Uses engine/dom-utils +builder_run_child_actions build:engine/osk - builder_finish_action success build:web -fi +# Uses engine/osk (due to resource-path config interface) +builder_run_child_actions build:engine/paths -if builder_start_action build:ui; then - compile app/ui - finalize app/ui ${UI_TARGETS[@]} +# Uses engine/config (also due to resource-path config interface, but for the +# more complete version of that interface) +builder_run_child_actions build:engine/package-cache - copy_resources app/ui ui - copy_sources app/ui app/ui resources/ui +# Uses engine/paths, engine/device-detect, engine/package-cache, & engine/osk +builder_run_child_actions build:engine/main - builder_finish_action success build:ui -fi +# Uses all but engine/element-wrappers +builder_run_child_actions build:app/webview -if builder_start_action build:tools; then - src/tools/build.sh - builder_finish_action success build:tools -fi +# Uses literally everything `engine/` above +# Is not yet compilable due to unmodularized components. +# builder_run_child_actions build:app/browser -if builder_start_action build:samples; then - # Some test pages actually have build scripts. - ./src/test/manual/embed/android-harness/build.sh # is not yet builder-based. +builder_run_child_actions test - builder_echo "Copying samples & test page resources..." - # Should probably be changed into a build script for the `prediction-ui` test page. - cp "${PREDICTIVE_TEXT_SOURCE}" "${PREDICTIVE_TEXT_OUTPUT}" +if builder_start_action test; then + ./test.sh :engine - # Which could then have a parallel script for `prediction-mtnt` that downloads + extracts - # the current MTNT model. - - builder_finish_action success build:samples; + builder_finish_action success test fi -if builder_start_action test:web; then - if builder_has_option --all; then - ./test.sh - else - ./test.sh :engine - fi - - builder_finish_action success test:web -fi - -# TODO: handle the block below somehow. - -# # We can only upload 'web' / 'native' artifacts after ALL are done compiling. -# if [ $UPLOAD_WEB_SENTRY = true ]; then -# pushd $WEB_OUTPUT -# echo "Uploading to Sentry..." -# npm run sentry-cli -- releases files "$VERSION_GIT_TAG" upload-sourcemaps --strip-common-prefix release/web/ --rewrite --ext js --ext map --ext ts || builder_die "Sentry upload failed." -# echo "Upload successful." -# popd -# fi +builder_die "Modularization work is not yet complete; builds dependent on this will fail." diff --git a/web/ci.sh b/web/ci.sh index 4d9670a9be..1b7429d737 100755 --- a/web/ci.sh +++ b/web/ci.sh @@ -38,6 +38,10 @@ 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 # three actions: diff --git a/web/common.inc.sh b/web/common.inc.sh new file mode 100644 index 0000000000..0a615962de --- /dev/null +++ b/web/common.inc.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# + +# Compiles all build products corresponding to the specified target. +# This should be called from the working directory of a child project's +# build script. +# +# ### Parameters +# +# * 1: `product` the product's source path under src/ +# +# ### Example +# +# ```bash +# compile engine/main +# ``` +compile ( ) { + if [ $# -lt 1 ]; then + builder_die "Scripting error: insufficient argument count!" + fi + + local COMPILE_TARGET="$1" + + tsc -b "${KEYMAN_ROOT}/web/src/$COMPILE_TARGET" -v + + # COMPILE_TARGET entries are all prefixed with `engine`, so remove that. + if [ -f "./build-bundler.js" ]; then + node "./build-bundler.js" + + # So... tsc does declaration-bundling on its own pretty well, at least for local development. + tsc --emitDeclarationOnly --outFile "${KEYMAN_ROOT}/web/build/$COMPILE_TARGET/lib/index.d.ts" -p "${KEYMAN_ROOT}/web/src/$COMPILE_TARGET" + fi +} \ No newline at end of file diff --git a/web/namespaced-build.sh b/web/namespaced-build.sh new file mode 100755 index 0000000000..6a426e84d7 --- /dev/null +++ b/web/namespaced-build.sh @@ -0,0 +1,579 @@ +#!/usr/bin/env bash +# +# Compiled the Keyman Engine for Web's old namespaced version +# Will be removed by the end of the Web modularization work. +# + +# set -x +set -eu + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(greadlink -f "${BASH_SOURCE[0]}" 2>/dev/null || readlink -f "${BASH_SOURCE[0]}")" +. "$(dirname "$THIS_SCRIPT")/../resources/build/build-utils.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" + +# This script runs from its own folder +cd "$THIS_SCRIPT_PATH" + +# ################################ Main script ################################ + +# Definition of global compile constants + +UI="app/ui" +WEB="app/web" +EMBEDDED="app/embed" + +BUILD_BASE="build" + +DEBUG="debug" +RELEASE="release" +INTERMEDIATE="obj" + +# Composites and outputs the output path corresponding to the build configuration +# specified by the parameters. +# +# ### Parameters +# +# * 1: - build product (app/embed, app/web, app/ui, engine) +# * 2: (optional) - build stage / config (obj, debug, release) +# +# ### Example +# +# ```bash +# cp index.js "$(output_path app/web debug)/index.js" +# ``` +# +# The block above would copy index.js into the build output folder for app/web's debug +# product. +# +# ``` bash +# rm -rf "$(output_path app/web)" +# ``` +# +# The block above is useful for deleting all app/web build products as part of a `clean` +# action. +# +# ### Other Notes +# +# In the future, we may opt to move $INTERMEDIATE stuff underneath both $DEBUG and $RELEASE, +# making it a third param. This is currently unclear, but if so, we'd do +# $DEBUG/$INTERMEDIATE and $RELEASE/$INTERMEDIATE via a third argument. +output_path ( ) { + if [ $# -lt 1 ]; then + builder_die "Insufficient argument count!" + elif [ $# -eq 1 ]; then + # Used by clean: actions + echo "$BUILD_BASE/$1" + else + echo "$BUILD_BASE/$1/$2" + fi +} + +SOURCE="src" + +SENTRY_RELEASE_VERSION="release@$VERSION_WITH_TAG" + +# Ensures that we rely first upon the local npm-based install of Typescript. +# (Facilitates automated setup for build agents.) +PATH="../node_modules/.bin:$PATH" + +compiler="npm run tsc --" +compilecmd="$compiler" + +PREDICTIVE_TEXT_SOURCE="../common/predictive-text/unit_tests/in_browser/resources/models/simple-trie.js" +PREDICTIVE_TEXT_OUTPUT="src/test/manual/web/prediction-ui/simple-en-trie.js" + +builder_describe "Builds Keyman Engine for Web (KMW)." \ + "@../common/web/keyman-version build" \ + "@../common/web/input-processor build" \ + "@src/tools/building/sourcemap-root build" \ + "clean" \ + "configure" \ + "build" \ + "test Runs unit tests. Only $(builder_term test:web) is currently defined" \ + ":embed Builds the configuration of KMW used within the Keyman mobile apps" \ + ":engine Builds all common code used by other targets" \ + ":web Builds the website-oriented configuration of Keyman Engine for Web" \ + ":ui Builds the desktop UI modules used by the $(builder_term :web) build product" \ + ":samples Builds only sample & test pages found under src/samples and src/test" \ + ":tools Builds related development + unit-test resources" \ + "--no-minify Skips any minification steps in the build" \ + "--all Sets action to run on KMW's submodules as well if appropriate (actions - $(builder_term test))" + +# Possible TODO? +# "upload-symbols Uploads build product to Sentry for error report symbolification. Only defined for $(builder_term build:embed) and $(builder_term build:web)" \ + +builder_describe_outputs \ + configure ../node_modules \ + configure:embed ../node_modules \ + configure:engine ../node_modules \ + configure:web ../node_modules \ + configure:ui ../node_modules \ + configure:samples ../node_modules \ + configure:tools ../node_modules \ + build:embed $(output_path $EMBEDDED $RELEASE)/keyman.js \ + build:web $(output_path $WEB $RELEASE)/keymanweb.js \ + build:ui $(output_path $UI $RELEASE)/kmwuibutton.js \ + build:samples $PREDICTIVE_TEXT_OUTPUT +# Deliberately excluding build:tools b/c its script provides the definitions. + +builder_parse "$@" + +#### Build utility methods + definitions #### + +# Build products for each main target. +EMBED_TARGETS=( "keyman.js" ) +WEB_TARGETS=( "keymanweb.js" ) +UI_TARGETS=( "kmwuibutton.js" "kmwuifloat.js" "kmwuitoggle.js" "kmwuitoolbar.js" ) + +: ${CLOSURECOMPILERPATH:=../node_modules/google-closure-compiler-java} +: ${JAVA:=java} + +minifier="$CLOSURECOMPILERPATH/compiler.jar" + +# We'd love to add the argument --source_map_include_content for distribution in the future, +# but Closure doesn't include the TS sources properly at this time. +# +# `checkTypes` is blocked b/c TypeScript can perform our type checking... and it causes an error +# with TypeScript's `extend` implementation (it doesn't recognize a constructor without manual edits). +# We also get a global `this` warning from the same. +# +# `checkVars` is blocked b/c Closure will otherwise fail on TypeScript namespacing, as each original TS +# source file will redeclare the namespace variable, despite being merged into a single file post-compilation. +# +# `jsDocMissingType` prevents errors on type documentation Closure thinks is missing. TypeScript may not +# have the same requirements, and we trust TypeScript over Closure. +minifier_warnings="--jscomp_error=* --jscomp_off=lintChecks --jscomp_off=unusedLocalVariables --jscomp_off=globalThis --jscomp_off=checkTypes --jscomp_off=checkVars --jscomp_off=jsdocMissingType --jscomp_off=uselessCode --jscomp_off=missingRequire --jscomp_off=strictMissingRequire" + +# We use these to prevent Closure from auto-inserting its own polyfills. Turns out, they can break in the +# WebView used by Android API 19, which our app still supported when written. Unsure if it can still occur +# in Android API 21, our current minimum. +# +# Also, we currently apply all needed polyfills either manually or during TS compilation; we don't need the extra, +# excess code. +minifier_lang_specs="--language_in ECMASCRIPT5 --language_out ECMASCRIPT5" +minifycmd="$JAVA -jar $minifier --compilation_level WHITESPACE_ONLY $minifier_warnings --generate_exports $minifier_lang_specs" + +readonly minifier +readonly minifycmd + +minified_sourcemap_cleaner="build/tools/building/sourcemap-root/index.mjs" + +# Fails the build if a specified file does not exist. +assert_exists ( ) { + if ! [ -f $1 ]; then + echo "Build failed: expected file ${COLOR_GREY}$1${COLOR_RESET} is missing." + exit 1 + fi +} + +# $1 - base file name +# $2 - output path +# $3 - optimization level +# $4 - extra path info to add to minified sourcemap "sourceRoot" property. +# $5 - additional output wrapper +minify ( ) { + if [ $# -ge 4 ]; then + cleanerOptions="--clean --sourceRoot $4" + else + cleanerOptions="--clean" + fi + + if [ $# -ge 5 ]; then + wrapper=$5 + else + wrapper="%output%" + fi + + local INPUT="$1" + local INPUT_FILE="$(basename $1)" + local INPUT_DIR="$(dirname $1)" + local INPUT_SOURCEMAP="$INPUT_DIR/$INPUT_FILE.map" + local OUTPUT="$2" + local OUTPUT_FILE="$(basename $2)" + local OUTPUT_SOURCEMAP="$(dirname $2)/$OUTPUT_FILE.map" + + # --source_map_location_mapping - maps paths on INPUT source maps for consumption by Closure. + # ../../.. => keymanapp, ../.. => keymanapp/keyman. We have TS root sources on 'keyman'. + $minifycmd --source_map_input "$INPUT|$INPUT_SOURCEMAP" \ + --create_source_map "$OUTPUT_SOURCEMAP" --source_map_include_content \ + --source_map_location_mapping "$INPUT_DIR|../../.." \ + --js "$INPUT" --compilation_level $3 \ + --js_output_file "$OUTPUT" --warning_level VERBOSE --output_wrapper "$wrapper +//# sourceMappingURL=$INPUT_FILE.map" + + # Now to clean the source map. + assert_exists "$OUTPUT" + assert_exists "$OUTPUT_SOURCEMAP" + + # "Clean" the minified output sourcemaps. + node $minified_sourcemap_cleaner "$INPUT_SOURCEMAP" "$OUTPUT_SOURCEMAP" $cleanerOptions +} + +# Copies specified engine resources to the specified target's build output directories. +# +# ### Parameters +# +# * 1: `product` the product's source path under src/ +# * 2: `outputs` an array of resource types to copy over +# +# ### Example +# +# ```bash +# copy_resources app/web osk ui +# ``` +copy_resources ( ) { + local COMPILE_TARGET=$1 + shift + + local RESOURCES_TO_COPY=("$@") + + # We leave out $INTERMEDIATE here, as it's not a 'release' of any sort and + # thus doesn't need to publish sources or resources. + local CONFIGS=($DEBUG) + + if ! builder_has_option --skip-minify; then + CONFIGS+=($RELEASE) + fi + + echo + + for CONFIG in "${CONFIGS[@]}"; + do + local CONFIG_OUT_PATH="$(output_path $COMPILE_TARGET $CONFIG)" + + echo Copying resources to $CONFIG_OUT_PATH/src + + for RESOURCE in "${RESOURCES_TO_COPY[@]}"; + do + mkdir -p "$CONFIG_OUT_PATH/$RESOURCE" + mkdir -p "$CONFIG_OUT_PATH/src/resources/$RESOURCE" + + echo "- $SOURCE/resources/$RESOURCE/ => $CONFIG_OUT_PATH/$RESOURCE" + cp -Rf "$SOURCE/resources/$RESOURCE" "$CONFIG_OUT_PATH/" >/dev/null + done + + echo + done +} + +# Copies specified source folders corresponding to the specified target's build +# output directories. +# +# ### Parameters +# +# * 1: `product` the product's source path under src/ +# * 2: `outputs` an array of src/ subfolders to copy over +# +# ### Example +# +# ```bash +# copy_sources app/web engine resources/osk +# ``` +copy_sources ( ) { + local COMPILE_TARGET=$1 + shift + + local SOURCES_TO_COPY=("$@") + + # We leave out $INTERMEDIATE here, as it's not a 'release' of any sort and + # thus doesn't need to publish sources or resources. + CONFIGS=($DEBUG) + + if ! builder_has_option --skip-minify; then + CONFIGS+=($RELEASE) + fi + + for CONFIG in "${CONFIGS[@]}"; + do + CONFIG_OUT_PATH="$(output_path $COMPILE_TARGET $CONFIG)" + echo Copying $COMPILE_TARGET sources to $CONFIG_OUT_PATH/src + + rm -rf "$CONFIG_OUT_PATH/src" + mkdir -p "$CONFIG_OUT_PATH/src" + echo $VERSION_PATCH > "$CONFIG_OUT_PATH/src/version.txt" + + for SOURCE_FOLDER in "${SOURCES_TO_COPY[@]}"; + do + echo "- $SOURCE/$SOURCE_FOLDER/ => $CONFIG_OUT_PATH/src/$SOURCE_FOLDER/" + mkdir -p "$CONFIG_OUT_PATH/src/$SOURCE_FOLDER" + cp -Rf "$SOURCE/$SOURCE_FOLDER/"* "$CONFIG_OUT_PATH/src/$SOURCE_FOLDER/" + done + + echo + done +} + +# Compiles compiled scripts from the first folder specified to the second folder specified. +# +# ### Parameters +# +# * 1: `src` the folder containing scripts to be copied +# * 2: `dst` the destination folder for the copy operation +# * 3: `scripts` an array of filenames for expected compiled scripts +# +# ### Example +# +# ```bash +# compile_and_minify build/app/web/obj build/app/web/debug keymanweb.js +# ``` +copy_outputs ( ) { + local src="$1" + local dst="$2" + + shift + shift + + local BASE_SCRIPTS=("$@") + + mkdir -p "$dst" + + for SCRIPTJS in "${BASE_SCRIPTS[@]}"; + do + cp -Rf "$src/$SCRIPTJS" "$dst/" + cp -Rf "$src/$SCRIPTJS.map" "$dst/" + done +} + +# Compiles all build products corresponding to the specified target. +# +# ### Parameters +# +# * 1: `product` the product's source path under src/ +# +# ### Example +# +# ```bash +# compile app/embed +# ``` +compile ( ) { + if [ $# -lt 1 ]; then + fail "Scripting error: insufficient argument count!" + fi + + local COMPILE_TARGET=$1 + local COMPILED_INTERMEDIATE_PATH="$(output_path $COMPILE_TARGET $INTERMEDIATE)" + + $compilecmd -b src/$COMPILE_TARGET -v + + echo $COMPILE_TARGET TypeScript compiled under $COMPILED_INTERMEDIATE_PATH +} + +# Finalizes all build products corresponding to the specified target. +# This should be called after `compile` for all `app/` targets. +# +# ### Parameters +# +# * 1: `product` the product's source path under src/ +# * 2: `outputs` an array of expected output script files for the build +# +# ### Example +# +# ```bash +# compile app/embed +# finalize app/embed keyman.js +# ``` +finalize ( ) { + if [ $# -lt 2 ]; then + fail "Scripting error: insufficient argument count!" + fi + + local COMPILE_TARGET=$1 + local COMPILED_INTERMEDIATE_PATH="$(output_path $COMPILE_TARGET $INTERMEDIATE)" + local DEBUG_OUT_PATH="$(output_path $COMPILE_TARGET $DEBUG)" + local RELEASE_OUT_PATH="$(output_path $COMPILE_TARGET $RELEASE)" + + shift + + local OUTPUT_SCRIPTS=("$@") + + # START: debug output + + mkdir -p "$DEBUG_OUT_PATH" + copy_outputs "$COMPILED_INTERMEDIATE_PATH" "$DEBUG_OUT_PATH" "${OUTPUT_SCRIPTS[@]}" + + echo Compiled $COMPILE_TARGET debug version saved under $DEBUG_OUT_PATH: ${OUTPUT_SCRIPTS[*]} + + # START: release output + if ! builder_has_option --skip-minify; then + for SCRIPT in "${OUTPUT_SCRIPTS[@]}"; + do + minify "$COMPILED_INTERMEDIATE_PATH/$SCRIPT" "$RELEASE_OUT_PATH/$SCRIPT" SIMPLE_OPTIMIZATIONS + done + + echo Compiled $COMPILE_TARGET release version saved under $RELEASE_OUT_PATH: ${OUTPUT_SCRIPTS[*]} + else + # The prior 'release' is now outdated: delete it. + rm -rf "$RELEASE_OUT_PATH" + fi +} + +#### Build action definitions #### + +if builder_start_action configure; then + verify_npm_setup + + if ! builder_has_option --no-minify; then + # NPM install is required for the file to be present. + if ! [ -f $minifier ]; + then + echo File $minifier does not exist: have you set the environment variable \$CLOSURECOMPILERPATH? + exit 1 + fi + fi + + builder_finish_action success configure +fi + +## Clean actions + +# Possible issue: there's no clear rule to `clean` the engine, which is auto-built +# by build:embed and build:web. +# +# Some sort of command to run ONLY for a general `clean` (no target specified) would +# be perfect for that, I think. + +if builder_start_action clean:engine; then + src/engine/build.sh clean + builder_finish_action success clean:engine +fi + +if builder_start_action clean:embed; then + rm -rf "$(output_path $EMBEDDED)" + builder_finish_action success clean:embed +fi + +if builder_start_action clean:web; then + rm -rf "$(output_path $WEB)" + builder_finish_action success clean:web +fi + +if builder_start_action clean:ui; then + rm -rf "$(output_path $UI)" + builder_finish_action success clean:ui +fi + +if builder_start_action clean:samples; then + rm -f $PREDICTIVE_TEXT_OUTPUT + + builder_finish_action success clean:samples +fi + +if builder_start_action clean:tools; then + src/tools/build.sh clean + + builder_finish_action success clean:tools +fi + +## Build actions + +# Adds a simple version 'header' when there's a main engine build product. +if builder_has_action build:embed || \ + builder_has_action build:web || \ + builder_has_action build:ui; then + + echo "" + echo "${COLOR_PURPLE}Compiling version ${VERSION}${COLOR_RESET}" +fi + +echo "" + + +if builder_start_action build:engine; then + src/engine/build.sh build + + builder_finish_action success build:engine +fi + +if builder_start_action build:embed; then + compile $EMBEDDED + finalize $EMBEDDED ${EMBED_TARGETS[@]} + + # The embedded version doesn't use UI modules. + copy_resources $EMBEDDED osk + copy_sources $EMBEDDED app/embed engine resources/osk + + builder_finish_action success build:embed + + # TODO: handle this block somehow. + + # if [ $UPLOAD_EMBED_SENTRY = true ]; then # upload-symbols:embed + # if [ $BUILD_DEBUG_EMBED = true ]; then + # ARTIFACT_FOLDER="release/unminified/embedded" + # pushd $EMBED_OUTPUT_NO_MINI + # else + # ARTIFACT_FOLDER="release/embedded" + # pushd $EMBED_OUTPUTs + # fi + # echo "Uploading to Sentry..." + # npm run sentry-cli -- releases files "$SENTRY_RELEASE_VERSION" upload-sourcemaps --strip-common-prefix $ARTIFACT_FOLDER --rewrite --ext js --ext map --ext ts || fail "Sentry upload failed." + # echo "Upload successful." + # popd + # fi +fi + +### -embed section complete. + +if builder_start_action build:web; then + compile $WEB + finalize $WEB ${WEB_TARGETS[@]} + + # The testing pages need both osk & ui resources in the same place. + copy_resources $WEB osk ui + copy_sources $WEB app/web engine resources/osk + + builder_finish_action success build:web +fi + +if builder_start_action build:ui; then + compile $UI + finalize $UI ${UI_TARGETS[@]} + + copy_resources $UI ui + copy_sources $UI app/ui resources/ui + + builder_finish_action success build:ui +fi + +if builder_start_action build:tools; then + src/tools/build.sh + builder_finish_action success build:tools +fi + +if builder_start_action build:samples; then + # Some test pages actually have build scripts. + ./src/test/manual/embed/android-harness/build.sh # is not yet builder-based. + + echo "Copying samples & test page resources..." + # Should probably be changed into a build script for the `prediction-ui` test page. + cp "${PREDICTIVE_TEXT_SOURCE}" "${PREDICTIVE_TEXT_OUTPUT}" + + # Which could then have a parallel script for `prediction-mtnt` that downloads + extracts + # the current MTNT model. + + builder_finish_action success build:samples; +fi + +if builder_start_action test:web; then + if builder_has_option --all; then + ./test.sh + else + ./test.sh :engine + fi + + builder_finish_action success test:web +fi + +# TODO: handle the block below somehow. + +# # We can only upload 'web' / 'native' artifacts after ALL are done compiling. +# if [ $UPLOAD_WEB_SENTRY = true ]; then +# pushd $WEB_OUTPUT +# echo "Uploading to Sentry..." +# npm run sentry-cli -- releases files "$SENTRY_RELEASE_VERSION" upload-sourcemaps --strip-common-prefix release/web/ --rewrite --ext js --ext map --ext ts || fail "Sentry upload failed." +# echo "Upload successful." +# popd +# fi diff --git a/web/package.json b/web/package.json index e21450c22d..c35c2a4e4f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,73 @@ { "name": "keyman", "description": "Facilitates text input in any language.", + "exports": { + "./engine/paths": { + "types": "./build/engine/paths/obj/index.d.ts", + "import": "./build/engine/paths/obj/index.js" + }, + "./engine/device-detect": { + "types": "./build/engine/device-detect/obj/index.d.ts", + "import": "./build/engine/device-detect/obj/index.js" + }, + "./engine/device-detect/lib": { + "types": "./build/engine/device-detect/lib/index.d.ts", + "import": "./build/engine/device-detect/lib/index.mjs" + }, + "./engine/dom-utils": { + "types": "./build/engine/dom-utils/obj/index.d.ts", + "import": "./build/engine/dom-utils/obj/index.js" + }, + "./engine/element-wrappers": { + "types": "./build/engine/element-wrappers/obj/index.d.ts", + "import": "./build/engine/element-wrappers/obj/index.js" + }, + "./engine/element-wrappers/lib": { + "types": "./build/engine/element-wrappers/lib/index.d.ts", + "import": "./build/engine/element-wrappers/lib/index.mjs" + }, + "./engine/package-cache": { + "types": "./build/engine/package-cache/obj/index.d.ts", + "import": "./build/engine/package-cache/obj/index.js", + "require": "./build/engine/package-cache/obj/index.js" + }, + "./engine/package-cache/lib": { + "types": "./build/engine/package-cache/lib/index.d.ts", + "import": "./build/engine/package-cache/lib/index.mjs" + }, + "./engine/package-cache/dom-requester": { + "types": "./build/engine/package-cache/obj/domCloudRequester.d.ts", + "import": "./build/engine/package-cache/obj/domCloudRequester.js" + }, + "./engine/package-cache/dom-requester/lib": { + "types": "./build/engine/package-cache/lib/index.d.ts", + "import": "./build/engine/package-cache/lib/dom-cloud-requester.mjs" + }, + "./engine/package-cache/node-requester": { + "types": "./build/engine/package-cache/obj/nodeCloudRequester.d.ts", + "import": "./build/engine/package-cache/obj/nodeCloudRequester.js" + }, + "./engine/package-cache/node-requester/lib": { + "types": "./build/engine/package-cache/lib/index.d.ts", + "import": "./build/engine/package-cache/lib/node-cloud-requester.mjs" + }, + "./engine/main": { + "types": "./build/engine/main/obj/index.d.ts", + "import": "./build/engine/main/obj/index.js" + }, + "./engine/main/lib": { + "types": "./build/engine/main/lib/index.d.ts", + "import": "./build/engine/main/lib/index.mjs" + }, + "./engine/osk": { + "types": "./build/engine/osk/obj/index.d.ts", + "import": "./build/engine/osk/obj/index.js" + }, + "./engine/osk/lib": { + "types": "./build/engine/osk/lib/index.d.ts", + "import": "./build/engine/osk/lib/index.mjs" + } + }, "repository": { "type": "git", "url": "git+https://github.com/keymanapp/keyman.git" @@ -59,5 +126,6 @@ "@keymanapp/web-utils": "*", "@types/node": "^11.9.4", "eventemitter3": "^4.0.0" - } + }, + "type": "module" } diff --git a/web/src/app/browser/README.md b/web/src/app/browser/README.md new file mode 100644 index 0000000000..a911dc6736 --- /dev/null +++ b/web/src/app/browser/README.md @@ -0,0 +1 @@ +This subproject holds the new, modularized form of the old namespaced `app/web` subproject. \ No newline at end of file diff --git a/web/src/app/browser/build-bundler.js b/web/src/app/browser/build-bundler.js new file mode 100644 index 0000000000..8da047207a --- /dev/null +++ b/web/src/app/browser/build-bundler.js @@ -0,0 +1,22 @@ +/* + * Note: while this file is not meant to exist long-term, it provides a nice + * low-level proof-of-concept for esbuild bundling of the various Web submodules. + * + * Add some extra code at the end of src/index.ts and run it to verify successful bundling! + */ + +import esbuild from 'esbuild'; +import { spawn } from 'child_process'; + +await esbuild.build({ + bundle: true, + sourcemap: true, + format: "iife", + nodePaths: ['../../../../node_modules'], + entryPoints: { + 'index': '../../../build/app/webview/obj/main.js', + }, + outdir: '../../../build/app/webview/lib/', + tsconfig: './tsconfig.json', + target: "es5" +}); diff --git a/web/src/app/browser/build.sh b/web/src/app/browser/build.sh new file mode 100755 index 0000000000..d693f0967e --- /dev/null +++ b/web/src/app/browser/build.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# + +# set -x +set -eu + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" +. "${THIS_SCRIPT%/*}/../../../../resources/build/build-utils.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" + +# This script runs from its own folder +cd "$THIS_SCRIPT_PATH" + +# Imports common Web build-script definitions & functions +SUBPROJECT_NAME=app/browser +. "$KEYMAN_ROOT/web/common.inc.sh" + +# ################################ 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/osk build" \ + "@/web/src/engine/element-wrappers build" \ + "@/web/src/engine/main build" \ + "clean" \ + "configure" \ + "build" \ + "test" + +# Possible TODO?s +# "upload-symbols Uploads build product to Sentry for error report symbolification. Only defined for $DOC_BUILD_EMBED_WEB" \ + +builder_describe_outputs \ + configure /node_modules \ + build /web/build/$SUBPROJECT_NAME/lib/index.js + +builder_parse "$@" + +#### Build action definitions #### + +if builder_start_action configure; then + verify_npm_setup + + builder_finish_action success configure +fi + +if builder_start_action clean; then + rm -rf "$KEYMAN_ROOT/web/build/$SUBPROJECT_NAME" + builder_finish_action success clean +fi + +if builder_start_action build; then + compile $SUBPROJECT_NAME + + builder_finish_action success build +fi + +if builder_start_action test; then + # No headless tests of yet. + builder_finish_action success test +fi \ No newline at end of file diff --git a/web/src/app/browser/src/configuration.ts b/web/src/app/browser/src/configuration.ts new file mode 100644 index 0000000000..5aa2fdf450 --- /dev/null +++ b/web/src/app/browser/src/configuration.ts @@ -0,0 +1,37 @@ +import { EngineConfiguration, InitOptionSpec, InitOptionDefaults } from "keyman/engine/main"; + +export class BrowserConfiguration extends EngineConfiguration { + private _ui: string; + private _attachType: string; + + initialize(options: Required) { + this.initialize(options); + + this._ui = options.ui; + this._attachType = options.attachType; + } + + get attachType() { + return this._attachType; + } + + debugReport(): Record { + const baseReport = super.debugReport(); + baseReport.attachType = this.attachType; + baseReport.ui = this._ui; + baseReport.keymanEngine = 'app/browser'; + + return baseReport; + } +} + +export interface BrowserInitOptionSpec extends InitOptionSpec { + ui?: string; + attachType?: 'auto' | 'manual' | ''; // If blank or undefined, attachType will be assigned to "auto" or "manual" +} + +export const BrowserInitOptionDefaults: Required = { + ui: '', + attachType: '', + ...InitOptionDefaults +} \ No newline at end of file diff --git a/web/src/app/browser/src/contextManager.ts b/web/src/app/browser/src/contextManager.ts new file mode 100644 index 0000000000..8ef1bf5ca8 --- /dev/null +++ b/web/src/app/browser/src/contextManager.ts @@ -0,0 +1,61 @@ +import { type Keyboard, Mock, OutputTarget } from '@keymanapp/keyboard-processor'; +import { type KeyboardStub } from 'keyman/engine/package-cache'; +import { + ContextManager as ContextManagerBase, + type KeyboardInterface +} from 'keyman/engine/main'; +import { BrowserConfiguration } from './configuration.js'; + +export default class ContextManager extends ContextManagerBase { + private _activeKeyboard: {keyboard: Keyboard, metadata: KeyboardStub}; + private config: BrowserConfiguration; + + initialize(): void { + // TBD: keyman.domManager.init (the page-integration parts) + // CTRL+F: `// Exit initialization here if we're using an embedded code path.` + // EVERYTHING after that block will likely go here - DOMManager's role always + // was context-management and the facilitation thereof. + throw new Error('Method not implemented.'); + } + + get activeTarget(): OutputTarget { + // TBD: basically DOMManager's .activeElement. + throw new Error('Method not implemented.'); + } + + get activeKeyboard() { + return this._activeKeyboard; + } + + set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub}) { + throw new Error('Method not implemented.'); + // depends on the target + // if not set with an "independent keyboard", changes the global. + // if set with an "independent keyboard", changes only the active target's keyboard. + } + + insertText(kbdInterface: KeyboardInterface, Ptext: string, PdeadKey: number) { + // Find the correct output target to manipulate. + const outputTarget = this.activeTarget; + + if(outputTarget != null) { + // Intent: this class will be responsible for maintaining the active context... so + // `this` itself will be responsible for _IgnoreNextSelChange and focusLastActiveElement. + // Still trying to work out the uiManager bit, since the OSK does need to interact with that. + // Keep the rest of the comment below post-modularization, though: + // + // While not yet fully connected, ContextManager and its subclasses will be responsible for maintaining + // active elements & their focus... something that should only really matter for the website-integrating + // subclass. + + // Required for the `sil_euro_latin` keyboard's desktop OSK/table to function properly. + keyman.uiManager.setActivatingUI(true); + dom.DOMEventHandlers.states._IgnoreNextSelChange = 100; + keyman.domManager.focusLastActiveElement(); + dom.DOMEventHandlers.states._IgnoreNextSelChange = 0; + + return super.insertText(kbdInterface, Ptext, PdeadKey); + } + return false; + } +} \ No newline at end of file diff --git a/web/src/app/browser/src/defaultBrowserRules.ts b/web/src/app/browser/src/defaultBrowserRules.ts new file mode 100644 index 0000000000..135340b77b --- /dev/null +++ b/web/src/app/browser/src/defaultBrowserRules.ts @@ -0,0 +1,54 @@ +import { + Codes, + DefaultRules, + type KeyEvent, + type OutputTarget +} from '@keymanapp/keyboard-processor'; + +import ContextManager from './contextManager.js'; + +export default class DefaultBrowserRules extends DefaultRules { + private contextManager: ContextManager; + + constructor(contextManager: ContextManager) { + super(); + this.contextManager = contextManager; + } + + isCommand(Lkc: KeyEvent): boolean { + let code = this.codeForEvent(Lkc); + + switch(code) { + case Codes.keyCodes['K_TAB']: + case Codes.keyCodes['K_TABBACK']: + case Codes.keyCodes['K_TABFWD']: + return true; + default: + return super.isCommand(Lkc); + } + } + + /** + * applyCommand - used when a RuleBehavior represents a non-text "command" within the Engine. + */ + applyCommand(Lkc: KeyEvent, outputTarget: OutputTarget): void { + let code = this.codeForEvent(Lkc); + + const contextManager = this.contextManager; + + switch(code) { + // This method will be transplanted to the specific `ContextManager` module stored above. + case Codes.keyCodes['K_TAB']: + domManager.moveToNext((Lkc.Lmodifiers & Codes.modifierCodes['SHIFT']) != 0); + break; + case Codes.keyCodes['K_TABBACK']: + domManager.moveToNext(true); + break; + case Codes.keyCodes['K_TABFWD']: + domManager.moveToNext(false); + break; + } + + super.applyCommand(Lkc, outputTarget); + } +} \ No newline at end of file diff --git a/web/src/app/browser/src/keyEventKeyboard.ts b/web/src/app/browser/src/keyEventKeyboard.ts new file mode 100644 index 0000000000..f85e499417 --- /dev/null +++ b/web/src/app/browser/src/keyEventKeyboard.ts @@ -0,0 +1,14 @@ +import { DeviceSpec, KeyEvent, ManagedPromise } from '@keymanapp/keyboard-processor'; + +import { HardKeyboard } from 'keyman/engine/main'; + +export default class KeyEventKeyboard extends HardKeyboard { + readonly baseDevice: DeviceSpec; + + constructor(baseDevice: DeviceSpec) { + super(); + this.baseDevice = baseDevice; + } + + // TODO: actually implement +} \ No newline at end of file diff --git a/web/src/app/browser/src/keyboardInterface.ts b/web/src/app/browser/src/keyboardInterface.ts new file mode 100644 index 0000000000..08d7e50f30 --- /dev/null +++ b/web/src/app/browser/src/keyboardInterface.ts @@ -0,0 +1,101 @@ +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'; + +export default class KeyboardInterface extends KeyboardInterfaceBase { + // TBD: allowing it to be set and/or the retrieval mechanism. + // Note that the OSK is constructed notably later, after page load + during full engine init. + // So, the actual instance will not be available at construction-time. + private osk: OSKView; // Or some way to retrieve it. + + + constructor( + _jsGlobal: any, + keymanGlobal: KeyboardKeymanGlobal, + cache: StubAndKeyboardCache, + contextManager: ContextManager, + ) { + super(_jsGlobal, keymanGlobal, cache, contextManager); + + // Nothing else to do here... quite yet. Things may not stay that way, though. + } + // *** The following are quite useful for website-integrating KMW, but not needed for the embedded form. *** + + /** + * Function KSF + * Scope Public + * Description Save keyboard focus + */ + saveFocus(): void { + // Its role will exist within `.contextManager`. + dom.DOMEventHandlers.states._IgnoreNextSelChange = 1; + } + + /** + * Legacy entry points (non-standard names)- included only to allow existing IME keyboards to continue to be used + */ + getLastActiveElement(): OutputTarget { + // Its role will exist within `.contextManager`. + // Possibly just .activeTarget? It's not 100% clear. + return dom.Utils.getOutputTarget(); + } + + focusLastActiveElement(): void { + // Its role will exist within `.contextManager`. + keyman.domManager.focusLastActiveElement(); + } + + //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 + hideHelp(): void { + const osk = this.osk; + osk.startHide(true); + } + + showHelp(Px: number, Py: number): void { + const osk = this.osk; + + if(osk instanceof FloatingOSKView) { + osk.presentAtPosition(Px,Py); + } else { + osk.present(); + } + } + + showPinnedHelp(): void { + const osk = this.osk; + + if(osk instanceof FloatingOSKView) { + // An old KMW bug previously auto-unset the affected field when this function was + // used by CJK keyboards during rule processing. As a result, we need to condition + // on whether or not: + // 1. The active keyboard is CJK + // 2. A keyboard rule is actively processing. + // + // If BOTH are true, we do NOT mutate keyman.osk.userPositioned. + // Otherwise, not all conditions are met, so we still allow OSK pinning. + if(!osk.activeKeyboard.keyboard.isCJK || !this.ruleBehavior) { + osk.userPositioned=true; + } + } + // Automatically reuses previously-set positioning. + // Other OSK API functions must have previously been used to set the + // pinned position. + osk.present(); + } + + // Also needed for some legacy CJK keyboards. + readonly GetLastActiveElement = this.getLastActiveElement; + readonly FocusLastActiveElement = this.focusLastActiveElement; + readonly HideHelp = this.hideHelp; + readonly ShowHelp = this.showHelp; + readonly ShowPinnedHelp = this.showPinnedHelp; +} + +(function() { + // Update the shorthand API; we did just rewrite KSF / `saveFocus`. + KeyboardInterface.__publishShorthandAPI(); +})(); \ No newline at end of file diff --git a/web/src/app/browser/src/keymanEngine.ts b/web/src/app/browser/src/keymanEngine.ts new file mode 100644 index 0000000000..a63bccf299 --- /dev/null +++ b/web/src/app/browser/src/keymanEngine.ts @@ -0,0 +1,19 @@ +import { EngineConfiguration, KeymanEngine as KeymanEngineBase } from 'keyman/engine/main'; +import { ProcessorInitOptions } from "@keymanapp/keyboard-processor"; + +import ContextManager from './contextManager.js'; +import DefaultOutput from './defaultOutput.js'; +import KeyEventKeyboard from './keyEventKeyboard.js'; + +export class KeymanEngine extends KeymanEngineBase { + constructor(worker: Worker, config: EngineConfiguration) { + super(worker, config, new ContextManager()); + } + + protected processorConfiguration(): ProcessorInitOptions { + return { + keyboardInterface: this.interface, + defaultOutputRules: new DefaultOutput(this.contextManager) + }; + }; +} diff --git a/web/src/app/browser/src/main.ts b/web/src/app/browser/src/main.ts new file mode 100644 index 0000000000..e2a56806ca --- /dev/null +++ b/web/src/app/browser/src/main.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000..484bb9a498 --- /dev/null +++ b/web/src/app/browser/src/oskConfiguration.ts @@ -0,0 +1,34 @@ +import { type KeyElement, OSKView, VisualKeyboard } from "keyman/engine/osk"; +import { getAbsoluteX, getAbsoluteY } from "keyman/engine/dom-utils"; +import { DeviceSpec } from "@keymanapp/keyboard-processor"; +import { type EmbeddedGestureConfig } from "keyman/engine/osk"; + +export function setupEmbeddedListeners(osk: OSKView) { + osk.on('globeKey', (key, on) => { + if(on) { + if(osk.hostDevice.touchable) { + this.lgMenu = new LanguageMenu(com.keyman.singleton); + this.lgMenu.show(); + } + } + + if(osk.vkbd) { + osk.vkbd.highlightKey(key, false); // never leave the globe key highlighted + } + }); + + osk.on('hideRequested', (key) => { + if(osk) { + keyman.uiManager.setActivatingUI(false); + osk.startHide(true); + keyman.domManager.lastActiveElement = null; + } + }); + + osk.on('onhide', () => { + // If hidden by the UI, be sure to restore the focus + if(hiddenByUser && this.activeTarget) { + this.activeTarget?.focus(); + } + }); +} \ No newline at end of file diff --git a/web/src/app/browser/tsconfig.json b/web/src/app/browser/tsconfig.json new file mode 100644 index 0000000000..849055cc97 --- /dev/null +++ b/web/src/app/browser/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig-base.json", + + "compilerOptions": { + "allowJs": false, + "inlineSources": true, + "allowSyntheticDefaultImports": true, + "module": "es6", + "moduleResolution": "Node16", + "sourceMap": true, + "target": "es5", + "baseUrl": "./", + "outDir": "../../../build/app/browser/obj/", + "tsBuildInfoFile": "../../../build/app/browser/obj/tsconfig.tsbuildinfo", + "rootDir": "./src" + }, + + "include": [ "**/*.ts" ], + + "references": [ + { "path": "../../../../common/web/keyman-version" }, + { "path": "../../../../common/web/utils" } + ] +} diff --git a/web/src/app/embed/README.md b/web/src/app/embed/README.md new file mode 100644 index 0000000000..d1ed7f397c --- /dev/null +++ b/web/src/app/embed/README.md @@ -0,0 +1,3 @@ +**NOTE**: _deprecated_ + +This subproject holds old namespaced-code corresponding to the new, modularized `app/webview` subproject. \ No newline at end of file diff --git a/web/src/app/embed/kmwembedded.ts b/web/src/app/embed/kmwembedded.ts index c96fef8a80..33e07f476e 100644 --- a/web/src/app/embed/kmwembedded.ts +++ b/web/src/app/embed/kmwembedded.ts @@ -1,9 +1,3 @@ -// Since 'web' compilation is the path recognized by VSCode, we need to make references here to prevent TS errors. -// References the base Keyman object (and consequently, the rest of the core objects). -/// -/// -/// - // KeymanWeb 11.0 // Copyright 2019 SIL International @@ -13,442 +7,10 @@ /* */ /*****************************************/ -namespace com.keyman.osk { - VisualKeyboard.prototype.optionKey = function(this: VisualKeyboard, e: KeyElement, keyName: string, keyDown: boolean) { - let keyman = com.keyman.singleton; - - if(keyName.indexOf('K_LOPT') >= 0) { - if(keyDown) { - this.menuEvent = e; - if(typeof keyman['showKeyboardList'] == 'function') { - keyman['showKeyboardList'](); - } - } else { - if(this.menuEvent) { - this.highlightKey(this.menuEvent, false); - } - if(typeof(window['menuKeyUp']) == 'function') { - window['menuKeyUp'](); - } - this.menuEvent = null; - } - } else if(keyName.indexOf('K_ROPT') >= 0) { - if(keyDown) { - this.highlightKey(e,false); - if(typeof keyman['hideKeyboard'] == 'function') { - keyman['hideKeyboard'](); - } - } - } - }; - - // iOS now relies upon native-mode popup key management, so we only implement these hybrid-targeted - // methods when embedding in Android. - let device = com.keyman.singleton.util.device; - - if(device.OS == 'Android') { // assumption - if this file is being loaded, keyman.isEmbedded == true. - // Send the subkey array to iOS, with centre,top of base key position - /** - * Create a popup key array natively - * - * @param {Object} key base key element - */ - VisualKeyboard.prototype.startLongpress = function(this: VisualKeyboard, key: KeyElement): PendingGesture { - if(typeof(window['oskCreatePopup']) == 'function') { - var xBase = dom.Utils.getAbsoluteX(key) - dom.Utils.getAbsoluteX(this.kbdDiv) + key.offsetWidth/2, - yBase = dom.Utils.getAbsoluteY(key); - - // #3718: No longer prepend base key to subkey array - window['oskCreatePopup'](key['subKeys'], xBase, yBase, key.offsetWidth, key.offsetHeight); - - return new embedded.PendingLongpress(this, key); - } else { - // When embedded within our Android app, we expect the `oskCreatePopup` function to - // exist; all subkey control is delegated to the app. - // - // No function = big problem. - console.error("Missing `oskCreatePopup` function for engine integration."); - return null; - } - }; - - VisualKeyboard.prototype.createKeyTip = function(this: VisualKeyboard) { - if(com.keyman.singleton.util.device.formFactor == 'phone') { - this.keytip = new osk.embedded.KeyTip(window['oskCreateKeyPreview'], window['oskClearKeyPreview']); - } - }; - } // end Android-only block. - - VisualKeyboard.prototype.createGlobeHint = function(this: VisualKeyboard) { - this.globeHint = new com.keyman.osk.embedded.GlobeHint(this); - let keyman = com.keyman.singleton; - keyman.osk._Box.appendChild(this.globeHint.element!); - } - - SuggestionManager.prototype.platformHold = function(this: SuggestionManager, suggestionObj: BannerSuggestion, isCustom: boolean) { - // Parallels VisualKeyboard.prototype.touchHold, but for predictive suggestions instead of keystrokes. - let suggestionEle = suggestionObj.div; - - // Need to know if it's a option or not! - - var xBase = dom.Utils.getAbsoluteX(suggestionEle) - dom.Utils.getAbsoluteX(suggestionEle.parentElement) + suggestionEle.offsetWidth/2, - yBase = dom.Utils.getAbsoluteY(suggestionEle) - dom.Utils.getAbsoluteY(suggestionEle.parentElement); - - window['suggestionPopup'](suggestionObj.suggestion, isCustom, xBase, yBase, suggestionEle.offsetWidth, suggestionEle.offsetHeight); - } -} - -namespace com.keyman.text { - let nativeForBaseKeys = DefaultOutput.forBaseKeys; - - // Overrides the 'native'-mode implementation with in-app friendly defaults prioritized over 'native' defaults. - DefaultOutput.forBaseKeys = function(Lkc: KeyEvent): string { - let Codes = com.keyman.text.Codes; - let code = Lkc.Lcode; - - // Intentionally not assigning K_TAB or K_ENTER so KMW will pass them back - // to the mobile apps to handle (insert characters or navigate forms). - if (code == Codes.keyCodes.K_oE2) { - // Using defaults of English US layout for the 102nd key - if (Lkc.Lmodifiers == Codes.modifierCodes['SHIFT']) { - return '|'; - } else { - return '\\'; - } - } - - // Use 'native'-mode defaults, determining the character from the OSK - return nativeForBaseKeys(Lkc); - } -} - (function() { // Declare KeymanWeb and related objects - var keymanweb=window['keyman'], util=keymanweb['util'],device=util.device; - var dom = com.keyman.dom; - - // Allow definition of application name - keymanweb.options['app']=''; + var keymanweb=window['keyman']; // Flag to control refreshing of a keyboard that is already loaded - keymanweb.mustReloadKeyboard = true; - - // Skip full page initialization - skips native-mode only code - keymanweb.isEmbedded = true; - - // Set default device options - keymanweb.setDefaultDeviceOptions = function(opt: com.keyman.OptionType) { - opt['attachType'] = 'manual'; - device.app=opt['app']; - device.touchable=true; - device.formFactor = device.app.indexOf('Tablet') >= 0 ? 'tablet' : 'phone'; - device.browser='native'; - }; - - // Get default style sheet path - keymanweb.getStyleSheetPath = function(ssName) { - return keymanweb.rootPath+ssName; - }; - - keymanweb.linkStylesheetResources = function() { - const keyman = keymanweb as KeymanBase; - let util = keyman.util; - - // Install the globe-hint stylesheet. - util.linkStyleSheet(keymanweb.getStyleSheetPath('globe-hint.css')); - - // For now, the OSK will handle linking of the main OSK stylesheet separately. - } - - // Get KMEI, KMEA keyboard path (overrides default function, allows direct app control of paths) - keymanweb.getKeyboardPath = function(Lfilename, packageID) { - return Lfilename + "?v=" + (new Date()).getTime(); /*cache buster*/ - }; - - // Establishes keyboard namespacing. - keymanweb.namespaceID = function(Pstub) { - if(typeof(Pstub['KP']) != 'undefined') { - // An embedded use case wants to utilize package-namespacing. - Pstub['KI'] = Pstub['KP'] + "::" + Pstub['KI']; - } - } - - // In conjunction with the KeyboardManager's installKeyboard method and script IDs, preserves a keyboard's - // namespaced ID. - keymanweb.preserveID = function(Pk) { - var trueID; - - // Find the currently-executing script tag; KR is called directly from each keyboard's definition script. - if(document.currentScript) { - trueID = document.currentScript.id; - } else { - var scripts = document.getElementsByTagName('script'); - var currentScript = scripts[scripts.length-1]; - - trueID = currentScript.id; - } - - // Final check that the script tag is valid and appropriate for the loading keyboard. - if(trueID.indexOf(Pk['KI']) != -1) { - Pk['KI'] = trueID; // Take the script's version of the ID, which may include package namespacing. - } else { - console.error("Error when registering keyboard: current SCRIPT tag's ID does not match!"); - } - } - - /** - * Force reload of resource - * - * @param {string} s unmodified URL - * @return {string} modified URL - */ - util.unCached = function(s) { - var t=(new Date().getTime()); - s = s + '?v=' + t; - return s; - }; - - util.wait = function() { - // Empty stub - this function should not be implemented or used within embedded code routes. - console.warn("util.wait() call attempted in embedded mode!"); // Sends log message to embedding app. - }; - - util.alert = function() { - // Empty stub - this function should not be implemented or used within embedded code routes. - console.warn("util.alert() call attempted in embedded mode!"); // Sends log message to embedding app. - }; - - /** - * Refresh element content after change of text (if required) - * - * @param {Object} Pelem input element - */ - keymanweb.refreshElementContent = function(Pelem) - { - if('ontextchange' in keymanweb) keymanweb['ontextchange'](Pelem); - }; - - /** - * Set target element text direction (LTR or RTL): not functional for KMEI, KMEA - * - * @param {Object} Ptarg Target element - */ - keymanweb.domManager._SetTargDir = function(Ptarg){}; - - /** - * Use rotation events to adjust OSK element positions and scaling if necessary - */ - keymanweb.handleRotationEvents = function() {}; - - /** - * Caret position always determined from the active (but hidden) element - * - * @return {boolean} - **/ - keymanweb.isPositionSynthesized = function() - { - return false; - }; - - /** - * correctOSKTextSize handles rotation event -- currently rebuilds keyboard and adjusts font sizes - */ - keymanweb['correctOSKTextSize']=function() { - keymanweb.osk?.refreshLayout(); - }; - - /** - * Register a lexical model - * - * @param {com.keyman.text.prediction.ModelSpec} model Spec of the lexical model - */ - keymanweb['registerModel']=function(model: com.keyman.text.prediction.ModelSpec) { - keymanweb.modelManager.register(model); - }; - - /** - * Function called by Android and iOS when a device-implemented keyboard popup - * is displayed or hidden. As this is controlled by the app, we use it as a - * trigger for 'embedded'-mode gesture state management. - * - * @param {boolean} isVisible - * - **/ - keymanweb['popupVisible'] = function(isVisible) { - let osk = keymanweb.osk; - let gesture = osk.vkbd.subkeyGesture as com.keyman.osk.embedded.SubkeyDelegator; - let pendingLongpress = osk.vkbd.pendingSubkey; - - /* - * If a longpress popup was visible, but is no longer, this means that the - * associated longpress gesture was cancelled. It is possible for the base - * key to emit if selected at this time; detection of this is managed by - * the `SubkeyDelegator` class. - */ - if(!isVisible) { - if(gesture) { - gesture.resolve(null); - osk.vkbd.subkeyGesture = null; - } else if(pendingLongpress) { - pendingLongpress.cancel(); - osk.vkbd.pendingSubkey = null; - } - } - - /* - * If the popup was not visible, but now is, that means our previously-pending - * longpress is now 'realized' (complete). The OSK relies upon this state - * information, which will be properly updated by `resolve`. - * - * Prominent uses of such state info helps prevent change of base key, key - * previews, and key output from occurring while a subkey popup remains active. - */ - if(isVisible && pendingLongpress) { - // Fulfills the first-stage promise. - pendingLongpress.resolve(); - } - }; - - /** - * Return position of language menu key to KeymanTouch - * - * @return {string} comma-separated x,y,w,h of language menu key - * - **/ - keymanweb['touchMenuPos'] = function() { - let osk = keymanweb.osk; - if(osk == null || osk.vkbd == null || osk.vkbd.lgKey == null) { - return ''; - } - - var key: HTMLElement = osk.vkbd.lgKey; - // A CSS change of kmd-key-square from position:fixed to position:static was needed - // for Android 4.3 to display the OSK correctly, but resulted in the position of - // the menu key not being returned correctly. The following line gets the - // key element, instead of the key-square element, fixes this. It should be - // removed again when the key-square elements are all removed as planned. - if(typeof key.firstChild != 'undefined' && key.firstChild != null && util.hasClass(key.firstChild,'kmw-key')) { - key = key.firstChild; - } - - var w=key.offsetWidth, - h=key.offsetHeight, - // Since the full OSKManager '_Box' is displayed within the keyboards' WebViews, - // these calculations should be performed with respect to that, rather than osk.vkbd.kbdDiv. - x=dom.Utils.getAbsoluteX(key) - dom.Utils.getAbsoluteX(osk._Box) + w/2, - y=dom.Utils.getAbsoluteY(key) - dom.Utils.getAbsoluteY(osk._Box); - - return x+','+y+','+w+','+h; - }; - - keymanweb['showGlobeHint'] = function(text: string, onAutodismissal?: () => void) { - const keyman = keymanweb as KeymanBase; - const globeHint = keyman.osk?.vkbd?.globeHint; - - if(globeHint) { - // Ensure localized text is properly in-place. - globeHint.text = text; - globeHint.show(keyman.osk.vkbd.currentLayer.globeKey.btn, onAutodismissal); - } - } - - keymanweb['hideGlobeHint'] = function() { - const keyman = keymanweb as KeymanBase; - keyman.osk?.vkbd?.globeHint?.hide(keyman.osk.vkbd.currentLayer.globeKey.btn); - } - - /** - * Accept an external key ID (from KeymanTouch) and pass to the keyboard mapping - * - * @param {string} keyName key identifier which could contain a display layer and a "functional" layer - * e.g: 'shift-K_E+rightalt-shift' - **/ - keymanweb['executePopupKey'] = function(keyName: string) { - let osk = keymanweb.osk; - var origArg = keyName; - if(!keymanweb.core.activeKeyboard || !osk.vkbd) { - return false; - } - - /* Clear any pending (non-popup) key */ - osk.vkbd.keyPending = null; - - // Changes for Build 353 to resolve KMEI popup key issues - keyName=keyName.replace('popup-',''); //remove popup prefix if present (unlikely) - - // Regex for 'display layer'-'virtual key name'+'optional functional layer' - // Can't just split on '-' because some layers like ctrl-shift contain it. - // Virtual key name starts with T_, K_, or U_ - // matches[1]: displayLayer (not used) - // matches[2]: keyId - // matches[3]: optional functionalLayer - let matches = keyName.match(/^(.+)-([TKU]_[^+]+)\+?(.+)?$/); - if (matches == null) { - return false; - } - keyName = matches[2] + (matches[3] ? '+' + matches[3] : ''); - - // Note: this assumes Lelem is properly attached and has an element interface. - // Currently true in the Android and iOS apps. - var Lelem=keymanweb.domManager.lastActiveElement; - keymanweb.domManager.initActiveElement(Lelem); - - // This should be set if we're within this method... but it's best to guard against nulls here, just in case. - if(osk.vkbd.subkeyGesture) { - let gesture = osk.vkbd.subkeyGesture as com.keyman.osk.embedded.SubkeyDelegator; - gesture.resolve(keyName); - osk.vkbd.subkeyGesture = null; - } else { - console.warn("No base key exists for the subkey being executed: '" + origArg + "'"); - } - }; - - /** - * API endpoint for hardware keystroke events from Android external keyboards - * - * @param {number} code key identifier - * @param {number} shift shift state (0x01=left ctrl 0x02=right ctrl 0x04=left alt 0x08=right alt - * 0x10=shift 0x20=ctrl 0x40=alt) - * @param {number} lstates lock state (0x0200=no caps 0x0400=num 0x0800=no num 0x1000=scroll 0x2000=no scroll locks) - * @return {boolean} false when KMW _has_ fully handled the event and true when not. - **/ - keymanweb['executeHardwareKeystroke'] = function(code, shift, lstates = 0): boolean { - if(!keymanweb.core.activeKeyboard || code == 0) { - return false; - } - - // Clear any pending (non-popup) key - keymanweb.osk.vkbd.keyPending = null; - - // Note: this assumes Lelem is properly attached and has an element interface. - // Currently true in the Android and iOS apps. - var Lelem = keymanweb.domManager.lastActiveElement; - - keymanweb.domManager.initActiveElement(Lelem); - - // Check the virtual key - var Lkc: com.keyman.text.KeyEvent = { - Lmodifiers: shift, - vkCode: code, - Lcode: code, - Lstates: lstates, - LisVirtualKey: true, - kName: '', - device: keymanweb.util.physicalDevice.coreSpec, // As we're executing a hardware keystroke. - isSynthetic: false - }; - - try { - // Now that we've manually constructed a proper keystroke-sourced KeyEvent, pass control - // off to the processor for its actual execution. - - // Should return 'false' when KMW _has_ fully handled the event and 'true' when not. - const ruleBehavior: com.keyman.text.RuleBehavior = keymanweb.core.processKeyEvent(Lkc, com.keyman.dom.Utils.getOutputTarget(Lelem)); - - return !ruleBehavior || ruleBehavior.triggerKeyDefault; - } catch (err) { - console.error(err.message, err); - return false; - } - }; + keymanweb.mustReloadKeyboard = true; // pretty much tied to cache-busting, isn't it? })(); diff --git a/web/src/app/embed/osk/embedded/globeHint.ts b/web/src/app/embed/osk/embedded/globeHint.ts deleted file mode 100644 index e6326a7ccd..0000000000 --- a/web/src/app/embed/osk/embedded/globeHint.ts +++ /dev/null @@ -1,188 +0,0 @@ -/// - -namespace com.keyman.osk.embedded { - /** - * The GlobeHint is a specialized key tip that is presented above - * the globe key on first use of Keyman, to show the user how to - * switch input methods. The label is localizable text. - */ - export class GlobeHint implements osk.GlobeHint { - public readonly element: HTMLDivElement; - public state: boolean = false; - - // ----- - // | | <-- tip - // | x | <-- label - // |_ _| - // \ / <-- cap - // v - - private readonly cap: HTMLDivElement; - private readonly tip: HTMLDivElement; - private readonly label: HTMLSpanElement; - - private readonly vkbd: VisualKeyboard; - - private docInputCatch?: SingleInputCapture; - private tipInputTrap?: SingleInputCapture; - - private delayedDismissTimerHandle: number; - - constructor(vkbd: VisualKeyboard) { - this.vkbd = vkbd; - - let tipElement = this.element=document.createElement('div'); - tipElement.className = 'kmw-globehint'; - tipElement.id = 'kmw-globehint'; - - // The following style is critical, so do not rely on external CSS - tipElement.style.pointerEvents = 'none'; - tipElement.style.display = 'none'; - - tipElement.appendChild(this.tip = document.createElement('div')); - tipElement.appendChild(this.cap = document.createElement('div')); - this.tip.appendChild(this.label = document.createElement('span')); - - this.tip.className = 'kmw-globehint-tip'; - this.cap.className = 'kmw-globehint-cap'; - this.label.className = 'kmw-globehint-label'; - - this.text = "Tap here to change keyboard."; - } - - get text(): string { - return this.label.innerText; - } - - set text(val: string) { - this.label.innerText = val; - } - - private _show(key: KeyElement, on: boolean) { - let keyman = com.keyman.singleton; - - // Create and display the preview - // If !key.offsetParent, the OSK is probably hidden. Either way, it's a half- - // decent null-guard check. - if(!on || !key.offsetParent) { - this.element.style.display = 'none'; - } else { - // The key element is positioned relative to its key-square, which is, - // in turn, relative to its row. Rows take 100% width, so this is sufficient. - // - let rowElement = (key.key as OSKBaseKey).row.element; - - // May need adjustment for borders if ever enabled for the desktop form-factor target. - let rkey = key.getClientRects()[0], rrow = rowElement.getClientRects()[0]; - let xLeft = rkey.left - rrow.left, - xWidth = rkey.width, - xHeight = rkey.height; - - let center = rkey.left + rkey.width/2; - - let bubbleStyle = this.element.style; - - // Roughly matches how the subkey positioning is set. - const _Box = this.vkbd.element.parentNode as HTMLDivElement; - const _BoxRect = _Box.getBoundingClientRect(); - const keyRect = key.getBoundingClientRect(); - let y = (keyRect.bottom - _BoxRect.top + 1); - - // Width dimensions must be set explicitly to prevent clipping. - // We'll assume that the globe key is always positioned on the bottom row. - let oskBaseFontSize = new ParsedLengthStyle(keyman.osk.baseFontSize); - let bubbleWidth = Math.ceil(xWidth * 3); - - bubbleStyle.bottom = Math.floor(keyman.osk.computedHeight - y) + 'px'; - // CSS already defines transform: translateX(-50%) - this centers the element. - bubbleStyle.left = center + 'px'; - - this.tip.style.bottom = (rrow.height - 1) + 'px'; - this.tip.style.width = bubbleWidth + 'px'; - this.tip.style.pointerEvents = on ? 'auto' : 'none'; - - // Adjust shape if at edges - - // how much width lies outside the range of the base key, per side - const xOverflow = (bubbleWidth - xWidth) / 2; - - const capWidth = xWidth * 2 / 3; - - const leftEdgeMargin = xLeft; - const rightEdgeMargin = window.innerWidth - (xLeft + xWidth); - - if(leftEdgeMargin < xOverflow) { - // The overflow would be clipped by the left edge when centered, - // so we "offset" this part to keep it within screen bounds. - this.tip.style.transform = 'translateX(' + (xOverflow - leftEdgeMargin + 1) + 'px)'; - } else if(rightEdgeMargin < xOverflow) { - // The overflow would be clipped by the right edge when centered, - // so we "offset" this part to keep it within screen bounds. - this.tip.style.transform = 'translateX(-' + (xOverflow - rightEdgeMargin + 1) + 'px)'; - } - - if(!oskBaseFontSize.absolute) { - this.element.style.fontSize = keyman.osk.baseFontSize; - } - - let capHeight = xHeight / 3; - - this.cap.style.bottom = (rrow.height - capHeight) + 'px'; - this.cap.style.width = '0px'; - this.cap.style.height = '0px'; - this.cap.style.borderLeftWidth = (capWidth / 2) + 'px'; - this.cap.style.borderRightWidth = (capWidth / 2) + 'px'; - this.cap.style.borderTopWidth = capHeight + 'px'; - - bubbleStyle.display = 'block'; - } - - // Save the key preview state - this.state = on; - } - - private clearCatchers() { - // disable input-capture system - if(this.docInputCatch) { - this.docInputCatch.cancel(); - this.docInputCatch = null; - } - - if(this.tipInputTrap) { - this.tipInputTrap.cancel(); - this.tipInputTrap = null; - } - } - - public show(key: KeyElement, onDismiss?: () => void) { - this._show(key, true); - - const captureHandler = () => { - // Prevent duplicated dismissal calls that'd otherwise result when the tip itself receives a touch. - window.clearTimeout(this.delayedDismissTimerHandle); - this.hide(key); - onDismiss(); - }; - - if(onDismiss) { - // enable input-capture system - this.tipInputTrap = new SingleInputCapture(this.tip, true, captureHandler); - this.docInputCatch = new SingleInputCapture(document.body, false, () => { - this.delayedDismissTimerHandle = window.setTimeout(() => { - captureHandler(); - }, 1); // we need to delay processing so that events directly on the tip get priority first! - }); - } else { - // disable any previously-set input-captures as the incoming - // (thus, most-recent) call didn't request one. - this.clearCatchers(); - } - } - - public hide(key: KeyElement) { - this._show(key, false); - - this.clearCatchers(); - } - } -} \ No newline at end of file diff --git a/web/src/app/embed/osk/embedded/keytip.ts b/web/src/app/embed/osk/embedded/keytip.ts deleted file mode 100644 index da23aebe32..0000000000 --- a/web/src/app/embed/osk/embedded/keytip.ts +++ /dev/null @@ -1,49 +0,0 @@ -namespace com.keyman.osk.embedded { - export class KeyTip implements com.keyman.osk.KeyTip { - public key: KeyElement; - public state: boolean = false; - - private showPreview: (x: number, y: number, width: number, height: number, text: string) => void; - private clearPreview: () => void; - - constructor(showPreview: typeof KeyTip.prototype.showPreview, - clearPreview: () => void) { - - if(showPreview == null || typeof showPreview == 'function') { - this.showPreview = showPreview; - } - if(clearPreview == null || typeof clearPreview == 'function') { - this.clearPreview = clearPreview; - } - } - - show(key: KeyElement, on: boolean, vkbd: VisualKeyboard) { - let util = com.keyman.singleton.util; - - if(on && this.showPreview) { - var xBase = dom.Utils.getAbsoluteX(key) - dom.Utils.getAbsoluteX(vkbd.kbdDiv) + key.offsetWidth/2, - yBase = dom.Utils.getAbsoluteY(key) /*- dom.Utils.getAbsoluteY(this.kbdDiv) + bannerHeight*/, - kc; - - // Find key text element - for(var i=0; i= 0 && key.id.indexOf('K_SPACE') < 0) { - this.showPreview(xBase, yBase, key.offsetWidth, key.offsetHeight, kc.innerHTML); - } - } else if(!on && this.clearPreview) { - if(vkbd.touchCount == 0 || key == null) { - this.clearPreview(); - } - } - - this.key = key; - this.state = on; - } - } -} \ No newline at end of file diff --git a/web/src/app/embed/osk/embedded/pendingLongpress.ts b/web/src/app/embed/osk/embedded/pendingLongpress.ts deleted file mode 100644 index 0912732aa6..0000000000 --- a/web/src/app/embed/osk/embedded/pendingLongpress.ts +++ /dev/null @@ -1,47 +0,0 @@ -/// - -namespace com.keyman.osk.embedded { - /** - * As control over the subkey display timer and the subkey popup are - * both handled by the host app within the Android app, this class - * serves mostly to communicate longpress state management from the - * app to the VisualKeyboard. - * - * The `resolve()` function should be triggered by the host app - * whenever it has recognized a completed longpress and has thus - * begun displaying its subkey popup. (Should these two events - * ever become separated in time, the latter is the more critical - * aspect.) - */ - export class PendingLongpress implements PendingGesture { - private resolver: (delegator: SubkeyDelegator) => void; - private readonly vkbd: VisualKeyboard; - - public readonly baseKey: KeyElement; - public readonly promise: Promise; - - constructor(vkbd: VisualKeyboard, e: KeyElement) { - this.vkbd = vkbd; - let _this = this; - - this.promise = new Promise(function(resolve) { - _this.resolver = resolve; - }); - this.baseKey = e; - } - - public resolve() { - if(this.resolver) { - this.resolver(new SubkeyDelegator(this.vkbd, this.baseKey)); - } - this.resolver = null; - } - - public cancel() { - if(this.resolver) { - this.resolver(null); - this.resolver = null; - } - } - } -} \ No newline at end of file diff --git a/web/src/app/embed/osk/embedded/singleInputCapture.ts b/web/src/app/embed/osk/embedded/singleInputCapture.ts deleted file mode 100644 index a78dc9638a..0000000000 --- a/web/src/app/embed/osk/embedded/singleInputCapture.ts +++ /dev/null @@ -1,44 +0,0 @@ -namespace com.keyman.osk.embedded { - export class SingleInputCapture { - private readonly handler: (event: TouchEvent) => boolean; - private readonly baseElement: HTMLElement; - - private static readonly eventOptionConfig: AddEventListenerOptions = { - capture: true, - // While other - once: true, - passive: false, - // While `abort` exists, it's too new to rely upon it for some of the older platforms we support. - - }; - - constructor(element: HTMLElement, preventPropagation: boolean, captureCallback: () => void) { - this.baseElement = element; - - this.handler = (event) => { - captureCallback(); - if(preventPropagation) { - event.stopPropagation(); - event.stopImmediatePropagation(); - } - - // Make extra-sure this is only called once; older versions of Chrome for Android don't support - // the advanced options configuration that we prefer by default! - this.cancel(); - - return !preventPropagation; - } - - try { - // Not available in Android Chrome until version 49. `capture`: 52, `once`: 55, `passive`: 51. - this.baseElement.addEventListener('touchstart', this.handler, SingleInputCapture.eventOptionConfig); - } catch (err) { - this.baseElement.addEventListener('touchstart', this.handler, false); - } - } - - cancel() { - this.baseElement.removeEventListener('touchstart', this.handler, SingleInputCapture.eventOptionConfig.capture); - } - } -} \ No newline at end of file diff --git a/web/src/app/embed/osk/embedded/subkeyDelegator.ts b/web/src/app/embed/osk/embedded/subkeyDelegator.ts deleted file mode 100644 index 1b904ce97d..0000000000 --- a/web/src/app/embed/osk/embedded/subkeyDelegator.ts +++ /dev/null @@ -1,108 +0,0 @@ -namespace com.keyman.osk.embedded { - /** - * As the subkey popup view is handled by the host app when in embedded mode - * within our Android app, this class represents the fact that KMW has - * "delegated" subkey UI and selection to the host app. Hence, "Delegator", - * rather than "Popup". - * - * The `resolve` method should be triggered, in some fashion, by the host app - * whenever the user has completed their longpress, potentially selecting - * a subkey. - * - * This class will also track the ongoing touch event in case the base key is - * reselected, which _is_ managed by this class, not the host app. - */ - export class SubkeyDelegator implements RealizedGesture { - private resolver: (keyEvent: text.KeyEvent) => void; - private readonly vkbd: VisualKeyboard; - - public readonly baseKey: KeyElement; - public readonly promise: Promise; - - private movedFromBaseKey: boolean = false; - private baseKeySelected: boolean = false; - - constructor(vkbd: VisualKeyboard, e: KeyElement) { - this.vkbd = vkbd; - - let _this = this; - this.promise = new Promise(function(resolve) { - _this.resolver = resolve; - }); - - this.baseKey = e; - } - - /** - * Resolves the ongoing longpress -> subkey gesture, fulfilling this - * `SubkeyDelegator`'s `promise` of a `KeyEvent`. - * - * If no subkey is selected but the original base key is, `resolve(null)` - * will return a key event corresponding to the base key. - * - * @param keyCoreID {string} The 'core ID' (id + modifier layer) of - * a selected subkey. May be `null`. - */ - public resolve(keyCoreID: string) { - if(this.resolver) { - let keyEvent: text.KeyEvent = null; - - if(keyCoreID == null && this.baseKeySelected) { - // Handle selection of base key underneath the subkey array. - keyEvent = this.vkbd.keyEventFromSpec(this.baseKey.key.spec as keyboards.ActiveKey, null); - this.baseKey.key.highlight(false); - } else if(keyCoreID != null) { - // This is set with the base key of our current subkey elsewhere within the engine. - let baseKey: OSKKeySpec = this.baseKey.key.spec; - let selectedKey: OSKKeySpec; - - if(baseKey.coreID == keyCoreID) { - selectedKey = baseKey; - } else { - // ... yeah, there are some funky type shenanigans between the two. - // OSKKeySpec is the OSK's... reinterpretation of the ActiveKey type. - selectedKey = (baseKey as keyboards.ActiveKey).getSubkey(keyCoreID) as OSKKeySpec; - } - - if(!selectedKey) { - // While we can't complete successfully, the subkey operation is done; we - // should still signal that and update related gesture state management. - this.resolver(null); - console.error("Could not find subkey '" + keyCoreID + "' under base key '" + baseKey.coreID + "'!"); - return; - } - - keyEvent = this.vkbd.keyEventFromSpec(selectedKey as keyboards.ActiveKey, null); - keyEvent.vkCode=keyEvent.Lcode; - } // else /* if(keyCoreID == null) */ keyEvent = null; // As initialized at the top. - - this.resolver(keyEvent); - } - this.resolver = null; - } - - public isVisible(): boolean { - return true; - } - - public clear() { - // no-op; it's fully controlled on the app side. - } - - /** - * Allows this class to detect if the user may have changed their mind and - * re-selected the base key. - * @param touch - */ - updateTouch(input: InputEventCoordinate) { - this.baseKeySelected = this.baseKey.key.isUnderTouch(input); - - // Prevent highlighting & selection before the touch has moved from the base key. - if(this.movedFromBaseKey) { - this.baseKey.key.highlight(this.baseKeySelected); - } else { - this.movedFromBaseKey = !this.baseKeySelected; - } - } - } -} diff --git a/web/src/app/web/README.md b/web/src/app/web/README.md new file mode 100644 index 0000000000..ca2213a129 --- /dev/null +++ b/web/src/app/web/README.md @@ -0,0 +1,3 @@ +**NOTE**: _deprecated_ + +This subproject holds old namespaced-code corresponding to the new, modularized `app/browser` subproject. \ No newline at end of file diff --git a/web/src/app/web/kmwnative.ts b/web/src/app/web/kmwnative.ts index 793396e148..b192f07bb5 100644 --- a/web/src/app/web/kmwnative.ts +++ b/web/src/app/web/kmwnative.ts @@ -90,16 +90,6 @@ if(!window['keyman']['initialized']) { return (rx.test(Lfilename) ? '' : keymanweb.options['keyboards']) + Lfilename; } - /** - * Test if caret position is determined from the active element, or - * from the synthesized overlay element (touch devices) - * - * @return {boolean} - **/ - keymanweb.isPositionSynthesized = function() { - return device.touchable; - } - /** * Use rotation events to adjust OSK and input element positions and scaling as necessary */ diff --git a/web/src/app/webview/README.md b/web/src/app/webview/README.md new file mode 100644 index 0000000000..64f380a0aa --- /dev/null +++ b/web/src/app/webview/README.md @@ -0,0 +1 @@ +This subproject holds the new, modularized form of the old namespaced `app/embed` subproject. \ No newline at end of file diff --git a/web/src/app/webview/build-bundler.js b/web/src/app/webview/build-bundler.js new file mode 100644 index 0000000000..8da047207a --- /dev/null +++ b/web/src/app/webview/build-bundler.js @@ -0,0 +1,22 @@ +/* + * Note: while this file is not meant to exist long-term, it provides a nice + * low-level proof-of-concept for esbuild bundling of the various Web submodules. + * + * Add some extra code at the end of src/index.ts and run it to verify successful bundling! + */ + +import esbuild from 'esbuild'; +import { spawn } from 'child_process'; + +await esbuild.build({ + bundle: true, + sourcemap: true, + format: "iife", + nodePaths: ['../../../../node_modules'], + entryPoints: { + 'index': '../../../build/app/webview/obj/main.js', + }, + outdir: '../../../build/app/webview/lib/', + tsconfig: './tsconfig.json', + target: "es5" +}); diff --git a/web/src/app/webview/build.sh b/web/src/app/webview/build.sh new file mode 100755 index 0000000000..4ea2ed7e4d --- /dev/null +++ b/web/src/app/webview/build.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# + +# set -x +set -eu + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" +. "${THIS_SCRIPT%/*}/../../../../resources/build/build-utils.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" + +# This script runs from its own folder +cd "$THIS_SCRIPT_PATH" + +# Imports common Web build-script definitions & functions +SUBPROJECT_NAME=app/webview +. "$KEYMAN_ROOT/web/common.inc.sh" + +# ################################ Main script ################################ + +builder_describe "Builds the Keyman Engine for Web's puppetable version designed for use within WebViews." \ + "@/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/osk build" \ + "@/web/src/engine/main build" \ + "clean" \ + "configure" \ + "build" \ + "test" + +# Possible TODO?s +# "upload-symbols Uploads build product to Sentry for error report symbolification. Only defined for $DOC_BUILD_EMBED_WEB" \ + +builder_describe_outputs \ + configure /node_modules \ + build /web/build/$SUBPROJECT_NAME/lib/index.js + +builder_parse "$@" + +#### Build action definitions #### + +if builder_start_action configure; then + verify_npm_setup + + builder_finish_action success configure +fi + +if builder_start_action clean; then + rm -rf "$KEYMAN_ROOT/web/build/$SUBPROJECT_NAME" + builder_finish_action success clean +fi + +if builder_start_action build; then + compile $SUBPROJECT_NAME + + builder_finish_action success build +fi + +if builder_start_action test; then + # No headless tests of yet. + builder_finish_action success test +fi \ No newline at end of file diff --git a/web/src/app/webview/src/configuration.ts b/web/src/app/webview/src/configuration.ts new file mode 100644 index 0000000000..226ee50119 --- /dev/null +++ b/web/src/app/webview/src/configuration.ts @@ -0,0 +1,48 @@ +import { EngineConfiguration, InitOptionSpec, InitOptionDefaults } from "keyman/engine/main"; + +import { type OnInsertTextFunc } from "./contextManager.js"; + +export class WebviewConfiguration extends EngineConfiguration { + private _embeddingApp: string; + private _oninserttext: OnInsertTextFunc; + + initialize(options: Required) { + this.initialize(options); + + this._embeddingApp = options.embeddingApp; + } + + get embeddingApp() { + return this._embeddingApp; + } + + get oninserttext() { + return this._oninserttext; + } + + debugReport(): Record { + const baseReport = super.debugReport(); + baseReport.embeddingApp = this.embeddingApp; + baseReport.keymanEngine = 'app/webview'; + + return baseReport; + } +} + +export interface WebviewInitOptionSpec extends InitOptionSpec { + /** + * May be used to denote the name of the embedding application + */ + embeddingApp: string; + + /** + * Accepts a callback used for updating the host application's context. + */ + oninserttext?: OnInsertTextFunc; +} + +export const WebviewInitOptionDefaults: Required = { + embeddingApp: '', + oninserttext: null, + ...InitOptionDefaults +} \ No newline at end of file diff --git a/web/src/app/webview/src/contextManager.ts b/web/src/app/webview/src/contextManager.ts new file mode 100644 index 0000000000..47e465ba42 --- /dev/null +++ b/web/src/app/webview/src/contextManager.ts @@ -0,0 +1,63 @@ +import { type Keyboard, Mock } from '@keymanapp/keyboard-processor'; +import { type KeyboardStub } from 'keyman/engine/package-cache'; +import { ContextManagerBase, ContextManagerConfiguration } from 'keyman/engine/main'; +import { WebviewConfiguration } from './configuration.js'; + +export type OnInsertTextFunc = (deleteLeft: number, text: string, deleteRight: number) => void; + +class ContextHost extends Mock { + readonly oninserttext?: OnInsertTextFunc; + + constructor(oninserttext: OnInsertTextFunc) { + super(); + this.oninserttext = oninserttext; + } + + apply(transform: Transform): void { + super.apply(transform); + + // Signal the necessary text changes to the embedding app, if it exists. + if(this.oninserttext) { + this.oninserttext(transform.deleteLeft, transform.insert, transform.deleteRight); + } + } +} + +export default class ContextManager extends ContextManagerBase { + // Change of context? Just replace the Mock. Context will be ENTIRELY controlled + // by whatever is hosting the WebView. (Some aspects of this context replacement have + // yet to be modularized at this time, though.) + private _rawContext: ContextHost; + private config: WebviewConfiguration; + + private _activeKeyboard: {keyboard: Keyboard, metadata: KeyboardStub}; + + constructor(engineConfig: WebviewConfiguration) { + super(); + + this.config = engineConfig; + } + + initialize(): void { + this._rawContext = new ContextHost(this.config.oninserttext); + this.resetContext(); + } + + get activeTarget(): Mock { + return this._rawContext; + } + + get activeKeyboard() { + return this._activeKeyboard; + } + + set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub}) { + const priorEntry = this._activeKeyboard; + this._activeKeyboard = kbd; + + if(priorEntry.keyboard != kbd.keyboard || priorEntry.metadata != kbd.metadata) { + this.emit('keyboardchange', kbd); + this.resetContext(); + } + } +} \ No newline at end of file diff --git a/web/src/app/webview/src/keymanEngine.ts b/web/src/app/webview/src/keymanEngine.ts new file mode 100644 index 0000000000..4e6dc482b7 --- /dev/null +++ b/web/src/app/webview/src/keymanEngine.ts @@ -0,0 +1,208 @@ +import { DeviceSpec } from '@keymanapp/keyboard-processor' +import { KeymanEngine as KeymanEngineBase } from 'keyman/engine/main'; +import { AnchoredOSKView, ViewConfiguration, StaticActivator } from 'keyman/engine/osk'; +import { getAbsoluteX, getAbsoluteY } from 'keyman/engine/dom-utils'; +import { toPrefixedKeyboardId, toUnprefixedKeyboardId } from 'keyman/engine/package-cache'; + +import { WebviewConfiguration, WebviewInitOptionDefaults, WebviewInitOptionSpec } from './configuration.js'; +import ContextManager from './contextManager.js'; +import PassthroughKeyboard from './passthroughKeyboard.js'; +import { buildEmbeddedGestureConfig, setupEmbeddedListeners } from './oskConfiguration.js'; +import { SubkeyDelegator } from './osk/subkeyDelegator.js'; + +export class KeymanEngine extends KeymanEngineBase { + // Ideally, we would be able to auto-detect `sourceUri`: https://stackoverflow.com/a/60244278. + // But it's too new of a feature to utilize... and also expects to be in a module, when this may + // be compiled down to an IIFE. + constructor(worker: Worker, sourceUri: string) { + const config = new WebviewConfiguration(sourceUri); // currently set to perform device auto-detect. + config.stubNamespacer = (stub) => { + // If the package has not yet been applied as namespacing... + if(stub.KP && stub.KI.indexOf(`${stub.KP}::`) == -1) { + // Apply namespacing. To make 100% sure that we don't muck up internal prefixing, + // we ensure it is applied consistently in the manner specified below. + stub.KI = toPrefixedKeyboardId(`${stub.KP}::${toUnprefixedKeyboardId(stub.KI)}`); + } + } + + super(worker, config, new ContextManager(config)); + + this.hardKeyboard = new PassthroughKeyboard(config.hardDevice); + } + + init(options: Required) { + let device = new DeviceSpec( + 'native', + options.embeddingApp.indexOf('Tablet') >= 0 ? 'tablet' : 'phone', + this.config.hostDevice.OS, + true + ); + + this.config.hostDevice = device; + + super.init({...WebviewInitOptionDefaults, ...options}); + + this.contextManager.initialize(); + + const oskConfig: ViewConfiguration = { + hostDevice: this.config.hostDevice, + pathConfig: this.config.paths, + // When hosted in a WebView, we never hide the Web OSK without hiding the hosting WebView. + activator: new StaticActivator(), + embeddedGestureConfig: buildEmbeddedGestureConfig(this.config.softDevice), + doCacheBusting: true, + predictionContextManager: this.contextManager.predictionContext + }; + + this.osk = new AnchoredOSKView(oskConfig); + setupEmbeddedListeners(this, this.osk); + } + + // Functions that the old 'app/webview' equivalent had always provided to the WebView + + /** + * correctOSKTextSize handles rotation event -- currently rebuilds keyboard and adjusts font sizes + */ + correctOSKTextSize() { + this.osk?.refreshLayout(); + }; + + /** + * Function called by Android and iOS when a device-implemented keyboard popup + * is displayed or hidden. As this is controlled by the app, we use it as a + * trigger for 'embedded'-mode gesture state management. + * + * @param {boolean} isVisible + * + **/ + popupVisible(isVisible) { + const osk = this.osk; + if(!osk || !osk.vkbd) { + return; + } + + let gesture = osk.vkbd.subkeyGesture as SubkeyDelegator; + let pendingLongpress = osk.vkbd.pendingSubkey; + + /* + * If a longpress popup was visible, but is no longer, this means that the + * associated longpress gesture was cancelled. It is possible for the base + * key to emit if selected at this time; detection of this is managed by + * the `SubkeyDelegator` class. + */ + if(!isVisible) { + if(gesture) { + gesture.resolve(null); + osk.vkbd.subkeyGesture = null; + } else if(pendingLongpress) { + pendingLongpress.cancel(); + osk.vkbd.pendingSubkey = null; + } + } + + /* + * If the popup was not visible, but now is, that means our previously-pending + * longpress is now 'realized' (complete). The OSK relies upon this state + * information, which will be properly updated by `resolve`. + * + * Prominent uses of such state info helps prevent change of base key, key + * previews, and key output from occurring while a subkey popup remains active. + */ + if(isVisible && pendingLongpress) { + // Fulfills the first-stage promise. + pendingLongpress.resolve(); + } + }; + + /** + * Return position of language menu key to KeymanTouch + * + * @return {string} comma-separated x,y,w,h of language menu key + * + **/ + touchMenuPos() { + const osk = this.osk; + if(osk == null || osk.vkbd == null || osk.vkbd.lgKey == null) { + return ''; + } + + let key: HTMLElement = osk.vkbd.lgKey; + // A CSS change of kmd-key-square from position:fixed to position:static was needed + // for Android 4.3 to display the OSK correctly, but resulted in the position of + // the menu key not being returned correctly. The following line gets the + // key element, instead of the key-square element, fixes this. It should be + // removed again when the key-square elements are all removed as planned. + const child = key.firstChild as HTMLElement; + if(typeof child != 'undefined' && child != null && child.classList.contains('kmw-key')) { + key = child; + } + + var w=key.offsetWidth, + h=key.offsetHeight, + // Since the full OSKManager '_Box' is displayed within the keyboards' WebViews, + // these calculations should be performed with respect to that, rather than osk.vkbd.kbdDiv. + x = getAbsoluteX(key) - getAbsoluteX(osk._Box) + w/2, + y = getAbsoluteY(key) - getAbsoluteY(osk._Box); + + return x+','+y+','+w+','+h; + }; + + showGlobeHint(text: string, onAutodismissal?: () => void) { + const globeHint = this.osk?.vkbd?.globeHint; + + if(globeHint) { + // Ensure localized text is properly in-place. + globeHint.text = text; + globeHint.show(this.osk.vkbd.currentLayer.globeKey.btn, onAutodismissal); + } + } + + hideGlobeHint() { + this.osk?.vkbd?.globeHint?.hide(this.osk.vkbd.currentLayer.globeKey.btn); + } + + /** + * Accept an external key ID (from KeymanTouch) and pass to the keyboard mapping + * + * @param {string} keyName key identifier which could contain a display layer and a "functional" layer + * e.g: 'shift-K_E+rightalt-shift' + **/ + executePopupKey(keyName: string) { + const vkbd = this.osk?.vkbd; + let origArg = keyName; + if(!this.contextManager.activeKeyboard || !vkbd) { + return false; + } + + /* Clear any pending (non-popup) key */ + vkbd.keyPending = null; + + // Changes for Build 353 to resolve KMEI popup key issues + keyName=keyName.replace('popup-',''); //remove popup prefix if present (unlikely) + + // Regex for 'display layer'-'virtual key name'+'optional functional layer' + // Can't just split on '-' because some layers like ctrl-shift contain it. + // Virtual key name starts with T_, K_, or U_ + // matches[1]: displayLayer (not used) + // matches[2]: keyId + // matches[3]: optional functionalLayer + let matches = keyName.match(/^(.+)-([TKU]_[^+]+)\+?(.+)?$/); + if (matches == null) { + return false; + } + keyName = matches[2] + (matches[3] ? '+' + matches[3] : ''); + + // This should be set if we're within this method... but it's best to guard against nulls here, just in case. + if(vkbd.subkeyGesture) { + let gesture = vkbd.subkeyGesture as SubkeyDelegator; + gesture.resolve(keyName); + vkbd.subkeyGesture = null; + } else { + console.warn("No base key exists for the subkey being executed: '" + origArg + "'"); + } + }; + + // Properties set by the WebView hosting page + hideKeyboard?: () => void = null; + menuKeyUp?: () => void = null; +} diff --git a/web/src/app/webview/src/main.ts b/web/src/app/webview/src/main.ts new file mode 100644 index 0000000000..e2a56806ca --- /dev/null +++ b/web/src/app/webview/src/main.ts @@ -0,0 +1 @@ +import ContextManager from './contextManager.js'; \ No newline at end of file diff --git a/web/src/app/webview/src/osk/globeHint.ts b/web/src/app/webview/src/osk/globeHint.ts new file mode 100644 index 0000000000..a6d8ed0abb --- /dev/null +++ b/web/src/app/webview/src/osk/globeHint.ts @@ -0,0 +1,194 @@ +import { + type GlobeHint as GlobeHintInterface, + type KeyElement, + type OSKBaseKey, + ParsedLengthStyle, + type VisualKeyboard +} from "keyman/engine/osk"; + +import { SingleInputCapture } from "./singleInputCapture.js"; + +/** + * The GlobeHint is a specialized key tip that is presented above + * the globe key on first use of Keyman, to show the user how to + * switch input methods. The label is localizable text. + */ +export class GlobeHint implements GlobeHintInterface { + public readonly element: HTMLDivElement; + public state: boolean = false; + + // ----- + // | | <-- tip + // | x | <-- label + // |_ _| + // \ / <-- cap + // v + + private readonly cap: HTMLDivElement; + private readonly tip: HTMLDivElement; + private readonly label: HTMLSpanElement; + + private readonly vkbd: VisualKeyboard; + + private docInputCatch?: SingleInputCapture; + private tipInputTrap?: SingleInputCapture; + + private delayedDismissTimerHandle: number; + + constructor(vkbd: VisualKeyboard) { + this.vkbd = vkbd; + + let tipElement = this.element=document.createElement('div'); + tipElement.className = 'kmw-globehint'; + tipElement.id = 'kmw-globehint'; + + // The following style is critical, so do not rely on external CSS + tipElement.style.pointerEvents = 'none'; + tipElement.style.display = 'none'; + + tipElement.appendChild(this.tip = document.createElement('div')); + tipElement.appendChild(this.cap = document.createElement('div')); + this.tip.appendChild(this.label = document.createElement('span')); + + this.tip.className = 'kmw-globehint-tip'; + this.cap.className = 'kmw-globehint-cap'; + this.label.className = 'kmw-globehint-label'; + + this.text = "Tap here to change keyboard."; + } + + get text(): string { + return this.label.innerText; + } + + set text(val: string) { + this.label.innerText = val; + } + + private _show(key: KeyElement, on: boolean) { + //let keyman = com.keyman.singleton; + + // Create and display the preview + // If !key.offsetParent, the OSK is probably hidden. Either way, it's a half- + // decent null-guard check. + if(!on || !key.offsetParent) { + this.element.style.display = 'none'; + } else { + // The key element is positioned relative to its key-square, which is, + // in turn, relative to its row. Rows take 100% width, so this is sufficient. + // + let rowElement = (key.key as OSKBaseKey).row.element; + + // May need adjustment for borders if ever enabled for the desktop form-factor target. + let rkey = key.getClientRects()[0], rrow = rowElement.getClientRects()[0]; + let xLeft = rkey.left - rrow.left, + xWidth = rkey.width, + xHeight = rkey.height; + + let center = rkey.left + rkey.width/2; + + let bubbleStyle = this.element.style; + + // Roughly matches how the subkey positioning is set. + const _Box = this.vkbd.element.parentNode as HTMLDivElement; + const _BoxRect = _Box.getBoundingClientRect(); + const keyRect = key.getBoundingClientRect(); + let y = (keyRect.bottom - _BoxRect.top + 1); + + // Width dimensions must be set explicitly to prevent clipping. + // We'll assume that the globe key is always positioned on the bottom row. + let oskBaseFontSize = new ParsedLengthStyle(this.vkbd.fontSize.styleString); + let bubbleWidth = Math.ceil(xWidth * 3); + + bubbleStyle.bottom = Math.floor(this.vkbd.height - y) + 'px'; + // CSS already defines transform: translateX(-50%) - this centers the element. + bubbleStyle.left = center + 'px'; + + this.tip.style.bottom = (rrow.height - 1) + 'px'; + this.tip.style.width = bubbleWidth + 'px'; + this.tip.style.pointerEvents = on ? 'auto' : 'none'; + + // Adjust shape if at edges + + // how much width lies outside the range of the base key, per side + const xOverflow = (bubbleWidth - xWidth) / 2; + + const capWidth = xWidth * 2 / 3; + + const leftEdgeMargin = xLeft; + const rightEdgeMargin = window.innerWidth - (xLeft + xWidth); + + if(leftEdgeMargin < xOverflow) { + // The overflow would be clipped by the left edge when centered, + // so we "offset" this part to keep it within screen bounds. + this.tip.style.transform = 'translateX(' + (xOverflow - leftEdgeMargin + 1) + 'px)'; + } else if(rightEdgeMargin < xOverflow) { + // The overflow would be clipped by the right edge when centered, + // so we "offset" this part to keep it within screen bounds. + this.tip.style.transform = 'translateX(-' + (xOverflow - rightEdgeMargin + 1) + 'px)'; + } + + if(!oskBaseFontSize.absolute) { + this.element.style.fontSize = this.vkbd.fontSize.styleString; + } + + let capHeight = xHeight / 3; + + this.cap.style.bottom = (rrow.height - capHeight) + 'px'; + this.cap.style.width = '0px'; + this.cap.style.height = '0px'; + this.cap.style.borderLeftWidth = (capWidth / 2) + 'px'; + this.cap.style.borderRightWidth = (capWidth / 2) + 'px'; + this.cap.style.borderTopWidth = capHeight + 'px'; + + bubbleStyle.display = 'block'; + } + + // Save the key preview state + this.state = on; + } + + private clearCatchers() { + // disable input-capture system + if(this.docInputCatch) { + this.docInputCatch.cancel(); + this.docInputCatch = null; + } + + if(this.tipInputTrap) { + this.tipInputTrap.cancel(); + this.tipInputTrap = null; + } + } + + public show(key: KeyElement, onDismiss?: () => void) { + this._show(key, true); + + const captureHandler = () => { + // Prevent duplicated dismissal calls that'd otherwise result when the tip itself receives a touch. + window.clearTimeout(this.delayedDismissTimerHandle); + this.hide(key); + onDismiss(); + }; + + if(onDismiss) { + // enable input-capture system + this.tipInputTrap = new SingleInputCapture(this.tip, true, captureHandler); + this.docInputCatch = new SingleInputCapture(document.body, false, () => { + this.delayedDismissTimerHandle = window.setTimeout(() => { + captureHandler(); + }, 1); // we need to delay processing so that events directly on the tip get priority first! + }); + } else { + // disable any previously-set input-captures as the incoming + // (thus, most-recent) call didn't request one. + this.clearCatchers(); + } + } + + public hide(key: KeyElement) { + this._show(key, false); + + this.clearCatchers(); + } +} \ No newline at end of file diff --git a/web/src/app/webview/src/osk/keytip.ts b/web/src/app/webview/src/osk/keytip.ts new file mode 100644 index 0000000000..d2de6c2f7e --- /dev/null +++ b/web/src/app/webview/src/osk/keytip.ts @@ -0,0 +1,53 @@ +import { getAbsoluteX, getAbsoluteY } from "keyman/engine/dom-utils"; + +import { + type KeyTip as KeyTipInterface, + type KeyElement, + type VisualKeyboard +} from "keyman/engine/osk"; + +export class KeyTip implements KeyTipInterface { + public key: KeyElement; + public state: boolean = false; + + private showPreview: (x: number, y: number, width: number, height: number, text: string) => void; + private clearPreview: () => void; + + constructor(showPreview: typeof KeyTip.prototype.showPreview, + clearPreview: () => void) { + + if(showPreview == null || typeof showPreview == 'function') { + this.showPreview = showPreview; + } + if(clearPreview == null || typeof clearPreview == 'function') { + this.clearPreview = clearPreview; + } + } + + show(key: KeyElement, on: boolean, vkbd: VisualKeyboard) { + if(on && this.showPreview) { + let xBase = getAbsoluteX(key) - getAbsoluteX(vkbd.kbdDiv) + key.offsetWidth/2, + yBase = getAbsoluteY(key) /*- getAbsoluteY(this.kbdDiv) + bannerHeight*/; + let kc: Element; + + // Find key text element + for(let i=0; i= 0 && key.id.indexOf('K_SPACE') < 0) { + this.showPreview(xBase, yBase, key.offsetWidth, key.offsetHeight, kc.innerHTML); + } + } else if(!on && this.clearPreview) { + if(vkbd.touchCount == 0 || key == null) { + this.clearPreview(); + } + } + + this.key = key; + this.state = on; + } +} \ No newline at end of file diff --git a/web/src/app/webview/src/osk/pendingLongpress.ts b/web/src/app/webview/src/osk/pendingLongpress.ts new file mode 100644 index 0000000000..11effc399c --- /dev/null +++ b/web/src/app/webview/src/osk/pendingLongpress.ts @@ -0,0 +1,52 @@ + +import { + type KeyElement, + PendingGesture, + VisualKeyboard +} from 'keyman/engine/osk'; + +import { SubkeyDelegator } from "./subkeyDelegator.js"; + +/** + * As control over the subkey display timer and the subkey popup are + * both handled by the host app within the Android app, this class + * serves mostly to communicate longpress state management from the + * app to the VisualKeyboard. + * + * The `resolve()` function should be triggered by the host app + * whenever it has recognized a completed longpress and has thus + * begun displaying its subkey popup. (Should these two events + * ever become separated in time, the latter is the more critical + * aspect.) + */ +export class PendingLongpress implements PendingGesture { + private resolver: (delegator: SubkeyDelegator) => void; + private readonly vkbd: VisualKeyboard; + + public readonly baseKey: KeyElement; + public readonly promise: Promise; + + constructor(vkbd: VisualKeyboard, e: KeyElement) { + this.vkbd = vkbd; + let _this = this; + + this.promise = new Promise(function(resolve) { + _this.resolver = resolve; + }); + this.baseKey = e; + } + + public resolve() { + if(this.resolver) { + this.resolver(new SubkeyDelegator(this.vkbd, this.baseKey)); + } + this.resolver = null; + } + + public cancel() { + if(this.resolver) { + this.resolver(null); + this.resolver = null; + } + } +} \ No newline at end of file diff --git a/web/src/app/webview/src/osk/singleInputCapture.ts b/web/src/app/webview/src/osk/singleInputCapture.ts new file mode 100644 index 0000000000..9a592ee117 --- /dev/null +++ b/web/src/app/webview/src/osk/singleInputCapture.ts @@ -0,0 +1,42 @@ +export class SingleInputCapture { + private readonly handler: (event: TouchEvent) => boolean; + private readonly baseElement: HTMLElement; + + private static readonly eventOptionConfig: AddEventListenerOptions = { + capture: true, + // While other + once: true, + passive: false, + // While `abort` exists, it's too new to rely upon it for some of the older platforms we support. + + }; + + constructor(element: HTMLElement, preventPropagation: boolean, captureCallback: () => void) { + this.baseElement = element; + + this.handler = (event) => { + captureCallback(); + if(preventPropagation) { + event.stopPropagation(); + event.stopImmediatePropagation(); + } + + // Make extra-sure this is only called once; older versions of Chrome for Android don't support + // the advanced options configuration that we prefer by default! + this.cancel(); + + return !preventPropagation; + } + + try { + // Not available in Android Chrome until version 49. `capture`: 52, `once`: 55, `passive`: 51. + this.baseElement.addEventListener('touchstart', this.handler, SingleInputCapture.eventOptionConfig); + } catch (err) { + this.baseElement.addEventListener('touchstart', this.handler, false); + } + } + + cancel() { + this.baseElement.removeEventListener('touchstart', this.handler, SingleInputCapture.eventOptionConfig.capture); + } +} \ No newline at end of file diff --git a/web/src/app/webview/src/osk/subkeyDelegator.ts b/web/src/app/webview/src/osk/subkeyDelegator.ts new file mode 100644 index 0000000000..30289e8aee --- /dev/null +++ b/web/src/app/webview/src/osk/subkeyDelegator.ts @@ -0,0 +1,116 @@ +import { type ActiveKey, type KeyEvent } from '@keymanapp/keyboard-processor'; + +import { + type InputEventCoordinate, + type KeyElement, + type OSKKeySpec, + RealizedGesture, + VisualKeyboard +} from 'keyman/engine/osk'; + +/** + * As the subkey popup view is handled by the host app when in embedded mode + * within our Android app, this class represents the fact that KMW has + * "delegated" subkey UI and selection to the host app. Hence, "Delegator", + * rather than "Popup". + * + * The `resolve` method should be triggered, in some fashion, by the host app + * whenever the user has completed their longpress, potentially selecting + * a subkey. + * + * This class will also track the ongoing touch event in case the base key is + * reselected, which _is_ managed by this class, not the host app. + */ +export class SubkeyDelegator implements RealizedGesture { + private resolver: (keyEvent: KeyEvent) => void; + private readonly vkbd: VisualKeyboard; + + public readonly baseKey: KeyElement; + public readonly promise: Promise; + + private movedFromBaseKey: boolean = false; + private baseKeySelected: boolean = false; + + constructor(vkbd: VisualKeyboard, e: KeyElement) { + this.vkbd = vkbd; + + let _this = this; + this.promise = new Promise(function(resolve) { + _this.resolver = resolve; + }); + + this.baseKey = e; + } + + /** + * Resolves the ongoing longpress -> subkey gesture, fulfilling this + * `SubkeyDelegator`'s `promise` of a `KeyEvent`. + * + * If no subkey is selected but the original base key is, `resolve(null)` + * will return a key event corresponding to the base key. + * + * @param keyCoreID {string} The 'core ID' (id + modifier layer) of + * a selected subkey. May be `null`. + */ + public resolve(keyCoreID: string) { + if(this.resolver) { + let keyEvent: KeyEvent = null; + + if(keyCoreID == null && this.baseKeySelected) { + // Handle selection of base key underneath the subkey array. + keyEvent = this.vkbd.keyEventFromSpec(this.baseKey.key.spec as ActiveKey, null); + this.baseKey.key.highlight(false); + } else if(keyCoreID != null) { + // This is set with the base key of our current subkey elsewhere within the engine. + let baseKey: OSKKeySpec = this.baseKey.key.spec; + let selectedKey: OSKKeySpec; + + if(baseKey.coreID == keyCoreID) { + selectedKey = baseKey; + } else { + // ... yeah, there are some funky type shenanigans between the two. + // OSKKeySpec is the OSK's... reinterpretation of the ActiveKey type. + selectedKey = (baseKey as ActiveKey).getSubkey(keyCoreID) as OSKKeySpec; + } + + if(!selectedKey) { + // While we can't complete successfully, the subkey operation is done; we + // should still signal that and update related gesture state management. + this.resolver(null); + console.error("Could not find subkey '" + keyCoreID + "' under base key '" + baseKey.coreID + "'!"); + return; + } + + keyEvent = this.vkbd.keyEventFromSpec(selectedKey as ActiveKey, null); + keyEvent.vkCode=keyEvent.Lcode; + } // else /* if(keyCoreID == null) */ keyEvent = null; // As initialized at the top. + + this.resolver(keyEvent); + } + this.resolver = null; + } + + public isVisible(): boolean { + return true; + } + + public clear() { + // no-op; it's fully controlled on the app side. + } + + /** + * Allows this class to detect if the user may have changed their mind and + * re-selected the base key. + * @param touch + */ + updateTouch(input: InputEventCoordinate) { + this.baseKeySelected = this.baseKey.key.isUnderTouch(input); + + // Prevent highlighting & selection before the touch has moved from the base key. + if(this.movedFromBaseKey) { + this.baseKey.key.highlight(this.baseKeySelected); + } else { + this.movedFromBaseKey = !this.baseKeySelected; + } + } +} \ No newline at end of file diff --git a/web/src/app/webview/src/oskConfiguration.ts b/web/src/app/webview/src/oskConfiguration.ts new file mode 100644 index 0000000000..6737b64e38 --- /dev/null +++ b/web/src/app/webview/src/oskConfiguration.ts @@ -0,0 +1,79 @@ +import { type KeyElement, OSKView, VisualKeyboard } from "keyman/engine/osk"; +import { getAbsoluteX, getAbsoluteY } from "keyman/engine/dom-utils"; +import { DeviceSpec } from "@keymanapp/keyboard-processor"; +import { type EmbeddedGestureConfig } from "keyman/engine/osk"; + +import { GlobeHint } from './osk/globeHint.js'; +import { KeyTip } from './osk/keytip.js'; +import { PendingLongpress } from './osk/pendingLongpress.js'; +import { type KeymanEngine } from "./keymanEngine.js"; + +export function setupEmbeddedListeners(engine: KeymanEngine, osk: OSKView) { + osk.on('globeKey', (key, on) => { + if(on) { + if(typeof window['showKeyboardList'] == 'function') { // OSKView event: shouldShowLanguageMenu + window['showKeyboardList'](); // Is connected to VisualKeyboard event: globeKey + } + } else if(osk.vkbd) { + if(osk.vkbd.menuEvent) { + this.highlightKey(osk.vkbd.menuEvent, false); + } + if(typeof(engine.menuKeyUp) == 'function') { // VisualKeyboard event: globeKey + engine.menuKeyUp(); + } + osk.vkbd.menuEvent = null; + } + + if(osk.vkbd) { + osk.vkbd.highlightKey(key, false); // never leave the globe key highlighted + } + }); + + osk.on('hideRequested', (key) => { + if(osk.vkbd) { + osk.vkbd.highlightKey(key, false); + } + if(typeof engine.hideKeyboard == 'function') { // VisualKeyboard event: hideRequested + engine.hideKeyboard(); + } + }) +} + +export function buildEmbeddedGestureConfig(device: DeviceSpec) { + const embeddedGestureConfig: EmbeddedGestureConfig = { + createGlobeHint: (vkbd) => { + return new GlobeHint(vkbd); + } + } + + if(device.OS == DeviceSpec.OperatingSystem.Android) { + embeddedGestureConfig.createKeyTip = (vkbd) => { + if(vkbd.device.formFactor == 'phone') { + return new KeyTip(window['oskCreateKeyPreview'], window['oskClearKeyPreview']); + } else { + return null; + } + } + + embeddedGestureConfig.startLongpress = (vkbd, key) => { + if(typeof(window['oskCreatePopup']) == 'function') { + var xBase = getAbsoluteX(key) - getAbsoluteX(vkbd.kbdDiv) + key.offsetWidth/2, + yBase = getAbsoluteY(key); + + // #3718: No longer prepend base key to subkey array + window['oskCreatePopup'](key['subKeys'], xBase, yBase, key.offsetWidth, key.offsetHeight); + + return new PendingLongpress(vkbd, key); + } else { + // When embedded within our Android app, we expect the `oskCreatePopup` function to + // exist; all subkey control is delegated to the app. + // + // No function = big problem. + console.error("Missing `oskCreatePopup` function for engine integration."); + return null; + } + } + + return embeddedGestureConfig; + } +}; \ No newline at end of file diff --git a/web/src/app/webview/src/passthroughKeyboard.ts b/web/src/app/webview/src/passthroughKeyboard.ts new file mode 100644 index 0000000000..437ccf1914 --- /dev/null +++ b/web/src/app/webview/src/passthroughKeyboard.ts @@ -0,0 +1,54 @@ +import { DeviceSpec, KeyEvent, ManagedPromise } from '@keymanapp/keyboard-processor'; + +import { HardKeyboard } from 'keyman/engine/main'; + +export default class PassthroughKeyboard extends HardKeyboard { + readonly baseDevice: DeviceSpec; + + constructor(baseDevice: DeviceSpec) { + super(); + this.baseDevice = baseDevice; + } + + /** + * API endpoint for hardware keystroke events from WebView-external keyboards + * (such as external keyboards for a phone used with the Android app) + * + * @param {number} code key identifier + * @param {number} shift shift state (0x01=left ctrl 0x02=right ctrl 0x04=left alt 0x08=right alt + * 0x10=shift 0x20=ctrl 0x40=alt) + * @param {number} lstates lock state (0x0200=no caps 0x0400=num 0x0800=no num 0x1000=scroll 0x2000=no scroll locks) + * @return {Promise} false when KMW _has_ fully handled the event and true when not. + **/ + raiseKeyEvent(code: number, shift: number, lstates: number): Promise { + if(code == 0) { + return Promise.resolve(false); + } + + // Check the virtual key + let Lkc: KeyEvent = new KeyEvent({ + Lmodifiers: shift, + vkCode: code, + Lcode: code, + Lstates: lstates, + LisVirtualKey: true, + kName: '', + device: this.baseDevice, + isSynthetic: false // is not an OSK keystroke. + }); + + const promise = new ManagedPromise(); + + try { + this.emit('keyEvent', Lkc, (result, error) => { + if(error) { + promise.reject(error); + } else { + promise.resolve(!result || result.triggerKeyDefault); + } + }); + } catch (err) { + promise.reject(err); + } + } +} \ No newline at end of file diff --git a/web/src/app/webview/tsconfig.json b/web/src/app/webview/tsconfig.json new file mode 100644 index 0000000000..043cb3649b --- /dev/null +++ b/web/src/app/webview/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig-base.json", + + "compilerOptions": { + "allowJs": false, + "inlineSources": true, + "allowSyntheticDefaultImports": true, + "module": "es6", + "moduleResolution": "Node16", + "sourceMap": true, + "target": "es5", + "baseUrl": "./", + "outDir": "../../../build/app/webview/obj/", + "tsBuildInfoFile": "../../../build/app/webview/obj/tsconfig.tsbuildinfo", + "rootDir": "./src" + }, + + "include": [ "**/*.ts" ], + + "references": [ + { "path": "../../../../common/web/keyman-version" }, + { "path": "../../../../common/web/utils" } + ] +} diff --git a/web/src/engine/README.md b/web/src/engine/README.md new file mode 100644 index 0000000000..7bf84270c2 --- /dev/null +++ b/web/src/engine/README.md @@ -0,0 +1,3 @@ +**NOTE**: _deprecated_ + +This subproject holds old namespaced-code corresponding to the new, modularized `engine/main` subproject. \ No newline at end of file diff --git a/web/src/engine/build.sh b/web/src/engine/build.sh deleted file mode 100755 index 36ba484bf3..0000000000 --- a/web/src/engine/build.sh +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env bash -# -# Compile keymanweb and copy compiled javascript and resources to output/embedded folder -# - -# set -x -set -eu - -## START STANDARD BUILD SCRIPT INCLUDE -# adjust relative paths as necessary -THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" -. "${THIS_SCRIPT%/*}/../../../resources/build/build-utils.sh" -## END STANDARD BUILD SCRIPT INCLUDE - -. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" - -WORKING_DIRECTORY=`pwd` - -# This script runs from its own folder -cd "$THIS_SCRIPT_PATH" - -# ################################ Main script ################################ - -# Definition of global compile constants - -MAIN=engine/main # Covers all engine code, including submodules like those listed below. -DEVICEDETECT=engine/device-detect -ELEMENTWRAPPERS=engine/element-wrappers - -BUILD_BASE=build - -OUTPUT_DIR=obj - -# Composites and outputs the output path corresponding to the build configuration -# specified by the parameters. -# -# ### Parameters -# -# * 1: - build product (app/embed, app/web, app/ui, engine) -# * 2: (optional) - build stage / config (obj, debug, release) -# -# ### Example -# -# ```bash -# cp index.js "$(output_path app/web debug)/index.js" -# ``` -# -# The block above would copy index.js into the build output folder for app/web's debug -# product. -# -# ``` bash -# rm -rf "$(output_path app/web)" -# ``` -# -# The block above is useful for deleting all app/web build products as part of a `clean` -# action. -# -# ### Other Notes -# -# In the future, we may opt to move $INTERMEDIATE stuff underneath both $DEBUG and $RELEASE, -# making it a third param. This is currently unclear, but if so, we'd do -# $DEBUG/$INTERMEDIATE and $RELEASE/$INTERMEDIATE via a third argument. -output_path ( ) { - if [ $# -lt 1 ]; then - echo "Insufficient argument count!" - exit 1 - elif [ $# -eq 1 ]; then - # Used by clean: actions - echo "$BUILD_BASE/$1" - else - echo "$BUILD_BASE/$1/$2" - fi -} - -# TODO: eliminate $SOURCE variable -SOURCE="src" - -# Ensures that we rely first upon the local npm-based install of Typescript. -# (Facilitates automated setup for build agents.) -# TODO: this should be removeable given set_keyman_standard_build_path does this in build-utils.sh (and relative paths are dodgy in $PATH!) -PATH="../../../node_modules/.bin:$PATH" - -# TODO: call tsc directly -- but currently it runs from wrong folder if called directly -# TODO: eliminate $compiler and $compilecmd variables -compiler="npm run tsc --" -compilecmd="$compiler" - -builder_describe "Builds engine modules for Keyman Engine for Web (KMW)." \ - "@/common/web/keyman-version build:main" \ - "@/common/web/input-processor build:main" \ - "clean" \ - "configure" \ - "build" \ - ":device-detect Subset used for device-detection " \ - ":element-wrappers Subset used to integrate with website elements" \ - ":main Builds all common code used by KMW's app/-level targets" - -# Possible TODO? -# "upload-symbols Uploads build product to Sentry for error report symbolification. Only defined for $DOC_BUILD_EMBED_WEB" \ - -builder_describe_outputs \ - configure /node_modules \ - configure:device-detect /node_modules \ - configure:element-wrappers /node_modules \ - configure:main /node_modules \ - build:device-detect $(output_path $DEVICEDETECT $OUTPUT_DIR)/index.js \ - build:element-wrappers $(output_path $ELEMENTWRAPPERS $OUTPUT_DIR)/index.js \ - build:main $(output_path $MAIN $OUTPUT_DIR)/keymanweb.js -# TODO: eliminate outpath_path function and related variables and just use the plain filenames - -builder_parse "$@" - -#### Build utility methods + definitions #### - -# Compiles all build products corresponding to the specified target. -# -# ### Parameters -# -# * 1: `product` the product's source path under src/ -# -# ### Example -# -# ```bash -# compile engine/main -# ``` -compile ( ) { - if [ $# -lt 1 ]; then - builder_die "Scripting error: insufficient argument count!" - fi - - local COMPILE_TARGET=$1 - local COMPILED_OUTPUT_PATH="$(output_path $COMPILE_TARGET $OUTPUT_DIR)" - - BUNDLE_CONFIG=src/$COMPILE_TARGET/tsconfig.bundled.json - - $compilecmd -b src/$COMPILE_TARGET -v - - # Handle any 'bundled' compilations, too! - # `if`'s working dir: web/src/engine; $compilecmd's: web/ - if [ -f "../../$BUNDLE_CONFIG" ]; then - $compilecmd -b $BUNDLE_CONFIG -v - fi - - echo $COMPILE_TARGET TypeScript compiled under $COMPILED_OUTPUT_PATH -} - -#### Build action definitions #### - -if builder_start_action configure; then - verify_npm_setup - - builder_finish_action success configure -fi - -## Clean actions - -# Possible issue: there's no clear rule to `clean` the engine, which is auto-built -# by build:embed and build:web. -# -# Some sort of command to run ONLY for a general `clean` (no target specified) would -# be perfect for that, I think. - -if builder_start_action clean:device-detect; then - rm -rf "$(output_path $DEVICEDETECT)" - builder_finish_action success clean:device-detect -fi - -if builder_start_action clean:element-wrappers; then - rm -rf "$(output_path $ELEMENTWRAPPERS)" - builder_finish_action success clean:element-wrappers -fi - -if builder_start_action clean:main; then - rm -rf "$(output_path $ENGINE)" - builder_finish_action success clean:main -fi - -## Build actions - -# Adds a simple version 'header' when there's a main engine build product. -if builder_start_action build:device-detect; then - compile $DEVICEDETECT - - builder_finish_action success build:device-detect -fi - -if builder_start_action build:element-wrappers; then - compile $ELEMENTWRAPPERS - - builder_finish_action success build:element-wrappers -fi - -if builder_start_action build:main; then - compile $MAIN - - builder_finish_action success build:main -fi \ No newline at end of file diff --git a/web/src/engine/device-detect/build-bundler.js b/web/src/engine/device-detect/build-bundler.js new file mode 100644 index 0000000000..272419cc57 --- /dev/null +++ b/web/src/engine/device-detect/build-bundler.js @@ -0,0 +1,23 @@ +/* + * Note: while this file is not meant to exist long-term, it provides a nice + * low-level proof-of-concept for esbuild bundling of the various Web submodules. + * + * Add some extra code at the end of src/index.ts and run it to verify successful bundling! + */ + +import esbuild from 'esbuild'; +import { spawn } from 'child_process'; + +await esbuild.build({ + bundle: true, + sourcemap: true, + format: "esm", + nodePaths: ['../../../../node_modules', '../../../build/engine/device-detect/obj'], + entryPoints: { + 'index': '../../../build/engine/device-detect/obj/index.js', + }, + external: ['fs', 'vm'], + outfile: '../../../build/engine/device-detect/lib/index.mjs', + tsconfig: './tsconfig.json', + target: "es5" +}); diff --git a/web/src/engine/device-detect/build.sh b/web/src/engine/device-detect/build.sh new file mode 100755 index 0000000000..8f3fcfc7b1 --- /dev/null +++ b/web/src/engine/device-detect/build.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# + +# set -x +set -eu + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" +. "${THIS_SCRIPT%/*}/../../../../resources/build/build-utils.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" + +# This script runs from its own folder +cd "$THIS_SCRIPT_PATH" + +# Imports common Web build-script definitions & functions +SUBPROJECT_NAME=engine/device-detect +. "$KEYMAN_ROOT/web/common.inc.sh" + +# ################################ Main script ################################ + +builder_describe "Builds the device-detection component of Keyman Engine for Web (KMW)." \ + "@/common/web/keyman-version" \ + "@/common/web/utils" \ + "clean" \ + "configure" \ + "build" \ + "test" + +# Possible TODO? +# "upload-symbols Uploads build product to Sentry for error report symbolification. Only defined for $DOC_BUILD_EMBED_WEB" \ + +builder_describe_outputs \ + configure /node_modules \ + build /web/build/$SUBPROJECT_NAME/lib/index.mjs + +builder_parse "$@" + +#### Build action definitions #### + +if builder_start_action configure; then + verify_npm_setup + + builder_finish_action success configure +fi + +if builder_start_action clean; then + rm -rf "$KEYMAN_ROOT/web/build/$SUBPROJECT_NAME" + builder_finish_action success clean +fi + +if builder_start_action build; then + compile $SUBPROJECT_NAME + + builder_finish_action success build +fi + +if builder_start_action test; then + # No actual tests yet. + builder_finish_action success test +fi \ No newline at end of file diff --git a/web/src/engine/device-detect/kmwdevice.ts b/web/src/engine/device-detect/kmwdevice.ts deleted file mode 100644 index 1d55068160..0000000000 --- a/web/src/engine/device-detect/kmwdevice.ts +++ /dev/null @@ -1,203 +0,0 @@ -// Includes version-related functionality -/// - -// The Device object definition ------------------------------------------------- - -namespace com.keyman { - export class Device { - touchable: boolean; - OS: string; - formFactor: string; - dyPortrait: number; - dyLandscape: number; - version: string; - orientation: string|number; - browser: string; - colorScheme: 'light' | 'dark'; - - private detected: boolean = false; - private _styles: utils.StyleConstants; - - // Generates a default Device value. - constructor() { - this.touchable = !!('ontouchstart' in window); - this.OS = ''; - this.formFactor='desktop'; - this.dyPortrait=0; - this.dyLandscape=0; - this.version='0'; - this.orientation=window.orientation; - this.browser=''; - } - - /** - * Get device horizontal DPI for touch devices, to set actual size of active regions - * Note that the actual physical DPI may be somewhat different. - * - * @return {number} - */ - getDPI(): number { - var t=document.createElement('DIV') ,s=t.style,dpi=96; - if(document.readyState !== 'complete') { - return dpi; - } - - t.id='calculateDPI'; - s.position='absolute'; s.display='block';s.visibility='hidden'; - s.left='10px'; s.top='10px'; s.width='1in'; s.height='10px'; - document.body.appendChild(t); - dpi=(typeof window.devicePixelRatio == 'undefined') ? t.offsetWidth : t.offsetWidth * window.devicePixelRatio; - document.body.removeChild(t); - return dpi; - } - - detect() : void { - var possMacSpoof = false; - - if(navigator && navigator.userAgent) { - var agent=navigator.userAgent; - - if(agent.indexOf('iPad') >= 0) { - this.OS='iOS'; - this.formFactor='tablet'; - this.dyPortrait=this.dyLandscape=0; - } else if(agent.indexOf('iPhone') >= 0) { - this.OS='iOS'; - this.formFactor='phone'; - this.dyPortrait=this.dyLandscape=25; - } else if(agent.indexOf('Android') >= 0) { - this.OS='Android'; - this.formFactor='phone'; // form factor may be redefined on initialization - this.dyPortrait=75; - this.dyLandscape=25; - try { - var rx=new RegExp("(?:Android\\s+)(\\d+\\.\\d+\\.\\d+)"); - this.version=agent.match(rx)[1]; - } catch(ex) {} - } else if(agent.indexOf('Linux') >= 0) { - this.OS='Linux'; - } else if(agent.indexOf('Macintosh') >= 0) { - // Starting with 13.1, "Macintosh" can reflect iPads (by default) or iPhones - // (by user setting); a new "Request Desktop Website" setting for Safari will - // change the user agent string to match a desktop Mac. - // - // Firefox uses '.' between version components, while Chrome and Safari use - // '_' instead. So, we have to check for both. Yay. - let regex = /Intel Mac OS X (\d+(?:[_\.]\d+)+)/i; - let results = regex.exec(agent); - - // Match result: a version string with components separated by underscores. - if(!results) { - console.warn("KMW could not properly parse the user agent string." - + "A suboptimal keyboard layout may result."); - this.OS='MacOSX'; - } else if(results.length > 1 && results[1]) { - // Convert version string into a usable form. - let versionString = results[1].replace('_', '.'); - let version = new utils.Version(versionString); - - possMacSpoof = utils.Version.MAC_POSSIBLE_IPAD_ALIAS.compareTo(version) <= 0; - this.OS='MacOSX'; - } - } else if(agent.indexOf('Windows NT') >= 0) { - this.OS='Windows'; - if(agent.indexOf('Touch') >= 0) { - this.formFactor='phone'; // will be redefined as tablet if resolution high enough - } - - // Windows Phone and Tablet PC - if(typeof (navigator).msMaxTouchPoints == 'number' && (navigator).msMaxTouchPoints > 0) { - this.touchable=true; - } - } - } - - // We look at the screen resolution for Android, because we can't tell from - // the user agent string whether or not this is supposed to be a tablet. - // It seems that there are a handful of older phones out there that report a - // higher resolution than 700px*___px, but it is proving hard to test these, - // and the majority have an aspect ratio <= 0.5625 anyway. - // But we trust what iOS tells us for phone vs tablet. - - const dimMin = Math.min(screen.width,screen.height), dimMax = Math.max(screen.width,screen.height); - const aspect = dimMin / dimMax; - - if(this.OS != 'iOS' && - this.formFactor == 'phone' && - ((dimMin >= 600 && aspect > 0.5625) || // 0.5625 -> 1920x1080 is common phone res - (aspect >= 0.625)) // all reported devices with aspect >= 0.625 are tablets per https://screensiz.es/ - ) { - this.formFactor='tablet'; - } - - // Test for potential Chrome emulation on Windows or macOS X (used only in next if-check) - let possibleChromeEmulation = navigator.platform == 'Win32' || navigator.platform == 'MacIntel' - - // alert(sxx+'->'+device.formFactor); - // Check for phony iOS devices (but don't undo for Chrome emulation used during development) - if(this.OS == 'iOS' && !('ongesturestart' in window) && !possibleChromeEmulation) { - this.OS='Android'; - } - - // Determine application or browser - this.browser='web'; - if(this.OS == 'iOS' || this.OS.toLowerCase() == 'macosx') { - this.browser='safari'; - } - - var bMatch=/Firefox|Chrome|OPR|Safari|Edge/; - if(bMatch.test(navigator.userAgent)) { - if((navigator.userAgent.indexOf('Firefox') >= 0) && ('onmozorientationchange' in screen)) { - this.browser='firefox'; - } else if(navigator.userAgent.indexOf('OPR') >= 0) { - this.browser='opera'; - } else if(navigator.userAgent.indexOf(' Edge/') >= 0) { - // Edge is too common a word, so test for Edge/ :) - // Must come before Chrome and Safari test because - // Edge pretends to be both - this.browser='edge'; - } else if(navigator.userAgent.indexOf('Chrome') >= 0) { - // This test must come before Safari test because on macOS, - // Chrome also reports "Safari" - this.browser='chrome'; - } else if(navigator.userAgent.indexOf('Safari') >= 0) { - this.browser='safari'; - } - } - - if(possMacSpoof && this.browser == 'safari') { - // Indistinguishable user agent string! We need a different test; fortunately, true macOS - // Safari doesn't support TouchEvents. (Chrome does, though! Hence the filter above.) - if(window['TouchEvent']) { - this.OS='iOS'; - this.formFactor='tablet'; - this.dyPortrait=this.dyLandscape=0; - - // It's currently impossible to differentiate between iPhone and iPad here - // except for by screen dimensions. - let aspectRatio = screen.height / screen.width; - if(aspectRatio < 1) { - aspectRatio = 1 / aspectRatio; - } - - // iPhones usually have a ratio of 16:9 (or 1.778) or higher, while iPads use 4:3 (or 1.333) - if(aspectRatio > 1.6) { - // Override - we'll treat this device as an iPhone. - this.formFactor = 'phone'; - this.dyPortrait=this.dyLandscape=25; - } - } - } - - this.colorScheme = utils.StyleConstants.prefersDarkMode() ? 'dark' : 'light'; - this.detected = true; - } - - /** - * Returns a slimmer, web-core compatible version of this object. - */ - public get coreSpec(): utils.DeviceSpec { - return new utils.DeviceSpec(this.browser, this.formFactor, this.OS, this.touchable); - } - } -} \ No newline at end of file diff --git a/web/src/engine/device-detect/src/index.ts b/web/src/engine/device-detect/src/index.ts new file mode 100644 index 0000000000..7ea611f7be --- /dev/null +++ b/web/src/engine/device-detect/src/index.ts @@ -0,0 +1,210 @@ +import StyleConstants from './utils/styleConstants.js'; +import { DeviceSpec, Version } from "@keymanapp/web-utils"; + +// The Device object definition ------------------------------------------------- + +export class Device { + // These correspond directly to the properties & parameters for `DeviceSpec`. + touchable: boolean; + OS: string; + formFactor: string; + browser: string; + + // These components aren't needed for key events. All but `version` could be a sort + // of `DeviceStyle`. + dyPortrait: number; // Its value is only referenced by an unused method. + dyLandscape: number; // Its value is only referenced by an unused method. + orientation: string|number; // Appears to be unused as well? + colorScheme: 'light' | 'dark'; // Also unused? + version: string; // As in, device version; only really persisted for Android. + // No real sign of actual use, though. + + private detected: boolean = false; + + // Generates a default Device value. + constructor() { + this.touchable = !!('ontouchstart' in window); + this.OS = ''; + this.formFactor='desktop'; + this.browser=''; + + this.dyPortrait=0; + this.dyLandscape=0; + this.version='0'; + this.orientation=window.orientation; + } + + /** + * Get device horizontal DPI for touch devices, to set actual size of active regions + * Note that the actual physical DPI may be somewhat different. + * + * @return {number} + */ + getDPI(): number { + var t=document.createElement('DIV') ,s=t.style,dpi=96; + if(document.readyState !== 'complete') { + return dpi; + } + + t.id='calculateDPI'; + s.position='absolute'; s.display='block';s.visibility='hidden'; + s.left='10px'; s.top='10px'; s.width='1in'; s.height='10px'; + document.body.appendChild(t); + dpi=(typeof window.devicePixelRatio == 'undefined') ? t.offsetWidth : t.offsetWidth * window.devicePixelRatio; + document.body.removeChild(t); + return dpi; + } + + detect() : DeviceSpec { + var possMacSpoof = false; + + if(navigator && navigator.userAgent) { + var agent=navigator.userAgent; + + if(agent.indexOf('iPad') >= 0) { + this.OS='iOS'; + this.formFactor='tablet'; + this.dyPortrait=this.dyLandscape=0; + } else if(agent.indexOf('iPhone') >= 0) { + this.OS='iOS'; + this.formFactor='phone'; + this.dyPortrait=this.dyLandscape=25; + } else if(agent.indexOf('Android') >= 0) { + this.OS='Android'; + this.formFactor='phone'; // form factor may be redefined on initialization + this.dyPortrait=75; + this.dyLandscape=25; + try { + var rx=new RegExp("(?:Android\\s+)(\\d+\\.\\d+\\.\\d+)"); + this.version=agent.match(rx)[1]; + } catch(ex) {} + } else if(agent.indexOf('Linux') >= 0) { + this.OS='Linux'; + } else if(agent.indexOf('Macintosh') >= 0) { + // Starting with 13.1, "Macintosh" can reflect iPads (by default) or iPhones + // (by user setting); a new "Request Desktop Website" setting for Safari will + // change the user agent string to match a desktop Mac. + // + // Firefox uses '.' between version components, while Chrome and Safari use + // '_' instead. So, we have to check for both. Yay. + let regex = /Intel Mac OS X (\d+(?:[_\.]\d+)+)/i; + let results = regex.exec(agent); + + // Match result: a version string with components separated by underscores. + if(!results) { + console.warn("KMW could not properly parse the user agent string." + + "A suboptimal keyboard layout may result."); + this.OS='MacOSX'; + } else if(results.length > 1 && results[1]) { + // Convert version string into a usable form. + let versionString = results[1].replace('_', '.'); + let version = new Version(versionString); + + possMacSpoof = Version.MAC_POSSIBLE_IPAD_ALIAS.compareTo(version) <= 0; + this.OS='MacOSX'; + } + } else if(agent.indexOf('Windows NT') >= 0) { + this.OS='Windows'; + if(agent.indexOf('Touch') >= 0) { + this.formFactor='phone'; // will be redefined as tablet if resolution high enough + } + + // Windows Phone and Tablet PC + if(typeof (navigator).msMaxTouchPoints == 'number' && (navigator).msMaxTouchPoints > 0) { + this.touchable=true; + } + } + } + + // We look at the screen resolution for Android, because we can't tell from + // the user agent string whether or not this is supposed to be a tablet. + // It seems that there are a handful of older phones out there that report a + // higher resolution than 700px*___px, but it is proving hard to test these, + // and the majority have an aspect ratio <= 0.5625 anyway. + // But we trust what iOS tells us for phone vs tablet. + + const dimMin = Math.min(screen.width,screen.height), dimMax = Math.max(screen.width,screen.height); + const aspect = dimMin / dimMax; + + if(this.OS != 'iOS' && + this.formFactor == 'phone' && + ((dimMin >= 600 && aspect > 0.5625) || // 0.5625 -> 1920x1080 is common phone res + (aspect >= 0.625)) // all reported devices with aspect >= 0.625 are tablets per https://screensiz.es/ + ) { + this.formFactor='tablet'; + } + + // Test for potential Chrome emulation on Windows or macOS X (used only in next if-check) + let possibleChromeEmulation = navigator.platform == 'Win32' || navigator.platform == 'MacIntel' + + // alert(sxx+'->'+device.formFactor); + // Check for phony iOS devices (but don't undo for Chrome emulation used during development) + if(this.OS == 'iOS' && !('ongesturestart' in window) && !possibleChromeEmulation) { + this.OS='Android'; + } + + // Determine application or browser + this.browser='web'; + if(this.OS == 'iOS' || this.OS.toLowerCase() == 'macosx') { + this.browser='safari'; + } + + var bMatch=/Firefox|Chrome|OPR|Safari|Edge/; + if(bMatch.test(navigator.userAgent)) { + if((navigator.userAgent.indexOf('Firefox') >= 0) && ('onmozorientationchange' in screen)) { + this.browser='firefox'; + } else if(navigator.userAgent.indexOf('OPR') >= 0) { + this.browser='opera'; + } else if(navigator.userAgent.indexOf(' Edge/') >= 0) { + // Edge is too common a word, so test for Edge/ :) + // Must come before Chrome and Safari test because + // Edge pretends to be both + this.browser='edge'; + } else if(navigator.userAgent.indexOf('Chrome') >= 0) { + // This test must come before Safari test because on macOS, + // Chrome also reports "Safari" + this.browser='chrome'; + } else if(navigator.userAgent.indexOf('Safari') >= 0) { + this.browser='safari'; + } + } + + if(possMacSpoof && this.browser == 'safari') { + // Indistinguishable user agent string! We need a different test; fortunately, true macOS + // Safari doesn't support TouchEvents. (Chrome does, though! Hence the filter above.) + if(window['TouchEvent']) { + this.OS='iOS'; + this.formFactor='tablet'; + this.dyPortrait=this.dyLandscape=0; + + // It's currently impossible to differentiate between iPhone and iPad here + // except for by screen dimensions. + let aspectRatio = screen.height / screen.width; + if(aspectRatio < 1) { + aspectRatio = 1 / aspectRatio; + } + + // iPhones usually have a ratio of 16:9 (or 1.778) or higher, while iPads use 4:3 (or 1.333) + if(aspectRatio > 1.6) { + // Override - we'll treat this device as an iPhone. + this.formFactor = 'phone'; + this.dyPortrait=this.dyLandscape=25; + } + } + } + + this.colorScheme = StyleConstants.prefersDarkMode() ? 'dark' : 'light'; + this.detected = true; + + return this.coreSpec; + } + + /** + * Returns a slimmer, web-core compatible version of this object. + */ + public get coreSpec(): DeviceSpec { + return new DeviceSpec(this.browser, this.formFactor, this.OS, this.touchable); + } +} + +export default Device; \ No newline at end of file diff --git a/web/src/engine/device-detect/src/utils/styleConstants.ts b/web/src/engine/device-detect/src/utils/styleConstants.ts new file mode 100644 index 0000000000..ce64c16959 --- /dev/null +++ b/web/src/engine/device-detect/src/utils/styleConstants.ts @@ -0,0 +1,31 @@ +import { DeviceSpec } from "@keymanapp/web-utils"; + +/* + * This file is intended for CSS-styling constants that see use with the OSK. + */ + +/** + * Defines device-level constants used for CSS styling. + */ +export default class StyleConstants { + constructor(device: DeviceSpec) { + // popupCanvasBackgroundColor + if(device.OS == DeviceSpec.OperatingSystem.Android) { + this.popupCanvasBackgroundColor = '#999'; + } else { + this.popupCanvasBackgroundColor = StyleConstants.prefersDarkMode() ? '#0f1319' : '#ffffff'; + } + } + + /** + * Checks is a user's browser is in dark mode, if the feature is supported. Returns false otherwise. + * + * Thanks to https://stackoverflow.com/a/57795518 for this code. + */ + static prefersDarkMode(): boolean { + // Ensure the detector exists (otherwise, returns false) + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + } + + public readonly popupCanvasBackgroundColor: string; +} \ No newline at end of file diff --git a/web/src/engine/device-detect/tsconfig.bundled.json b/web/src/engine/device-detect/tsconfig.bundled.json deleted file mode 100644 index 61ac455342..0000000000 --- a/web/src/engine/device-detect/tsconfig.bundled.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "./tsconfig.json", - - "compilerOptions": { - "outFile": "../../../build/engine/device-detect/obj/index.bundled.js", - }, - - "references": [ - { "path": "../../../../common/web/keyman-version", "prepend": true }, - { "path": "../../../../common/web/utils", "prepend": true } - ] -} diff --git a/web/src/engine/device-detect/tsconfig.json b/web/src/engine/device-detect/tsconfig.json index 471f6f9b90..7c4ab9a952 100644 --- a/web/src/engine/device-detect/tsconfig.json +++ b/web/src/engine/device-detect/tsconfig.json @@ -2,18 +2,21 @@ "extends": "../../../../tsconfig-base.json", "compilerOptions": { - "allowJs": true, + "allowJs": false, + "declaration": true, "inlineSources": true, - "module": "none", - "outFile": "../../../build/engine/device-detect/obj/index.js", + "allowSyntheticDefaultImports": true, + "module": "es6", + "moduleResolution": "Node16", "sourceMap": true, - "target": "es5" + "target": "es5", + "baseUrl": "./", + "outDir": "../../../build/engine/device-detect/obj/", + "tsBuildInfoFile": "../../../build/engine/device-detect/obj/tsconfig.tsbuildinfo", + "rootDir": "./src" }, - "files": [ - "kmwdevice.ts", - "utils/styleConstants.ts", // goes with kmwdevice.ts - ], + "include": [ "**/*.ts" ], "references": [ { "path": "../../../../common/web/keyman-version" }, diff --git a/web/src/engine/device-detect/utils/styleConstants.ts b/web/src/engine/device-detect/utils/styleConstants.ts deleted file mode 100644 index 0ea2e3be22..0000000000 --- a/web/src/engine/device-detect/utils/styleConstants.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Includes Device definitions, which may play a role in constant logic. -/// - -/* - * This file is intended for CSS-styling constants that see use with the OSK. - */ - -namespace com.keyman.utils { - /** - * Defines device-level constants used for CSS styling. - */ - export class StyleConstants { - constructor(device: com.keyman.utils.DeviceSpec) { - // popupCanvasBackgroundColor - if(device.OS == utils.OperatingSystem.Android) { - this.popupCanvasBackgroundColor = '#999'; - } else { - this.popupCanvasBackgroundColor = StyleConstants.prefersDarkMode() ? '#0f1319' : '#ffffff'; - } - } - - /** - * Checks is a user's browser is in dark mode, if the feature is supported. Returns false otherwise. - * - * Thanks to https://stackoverflow.com/a/57795518 for this code. - */ - static prefersDarkMode(): boolean { - // Ensure the detector exists (otherwise, returns false) - return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - } - - public readonly popupCanvasBackgroundColor: string; - } -} \ No newline at end of file diff --git a/web/src/engine/dom-utils/build.sh b/web/src/engine/dom-utils/build.sh new file mode 100755 index 0000000000..b107b9cc75 --- /dev/null +++ b/web/src/engine/dom-utils/build.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# + +# set -x +set -eu + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" +. "${THIS_SCRIPT%/*}/../../../../resources/build/build-utils.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" + +# This script runs from its own folder +cd "$THIS_SCRIPT_PATH" + +# Imports common Web build-script definitions & functions +SUBPROJECT_NAME=engine/dom-utils +. "$KEYMAN_ROOT/web/common.inc.sh" + +# ################################ Main script ################################ + +builder_describe "Builds DOM-utility modules used by the Keyman Engine for Web (KMW)." \ + "@/common/web/utils" \ + "@/common/web/keyboard-processor" \ + "clean" \ + "configure" \ + "build" \ + "test" + +# Possible TODO? +# "upload-symbols Uploads build product to Sentry for error report symbolification. Only defined for $DOC_BUILD_EMBED_WEB" \ + +builder_describe_outputs \ + configure /node_modules \ + build /web/build/$SUBPROJECT_NAME/obj/index.js + +builder_parse "$@" + +#### Build action definitions #### + +if builder_start_action configure; then + verify_npm_setup + + builder_finish_action success configure +fi + +if builder_start_action clean; then + rm -rf "$KEYMAN_ROOT/web/build/$SUBPROJECT_NAME" + builder_finish_action success clean +fi + +if builder_start_action build; then + compile $SUBPROJECT_NAME + + builder_finish_action success build +fi + +if builder_start_action test; then + # No HEADLESS tests yet. + + # TODO: DOM tests + builder_finish_action success test +fi \ No newline at end of file diff --git a/web/src/engine/dom-utils/readme.md b/web/src/engine/dom-utils/readme.md new file mode 100644 index 0000000000..663f711276 --- /dev/null +++ b/web/src/engine/dom-utils/readme.md @@ -0,0 +1,12 @@ +## engine/element-wrappers + +This submodule provides a subset of the main engine's Web-oriented code that's used to facilitate DOM-based +layout interactions; it is used in the following scenarios: + +1. It is a published as part of our public API on Keyman Engine for Web. +2. It is used for various layout operations within the OSK. +3. It is used by our various desktop UI modules that interface with Keyman Engine for Web. +4. It is used by the element-attachment manager to help determine tab order among attached elements. + +Since it is used by multiple different top-level modules, it is best to isolate these functions as their +own standalone module in order to avoid unwanted entanglement among its consumers. \ No newline at end of file diff --git a/web/src/engine/dom-utils/src/cookieSerializer.ts b/web/src/engine/dom-utils/src/cookieSerializer.ts new file mode 100644 index 0000000000..a7a0263027 --- /dev/null +++ b/web/src/engine/dom-utils/src/cookieSerializer.ts @@ -0,0 +1,91 @@ +type DecodedCookieFieldValue = string | number | boolean; + +type FilteredRecordEncoder = (value: DecodedCookieFieldValue, key: string) => string; +type FilteredRecordDecoder = (value: string, key: string) => DecodedCookieFieldValue; +const no_change = (val: string) => val as string; + +export default class CookieSerializer> { + readonly name: string; + + constructor(name: string) { + this.name = name; + } + + load(decoder?: FilteredRecordDecoder): Type { + return this.loadCookie(this.name, decoder || no_change) as Type; + } + + save(cookie: Type, encoder?: FilteredRecordEncoder) { + this.saveCookie(this.name, cookie, encoder || no_change); + } + + /** + * Document cookie parsing for use by kernel, OSK, UI etc. + * + * @return {Object} array of names and strings + */ + private _loadRawCookies(): Record { + let v: Record = {}; + if(typeof(document.cookie) != 'undefined' && document.cookie != '') { + let c = document.cookie.split(/;\s*/); + for(let i = 0; i < c.length; i++) { + let d = c[i].split('='); + if(d.length == 2) { + v[d[0]] = d[1]; + } + } + } + + return v; + } + + /** + * Document cookie parsing for use by kernel, OSK, UI etc. + * + * @param {string} cookieName cookie name + * @return {Object} array of variables and values + */ + private loadCookie(cookieName: string, decoder: FilteredRecordDecoder): Record { + let cookie: Record = {}; + let allCookies = this._loadRawCookies(); + const encodedCookie = allCookies[cookieName]; + + if(encodedCookie) { + let rawDecode = decodeURIComponent(encodedCookie).split(';'); + for(let i=0; i 1) { + const [key, value] = record; + // key, value + cookie[key] = decoder(value, key); + } else { + // key, + cookie[record[0]] = ''; + } + } + } + return cookie; + } + + /** + * Standard cookie saving for use by kernel, OSK, UI etc. + * + * @param {string} cookieName name of cookie + * @param {Object} cookieValueMap object with array of named arguments and values + */ + private saveCookie(cookieName: string, cookieValueMap: Record, encoder: FilteredRecordEncoder) { + let serialization=''; + for(let key in cookieValueMap) { + serialization += key + '=' + encoder(cookieValueMap[key], key) + ";"; + } + + let d = new Date(new Date().valueOf() + 1000 * 60 * 60 * 24 * 30).toUTCString(); + let cookieConfig = ' path=/; expires=' + d; //Fri, 31 Dec 2099 23:59:59 GMT;'; + document.cookie = `${cookieName}=${encodeURIComponent(serialization)}; ${cookieConfig}`; + } +} \ No newline at end of file diff --git a/web/src/engine/dom-utils/src/createUnselectableElement.ts b/web/src/engine/dom-utils/src/createUnselectableElement.ts new file mode 100644 index 0000000000..10cc049ee9 --- /dev/null +++ b/web/src/engine/dom-utils/src/createUnselectableElement.ts @@ -0,0 +1,9 @@ +// Found a bit of magic formatting that allows dynamic return typing for a specified element tag! +export default function createUnselectableElement(nodeName:E) { + const e = document.createElement(nodeName); + + e.style.userSelect="none"; + e.style.webkitUserSelect="none"; + e.style['MozUserSelect'] = "none"; + return e; +} \ No newline at end of file diff --git a/web/src/engine/dom-utils/src/getAbsolute.ts b/web/src/engine/dom-utils/src/getAbsolute.ts new file mode 100644 index 0000000000..27a9b38eab --- /dev/null +++ b/web/src/engine/dom-utils/src/getAbsolute.ts @@ -0,0 +1,81 @@ +/** + * Function getAbsoluteX + * Scope Public + * @param {Object} Pobj HTML element + * @return {number} + * Description Returns x-coordinate of Pobj element absolute position with respect to page + */ +export function getAbsoluteX(Pobj: HTMLElement): number { // I1476 - Handle SELECT overlapping END + var Lobj: HTMLElement + + if(!Pobj) { + return 0; + } + + var Lcurleft = Pobj.offsetLeft ? Pobj.offsetLeft : 0; + Lobj = Pobj; // I2404 - Support for IFRAMEs + + if (Lobj.offsetParent) { + while (Lobj.offsetParent) { + Lobj = Lobj.offsetParent as HTMLElement; + Lcurleft += Lobj.offsetLeft; + } + + // On mobile devices, the OSK uses 'fixed' - this requires some extra offset work to handle. + let Ldoc = Lobj.ownerDocument; + if(Lobj.style.position == 'fixed' && Ldoc && Ldoc.scrollingElement) { + Lcurleft += Ldoc.scrollingElement.scrollLeft; + } + } + // Correct position if element is within a frame (but not if the controller is in document within that frame) + // We used to reference a KMW state variable `this.keyman._MasterDocument`, but it was only ever set to `window.document`. + if(Lobj && Lobj.ownerDocument && (Pobj.ownerDocument != window.document)) { + var Ldoc=Lobj.ownerDocument; // I2404 - Support for IFRAMEs + + if(Ldoc && Ldoc.defaultView && Ldoc.defaultView.frameElement) { + return Lcurleft + getAbsoluteX(Ldoc.defaultView.frameElement) - Ldoc.documentElement.scrollLeft; + } + } + return Lcurleft; +} + +/** + * Function getAbsoluteY + * Scope Public + * @param {Object} Pobj HTML element + * @return {number} + * Description Returns y-coordinate of Pobj element absolute position with respect to page + */ +export function getAbsoluteY(Pobj: HTMLElement): number { + var Lobj: HTMLElement + + if(!Pobj) { + return 0; + } + var Lcurtop = Pobj.offsetTop ? Pobj.offsetTop : 0; + Lobj = Pobj; // I2404 - Support for IFRAMEs + + if (Lobj.ownerDocument && Lobj instanceof Lobj.ownerDocument.defaultView.HTMLElement) { + while (Lobj.offsetParent) { + Lobj = Lobj.offsetParent as HTMLElement; + Lcurtop += Lobj.offsetTop; + } + + // On mobile devices, the OSK uses 'fixed' - this requires some extra offset work to handle. + let Ldoc = Lobj.ownerDocument; + if(Lobj.style.position == 'fixed' && Ldoc && Ldoc.scrollingElement) { + Lcurtop += Ldoc.scrollingElement.scrollTop; + } + } + + // Correct position if element is within a frame (but not if the controller is in document within that frame) + // We used to reference a KMW state variable `this.keyman._MasterDocument`, but it was only ever set to `window.document`. + if(Lobj && Lobj.ownerDocument && (Pobj.ownerDocument != window.document)) { + var Ldoc=Lobj.ownerDocument; // I2404 - Support for IFRAMEs + + if(Ldoc && Ldoc.defaultView && Ldoc.defaultView.frameElement) { + return Lcurtop + getAbsoluteY(Ldoc.defaultView.frameElement); + } + } + return Lcurtop; +} \ No newline at end of file diff --git a/web/src/engine/dom-utils/src/index.ts b/web/src/engine/dom-utils/src/index.ts new file mode 100644 index 0000000000..cdaa75f06e --- /dev/null +++ b/web/src/engine/dom-utils/src/index.ts @@ -0,0 +1,5 @@ +export { getAbsoluteX, getAbsoluteY } from './getAbsolute.js'; +export { default as createUnselectableElement } from './createUnselectableElement.js'; +export { createStyleSheet, StylesheetManager } from './stylesheets.js'; +export { default as landscapeView } from './landscapeView.js'; +export { default as CookieSerializer } from './cookieSerializer.js'; \ No newline at end of file diff --git a/web/src/engine/dom-utils/src/landscapeView.ts b/web/src/engine/dom-utils/src/landscapeView.ts new file mode 100644 index 0000000000..d12b347cbc --- /dev/null +++ b/web/src/engine/dom-utils/src/landscapeView.ts @@ -0,0 +1,22 @@ +/** + * Get orientation of tablet or phone display + * + * @return {boolean} + */ +export default function landscapeView(): boolean { // new for I3363 (Build 301) + var orientation: number; + + // Assume portrait mode if orientation undefined + if(typeof window.orientation != 'undefined') { // Used by iOS Safari + // Else landscape for +/-90, portrait for 0, +/-180 + orientation = window.orientation as number; + } else if(typeof window.screen.orientation != 'undefined') { // Used by Firefox, Chrome + orientation = window.screen.orientation.angle; + } + + if(orientation !== undefined) { + return (Math.abs(orientation/90) == 1); + } else { + return false; + } +} \ No newline at end of file diff --git a/web/src/engine/dom-utils/src/stylesheets.ts b/web/src/engine/dom-utils/src/stylesheets.ts new file mode 100644 index 0000000000..2dd2ab4b3c --- /dev/null +++ b/web/src/engine/dom-utils/src/stylesheets.ts @@ -0,0 +1,220 @@ +import { DeviceSpec } from '@keymanapp/web-utils'; +import { type InternalKeyboardFont as KeyboardFont } from '@keymanapp/keyboard-processor'; + +type FontFamilyStyleMap = {[family: string]: HTMLStyleElement}; + +export class StylesheetManager { + private fontStyleDefinitions: { [os: string]: FontFamilyStyleMap} = {}; + private linkedSheets: HTMLStyleElement[] = []; + private doCacheBusting: boolean; + + public readonly linkNode: Node; + + public constructor(linkNode?: Node, doCacheBusting?: boolean) { + if(!linkNode) { + let _ElemHead=document.getElementsByTagName('HEAD'); + if(_ElemHead.length > 0) { + linkNode = _ElemHead[0]; + } else { + linkNode = document.body; // Won't work on [old?] Chrome, ah well + } + } + this.linkNode = linkNode; + this.doCacheBusting = doCacheBusting || false; + } + + linkStylesheet(sheet: HTMLStyleElement) { + this.linkedSheets.push(sheet); + this.linkNode.appendChild(sheet); + } + + /** + * Build a stylesheet with a font-face CSS descriptor for the embedded font appropriate + * for the browser being used + * + * @param {Object} fd keymanweb font descriptor (internal format; should be preprocessed) + * @param {string} fontPathRoot Should correspond to `this.keyman.options['fonts']` + **/ + addStyleSheetForFont(fd: KeyboardFont, fontPathRoot: string, os?: DeviceSpec.OperatingSystem) { + // Test if a valid font descriptor + if(typeof(fd) == 'undefined') { + return; + } + + if(typeof(fd.files == 'undefined')) { + return; + } + + const fontKey = fd.family; + + let i, ttf='', woff='', eot='', svg='', fList=[]; + + // TODO: 22 Aug 2014: check that font path passed from cloud is actually used! + + if(!os) { + os = DeviceSpec.OperatingSystem.Other; // as a fallback option. + } + + // Do not add a new font-face style sheet if already added for this font + const fontStyleMap = this.fontStyleDefinitions[os] = this.fontStyleDefinitions[os] || {}; + + if(fontStyleMap[fontKey]) { + const sheet = fontStyleMap[fontKey]; + + if(!sheet.parentNode) { + this.linkStylesheet(sheet); + } + return; + } + + if(typeof(fd.files) == 'string') { + fList[0]=fd.files; + } else { + fList=fd.files; + } + + for(i=0;i 0) ttf=fList[i]; + if(fList[i].toLowerCase().indexOf('.ttf') > 0) ttf=fList[i]; + if(fList[i].toLowerCase().indexOf('.woff') > 0) woff=fList[i]; + if(fList[i].toLowerCase().indexOf('.eot') > 0) eot=fList[i]; + if(fList[i].toLowerCase().indexOf('.svg') > 0) svg=fList[i]; + } + + // Font path qualified to support page-relative fonts (build 347) + if(ttf != '' && (ttf.indexOf('/') < 0)) { + ttf = fontPathRoot+ttf; + } + + if(woff != '' && (woff.indexOf('/') < 0)) { + woff = fontPathRoot+woff; + } + + if(eot != '' && (eot.indexOf('/') < 0)) { + eot = fontPathRoot+eot; + } + + if(svg != '' && (svg.indexOf('/') < 0)) { + svg = fontPathRoot+svg; + } + + // Build the font-face definition according to the browser being used + var s='@font-face {\nfont-family:' + + fd.family + ';\nfont-style:normal;\nfont-weight:normal;\n'; + + // Build the font source string according to the browser, + // but return without adding the style sheet if the required font type is unavailable + + // Modern browsers: use WOFF, TTF and fallback finally to SVG. Don't provide EOT + if(os == DeviceSpec.OperatingSystem.iOS) { + if(ttf != '') { + if(this.doCacheBusting) { + ttf = this.cacheBust(ttf); + } + s=s+'src:url(\''+ttf+'\') format(\'truetype\');'; + } else { + return null; + } + } else { + var s0 = []; + + if(os == DeviceSpec.OperatingSystem.Android) { + // Android 4.2 and 4.3 have bugs in their rendering for some scripts + // with embedded ttf or woff. svg mostly works so is a better initial + // choice on the Android browser. + if(svg != '') { + s0.push("url('"+svg+"') format('svg')"); + } + + if(woff != '') { + s0.push("url('"+woff+"') format('woff')"); + } + + if(ttf != '') { + s0.push("url('"+ttf+"') format('truetype')"); + } + } else { + if(woff != '') { + s0.push("url('"+woff+"') format('woff')"); + } + + if(ttf != '') { + s0.push("url('"+ttf+"') format('truetype')"); + } + + if(svg != '') { + s0.push("url('"+svg+"') format('svg')"); + } + } + + if(s0.length == 0) { + return null; + } + + s += 'src:'+s0.join(',')+';'; + } + + s=s+'\n}\n'; + + const sheet = createStyleSheet(s); + fontStyleMap[fontKey] = sheet; + + this.linkStylesheet(sheet); + + return sheet; + } + + private cacheBust(uri: string) { + // Our WebView version directly sets the keyboard path, and it may replace the file + // after KMW has loaded. We need cache-busting to prevent the new version from + // being ignored. + return uri + "?v=" + (new Date()).getTime(); /*cache buster*/ + } + + /** + * Add a reference to an external stylesheet file + * + * @param {string} href path to stylesheet file + */ + linkExternalSheet(href: string): void { + try { + if(document.querySelector("link[href="+JSON.stringify(href)+"]") != null) { + // We've already linked this stylesheet, don't do it again + return; + } + } catch(e) { + // We've built an invalid href, somehow? + return; + } + + const linkElement=document.createElement('link'); + linkElement.type='text/css'; + linkElement.rel='stylesheet'; + linkElement.href=href; + + this.linkStylesheet(linkElement); + } + + public unlinkAll() { + for(let sheet of this.linkedSheets) { + if(sheet.parentNode) { + sheet.parentNode.removeChild(sheet); + } + } + } +} + +/** + * 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 + **/ +export function createStyleSheet(styleString: string): HTMLStyleElement { + var _ElemStyle: HTMLStyleElement = document.createElement<'style'>('style'); + + _ElemStyle.type = 'text/css'; + _ElemStyle.appendChild(document.createTextNode(styleString)); + + return _ElemStyle; +} \ No newline at end of file diff --git a/web/src/engine/dom-utils/tsconfig.json b/web/src/engine/dom-utils/tsconfig.json new file mode 100644 index 0000000000..52fe9ef829 --- /dev/null +++ b/web/src/engine/dom-utils/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig-base.json", + + "compilerOptions": { + "allowJs": true, + "inlineSources": true, + "allowSyntheticDefaultImports": true, + "module": "es6", + "moduleResolution": "Node16", + "sourceMap": true, + "target": "es5", + "baseUrl": "./", + "outDir": "../../../build/engine/dom-utils/obj/", + "tsBuildInfoFile": "../../../build/engine/dom-utils/obj/tsconfig.tsbuildinfo", + "rootDir": "./src" + }, + + "include": [ "src/**/*.ts" ], +} diff --git a/web/src/engine/element-wrappers/build-bundler.js b/web/src/engine/element-wrappers/build-bundler.js new file mode 100644 index 0000000000..899fe228b2 --- /dev/null +++ b/web/src/engine/element-wrappers/build-bundler.js @@ -0,0 +1,24 @@ +/* + * Note: while this file is not meant to exist long-term, it provides a nice + * low-level proof-of-concept for esbuild bundling of the various Web submodules. + * + * Add some extra code at the end of src/index.ts and run it to verify successful bundling! + */ + +import esbuild from 'esbuild'; +import { spawn } from 'child_process'; + +await esbuild.build({ + bundle: true, + sourcemap: true, + format: "esm", + nodePaths: ['../../../../node_modules'], + entryPoints: { + 'index': '../../../build/engine/element-wrappers/obj/index.js', + }, + external: ['fs', 'vm'], + outdir: '../../../build/engine/element-wrappers/lib/', + outExtension: { '.js': '.mjs' }, + tsconfig: './tsconfig.json', + target: "es5" +}); diff --git a/web/src/engine/element-wrappers/build.sh b/web/src/engine/element-wrappers/build.sh new file mode 100755 index 0000000000..512361773d --- /dev/null +++ b/web/src/engine/element-wrappers/build.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# + +# set -x +set -eu + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" +. "${THIS_SCRIPT%/*}/../../../../resources/build/build-utils.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" + +# This script runs from its own folder +cd "$THIS_SCRIPT_PATH" + +# Imports common Web build-script definitions & functions +SUBPROJECT_NAME=engine/element-wrappers +. "$KEYMAN_ROOT/web/common.inc.sh" + +# ################################ Main script ################################ + +builder_describe "Builds DOM-based OutputTarget subclasses used by the Keyman Engine for Web (KMW)." \ + "@/common/web/keyboard-processor" \ + "clean" \ + "configure" \ + "build" \ + "test" + +# Possible TODO? +# "upload-symbols Uploads build product to Sentry for error report symbolification. Only defined for $DOC_BUILD_EMBED_WEB" \ + +builder_describe_outputs \ + configure /node_modules \ + build /web/build/$SUBPROJECT_NAME/lib/index.mjs + +builder_parse "$@" + +#### Build action definitions #### + +if builder_start_action configure; then + verify_npm_setup + + builder_finish_action success configure +fi + +if builder_start_action clean; then + rm -rf "$KEYMAN_ROOT/web/build/$SUBPROJECT_NAME" + builder_finish_action success clean +fi + +if builder_start_action build; then + compile $SUBPROJECT_NAME + + builder_finish_action success build +fi + +if builder_start_action test; then + # No HEADLESS tests yet. + + # TODO: DOM tests + builder_finish_action success test +fi \ No newline at end of file diff --git a/web/src/engine/element-wrappers/readme.md b/web/src/engine/element-wrappers/readme.md index dcae1c191b..1237237c7b 100644 --- a/web/src/engine/element-wrappers/readme.md +++ b/web/src/engine/element-wrappers/readme.md @@ -1,8 +1,4 @@ ## engine/element-wrappers This submodule provides a subset of the main engine's Web-oriented code that's used to 'wrap' webpage -elements as part of KMW attachment and interface the element with the `keyboard-processor` submodule. - -At this time, said code is included as part of KMW's main source. It is provided here as a -separate module for use in unit tests, though it is 100% engine code. It is not currently practical -to 100% modularize it in KMW's current state due to cross-references. \ No newline at end of file +elements as part of KMW attachment and interface the element with the `keyboard-processor` submodule. \ No newline at end of file diff --git a/web/src/engine/element-wrappers/src/contentEditable.ts b/web/src/engine/element-wrappers/src/contentEditable.ts new file mode 100644 index 0000000000..c5f17c9be5 --- /dev/null +++ b/web/src/engine/element-wrappers/src/contentEditable.ts @@ -0,0 +1,273 @@ +import OutputTarget from './outputTarget.js'; + +class SelectionCaret { + node: Node; + offset: number; + + constructor(node, offset) { + this.node = node; + this.offset = offset; + } +} + +class SelectionRange { + start: SelectionCaret; + end: SelectionCaret; + + constructor(start, end) { + this.start = start; + this.end = end; + } +} + +export default class ContentEditable extends OutputTarget<{}> { + root: HTMLElement; + + constructor(ele: HTMLElement) { + if(ele.isContentEditable) { + super(); + this.root = ele; + } else { + throw "Specified element is not already content-editable!"; + } + } + + get isSynthetic(): boolean { + return false; + } + + getElement(): HTMLElement { + return this.root; + } + + isSelectionEmpty(): boolean { + if(!this.hasSelection()) { + return true; + } + + return this.root.ownerDocument.getSelection().isCollapsed; + } + + hasSelection(): boolean { + let Lsel = this.root.ownerDocument.getSelection(); + + if(this.root != Lsel.anchorNode && !this.root.contains(Lsel.anchorNode)) { + return false; + } + + if(this.root != Lsel.focusNode && !this.root.contains(Lsel.focusNode)) { + return false; + } + + return true; + } + + clearSelection(): void { + if(this.hasSelection()) { + let Lsel = this.root.ownerDocument.getSelection(); + + if(!Lsel.isCollapsed) { + Lsel.deleteFromDocument(); // I2134, I2192 + } + } else { + console.warn("Attempted to clear an unowned Selection!"); + } + } + + invalidateSelection(): void { /* No cache maintenance needed here, partly because + * it's impossible to cache a Selection; it mutates. + */ } + + getCarets(): SelectionRange { + let Lsel = this.root.ownerDocument.getSelection(); + let code = Lsel.anchorNode.compareDocumentPosition(Lsel.focusNode); + + if(Lsel.isCollapsed) { + let caret = new SelectionCaret(Lsel.anchorNode, Lsel.anchorOffset); + return new SelectionRange(caret, caret); + } else { + let anchor = new SelectionCaret(Lsel.anchorNode, Lsel.anchorOffset); + let focus = new SelectionCaret(Lsel.focusNode, Lsel.focusOffset); + + if(anchor.node == focus.node) { + code = (focus.offset - anchor.offset > 0) ? 2 : 4; + } + + if(code & 2) { + return new SelectionRange(anchor, focus); + } else { // Default + // can test against code & 4 to ensure Focus is before anchor, though. + return new SelectionRange(focus, anchor); + } + } + } + + getDeadkeyCaret(): number { + return this.getTextBeforeCaret().kmwLength(); + } + + getTextBeforeCaret(): string { + if(!this.hasSelection()) { + return; + } + + let caret = this.getCarets().start; + + if(caret.node.nodeType != 3) { + return ''; // Must be a text node to provide a context. + } + + return caret.node.textContent.substr(0, caret.offset); + } + + getSelectedText(): string { + // TODO: figure out the proper implementation. + // KMW 16 and before behavior may be maintained by just returning the empty string. + return ''; + } + + getTextAfterCaret(): string { + if(!this.hasSelection()) { + return ''; + } + + let caret = this.getCarets().end; + + if(caret.node.nodeType != 3) { + return ''; // Must be a text node to provide a context. + } + + return caret.node.textContent.substr(caret.offset); + } + + getText(): string { + return this.root.innerText; + } + + deleteCharsBeforeCaret(dn: number) { + if(!this.hasSelection() || dn <= 0) { + return; + } + + let start = this.getCarets().start; + + // Bounds-check on the number of chars to delete. + if(dn > start.offset) { + dn = start.offset; + } + + if(start.node.nodeType != 3) { + console.warn("Deletion of characters requested without available context!"); + return; // No context to delete characters from. + } + + let range = this.root.ownerDocument.createRange(); + let dnOffset = start.offset - start.node.nodeValue.substr(0, start.offset)._kmwSubstr(-dn).length; + + range.setStart(start.node, dnOffset); + range.setEnd(start.node, start.offset); + + this.adjustDeadkeys(-dn); + range.deleteContents(); + // No need to reposition the caret - the DOM will auto-move the selection accordingly, since + // we didn't use the selection to delete anything. + } + + insertTextBeforeCaret(s: string) { + if(!this.hasSelection()) { + return; + } + + let start = this.getCarets().start; + let delta = s._kmwLength(); + let Lsel = this.root.ownerDocument.getSelection(); + + if(delta == 0) { + return; + } + + this.adjustDeadkeys(delta); + + // While Selection.extend() was really nice for this, IE didn't support it whatsoever. + // However, IE (11, at least) DID support setting selections via ranges, so we were still + // able to manage the caret properly. + // + // TODO: double-check that it was only IE-motivated, re-implement with Selection.extend(). + let finalCaret = this.root.ownerDocument.createRange(); + + if(start.node.nodeType == 3) { + let textStart = start.node; + textStart.insertData(start.offset, s); + finalCaret.setStart(textStart, start.offset + s.length); + } else { + // Create a new text node - empty control + var n = start.node.ownerDocument.createTextNode(s); + + let range = this.root.ownerDocument.createRange(); + range.setStart(start.node, start.offset); + range.collapse(true); + range.insertNode(n); + finalCaret.setStart(n, s.length); + } + + finalCaret.collapse(true); + Lsel.removeAllRanges(); + try { + Lsel.addRange(finalCaret); + } catch(e) { + // Chrome (through 4.0 at least) throws an exception because it has not synchronised its content with the selection. + // scrollIntoView synchronises the content for selection + start.node.parentElement.scrollIntoView(); + Lsel.addRange(finalCaret); + } + Lsel.collapseToEnd(); + } + + handleNewlineAtCaret(): void { + // TODO: Implement. + // + // As it turns out, we never had an implementation for handling newline inputs from the OSK for this element type. + // At least this way, it's more explicit. + // + // Note: consult "// Create a new text node - empty control" case in insertTextBeforeCaret - + // this helps to handle the browser-default implementation of newline handling. In particular, + // entry of the first character after a newline. + // + // If raw newlines are entered into the HTML, but as with usual HTML, they're interpreted as excess whitespace and + // have no effect. We need to add DOM elements for a functional newline. + } + + protected setTextAfterCaret(s: string) { + if(!this.hasSelection()) { + return; + } + + let caret = this.getCarets().end; + let delta = s._kmwLength(); + let Lsel = this.root.ownerDocument.getSelection(); + + if(delta == 0) { + return; + } + + // This is designed explicitly for use in direct-setting operations; deadkeys + // will be handled after this method. + + if(caret.node.nodeType == 3) { + let textStart = caret.node; + textStart.replaceData(caret.offset, textStart.length, s); + } else { + // Create a new text node - empty control + var n = caret.node.ownerDocument.createTextNode(s); + + let range = this.root.ownerDocument.createRange(); + range.setStart(caret.node, caret.offset); + range.collapse(true); + range.insertNode(n); + } + } + + doInputEvent() { + this.dispatchInputEventOn(this.root); + } +} \ No newline at end of file diff --git a/web/src/engine/element-wrappers/src/designIFrame.ts b/web/src/engine/element-wrappers/src/designIFrame.ts new file mode 100644 index 0000000000..a16369c25f --- /dev/null +++ b/web/src/engine/element-wrappers/src/designIFrame.ts @@ -0,0 +1,358 @@ +import OutputTarget from './outputTarget.js'; + +class SelectionCaret { + node: Node; + offset: number; + + constructor(node, offset) { + this.node = node; + this.offset = offset; + } +} + +class SelectionRange { + start: SelectionCaret; + end: SelectionCaret; + + constructor(start, end) { + this.start = start; + this.end = end; + } +} + +class StyleCommand { + cmd: string; + stateType: number; + cache: string|boolean; + + constructor(c: string, s:number) { + this.cmd = c; + this.stateType = s; + } +} + +export default class DesignIFrame extends OutputTarget<{}> { + root: HTMLIFrameElement; + doc: Document; + docRoot: HTMLElement; + + commandCache: StyleCommand[]; + + constructor(ele: HTMLIFrameElement) { + super(); + this.root = ele; + + if(ele.contentWindow && ele.contentWindow.document && ele.contentWindow.document.designMode == 'on') { + this.doc = ele.contentWindow.document; + this.docRoot = ele.contentWindow.document.documentElement; + } else { + throw "Specified IFrame is not in design-mode!"; + } + } + + get isSynthetic(): boolean { + return false; + } + + getElement(): HTMLIFrameElement { + return this.root; + } + + focus(): void { + this.doc.defaultView.focus(); // I3363 (Build 301) + } + + isSelectionEmpty(): boolean { + if(!this.hasSelection()) { + return true; + } + + return this.doc.getSelection().isCollapsed; + } + + hasSelection(): boolean { + let Lsel = this.doc.getSelection(); + let outerSel = document.getSelection(); + + // If the outer doc's selection matches, we're active. + if(outerSel.anchorNode == Lsel.anchorNode && outerSel.focusNode == Lsel.focusNode) { + return true; + } else { + // Problem: for testing, we can't enforce the ideal (ie: first) condition. + // Technically, the IFrame _will_ always have its own internal selection, though... so... it kinda works? + return true; + } + } + + clearSelection(): void { + if(this.hasSelection()) { + let Lsel = this.doc.getSelection(); + + if(!Lsel.isCollapsed) { + Lsel.deleteFromDocument(); // I2134, I2192 + } + } else { + console.warn("Attempted to clear an unowned Selection!"); + } + } + + invalidateSelection(): void { /* No cache maintenance needed here, partly because + * it's impossible to cache a Selection; it mutates. + */ } + + getCarets(): SelectionRange { + let Lsel = this.doc.getSelection(); + let code = Lsel.anchorNode.compareDocumentPosition(Lsel.focusNode); + + if(Lsel.isCollapsed) { + let caret = new SelectionCaret(Lsel.anchorNode, Lsel.anchorOffset); + return new SelectionRange(caret, caret); + } else { + let anchor = new SelectionCaret(Lsel.anchorNode, Lsel.anchorOffset); + let focus = new SelectionCaret(Lsel.focusNode, Lsel.focusOffset); + + if(anchor.node == focus.node) { + code = (focus.offset - anchor.offset > 0) ? 2 : 4; + } + + if(code & 2) { + return new SelectionRange(anchor, focus); + } else { // Default + // can test against code & 4 to ensure Focus is before anchor, though. + return new SelectionRange(focus, anchor); + } + } + } + + getDeadkeyCaret(): number { + return this.getTextBeforeCaret().kmwLength(); + } + + getTextBeforeCaret(): string { + if(!this.hasSelection()) { + return; + } + + let caret = this.getCarets().start; + + if(caret.node.nodeType != 3) { + return ''; // Must be a text node to provide a context. + } + + return caret.node.textContent.substr(0, caret.offset); + } + + getSelectedText(): string { + // TODO: figure out the proper implementation. + // KMW 16 and before behavior may be maintained by just returning the empty string. + return ''; + } + + getTextAfterCaret(): string { + if(!this.hasSelection()) { + return ''; + } + + let caret = this.getCarets().end; + + if(caret.node.nodeType != 3) { + return ''; // Must be a text node to provide a context. + } + + return caret.node.textContent.substr(caret.offset); + } + + getText(): string { + return this.docRoot.innerText; + } + + deleteCharsBeforeCaret(dn: number) { + if(!this.hasSelection() || dn <= 0) { + return; + } + + let start = this.getCarets().start; + + // Bounds-check on the number of chars to delete. + if(dn > start.offset) { + dn = start.offset; + } + + if(start.node.nodeType != 3) { + console.warn("Deletion of characters requested without available context!"); + return; // No context to delete characters from. + } + + let range = this.doc.createRange(); + let dnOffset = start.offset - start.node.nodeValue.substr(0, start.offset)._kmwSubstr(-dn).length; + + range.setStart(start.node, dnOffset); + range.setEnd(start.node, start.offset); + + this.adjustDeadkeys(-dn); + range.deleteContents(); + // No need to reposition the caret - the DOM will auto-move the selection accordingly, since + // we didn't use the selection to delete anything. + } + + insertTextBeforeCaret(s: string) { + if(!this.hasSelection()) { + return; + } + + let start = this.getCarets().start; + let delta = s._kmwLength(); + let Lsel = this.doc.getSelection(); + + if(delta == 0) { + return; + } + + this.adjustDeadkeys(delta); + + // While Selection.extend() was really nice for this, IE didn't support it whatsoever. + // However, IE (11, at least) DID support setting selections via ranges, so we were still + // able to manage the caret properly. + // + // TODO: double-check that it was only IE-motivated, re-implement with Selection.extend(). + let finalCaret = this.root.ownerDocument.createRange(); + + if(start.node.nodeType == 3) { + let textStart = start.node; + textStart.insertData(start.offset, s); + finalCaret.setStart(textStart, start.offset + s.length); + } else { + // Create a new text node - empty control + var n = this.doc.createTextNode(s); + + let range = this.doc.createRange(); + range.setStart(start.node, start.offset); + range.collapse(true); + range.insertNode(n); + finalCaret.setStart(n, s.length); + } + + finalCaret.collapse(true); + Lsel.removeAllRanges(); + try { + Lsel.addRange(finalCaret); + } catch(e) { + // Chrome (through 4.0 at least) throws an exception because it has not synchronised its content with the selection. + // scrollIntoView synchronises the content for selection + start.node.parentElement.scrollIntoView(); + Lsel.addRange(finalCaret); + } + Lsel.collapseToEnd(); + } + + handleNewlineAtCaret(): void { + // TODO: Implement. + // + // As it turns out, we never had an implementation for handling newline inputs from the OSK for this element type. + // At least this way, it's more explicit. + // + // Note: consult "// Create a new text node - empty control" case in insertTextBeforeCaret - + // this helps to handle the browser-default implementation of newline handling. In particular, + // entry of the first character after a newline. + // + // If raw newlines are entered into the HTML, but as with usual HTML, they're interpreted as excess whitespace and + // have no effect. We need to add DOM elements for a functional newline. + } + + protected setTextAfterCaret(s: string) { + if(!this.hasSelection()) { + return; + } + + let caret = this.getCarets().end; + let delta = s._kmwLength(); + let Lsel = this.doc.getSelection(); + + if(delta == 0) { + return; + } + + // This is designed explicitly for use in direct-setting operations; deadkeys + // will be handled after this method. + + if(caret.node.nodeType == 3) { + let textStart = caret.node; + textStart.replaceData(caret.offset, textStart.length, s); + } else { + // Create a new text node - empty control + var n = caret.node.ownerDocument.createTextNode(s); + + let range = this.root.ownerDocument.createRange(); + range.setStart(caret.node, caret.offset); + range.collapse(true); + range.insertNode(n); + } + } + + /** + * Function saveProperties + * Scope Private + * Description Build and create list of styles that can be applied in iframes + */ + saveProperties() { + // Formerly _CacheCommands. + var _CacheableCommands=[ + new StyleCommand('backcolor',1), new StyleCommand('fontname',1), new StyleCommand('fontsize',1), + new StyleCommand('forecolor',1), new StyleCommand('bold',0), new StyleCommand('italic',0), + new StyleCommand('strikethrough',0), new StyleCommand('subscript',0), + new StyleCommand('superscript',0), new StyleCommand('underline',0) + ]; + + if(this.doc.defaultView) { + _CacheableCommands.push(new StyleCommand('hilitecolor',1)); + } + + for(var n=0; n < _CacheableCommands.length; n++) { // I1511 - array prototype extended + let cmd = _CacheableCommands[n]; + //KeymanWeb._Debug('Command:'+_CacheableCommands[n][0]); + if(cmd.stateType == 1) { + cmd.cache = this.doc.queryCommandValue(cmd.cmd); + } else { + cmd.cache = this.doc.queryCommandState(cmd.cmd); + } + } + this.commandCache = _CacheableCommands; + } + + /** + * Function restoreProperties + * Scope Private + * Description Restore styles in IFRAMEs (??) + */ + restoreProperties(_func?: () => void): void { + // Formerly _CacheCommandsReset. + if(!this.commandCache) { + console.error("No command cache exists to restore!"); + } + + for(var n=0; n < this.commandCache.length; n++) { // I1511 - array prototype extended + let cmd = this.commandCache[n]; + + //KeymanWeb._Debug('ResetCacheCommand:'+_CacheableCommands[n][0]+'='+_CacheableCommands[n][2]); + if(cmd.stateType == 1) { + if(this.doc.queryCommandValue(cmd.cmd) != cmd.cache) { + if(_func) { + _func(); + } + this.doc.execCommand(cmd.cmd, false, cmd.cache); + } + } else if(this.doc.queryCommandState(cmd.cmd) != cmd.cache) { + if(_func) { + _func(); + } + //KeymanWeb._Debug('executing command '+_CacheableCommand[n][0]); + this.doc.execCommand(cmd.cmd, false, null); + } + } + } + + doInputEvent() { + // Root = the iframe, the outermost component and the one we were originally told to attach to. + this.dispatchInputEventOn(this.root); + } +} \ No newline at end of file diff --git a/web/src/engine/element-wrappers/src/index.ts b/web/src/engine/element-wrappers/src/index.ts new file mode 100644 index 0000000000..06971bdeff --- /dev/null +++ b/web/src/engine/element-wrappers/src/index.ts @@ -0,0 +1,7 @@ +export { default as wrapElement } from './wrapElement.js'; +export { default as ContentEditable } from './contentEditable.js'; +export { default as DesignIFrame } from './designIFrame.js'; +export { default as Input } from './input.js'; +export { default as OutputTarget } from './outputTarget.js'; +export { default as TextArea } from './textarea.js'; +export { nestedInstanceOf } from './utils.js'; \ No newline at end of file diff --git a/web/src/engine/element-wrappers/src/input.ts b/web/src/engine/element-wrappers/src/input.ts new file mode 100644 index 0000000000..f28c2fe59a --- /dev/null +++ b/web/src/engine/element-wrappers/src/input.ts @@ -0,0 +1,238 @@ +import OutputTarget from './outputTarget.js'; + +interface EventMap { + /** + * Used to facilitate a pre-modularization utility method we wish to maintain: + ``` +export function forceScroll(element: HTMLInputElement | HTMLTextAreaElement) { + // Only executes when com.keyman.DOMEventHandlers is defined. + // + // We bypass this whenever operating in the embedded format. + if(com && com.keyman && com.keyman['DOMEventHandlers'] && !com.keyman['singleton']['isEmbedded']) { + let DOMEventHandlers = com.keyman['DOMEventHandlers']; + + let selectionStart = element.selectionStart; + let selectionEnd = element.selectionEnd; + + DOMEventHandlers.states._IgnoreBlurFocus = true; + //Forces scrolling; the re-focus triggers the scroll, at least. + element.blur(); + element.focus(); + DOMEventHandlers.states._IgnoreBlurFocus = false; + + // On Edge, it appears that the blur/focus combination will reset the caret position + // under certain scenarios during unit tests. So, we re-set it afterward. + element.selectionStart = selectionStart; + element.selectionEnd = selectionEnd; + } +} + ``` + * References to the event-handlers & related states objects are not available within this submodule. + * + * It is the parts between and including the _IgnoreBlurFocus references that must be + * implemented externally. + */ + 'scrollfocusrequest': (element: HTMLInputElement) => void, + + /** + * This event will be raised when a newline is received by wrapped elements not of + * the 'search' or 'submit' types. + * + * Original code this is replacing: + ``` + // Allows compiling this separately from the main body of KMW. + // TODO: rework class to accept a class-static 'callback' from the DOM module that this can call. + // Would eliminate the need for this 'static' reference. + // Only strongly matters once we better modularize KMW, with web-dom vs web-dom-targets vs web-core, etc. + if(com.keyman["singleton"]) { + com.keyman["singleton"].domManager.moveToNext(false); + } + ``` + * This does not belong in a modularized version of this class; it must be supplied + * by the consuming top-level products instead. + */ + 'unhandlednewline': (element: HTMLInputElement) => void +} + +export default class Input extends OutputTarget { + root: HTMLInputElement; + + /** + * Tracks the most recently-cached selection start index. + */ + private _cachedSelectionStart: number + + /** + * Tracks the most recently processed, extended-string-based selection start index. + * When the element's selectionStart value changes, this should be invalidated. + */ + private processedSelectionStart: number; + + /** + * Tracks the most recently processed, extended-string-based selection end index. + * When the element's selectionEnd value changes, this should be invalidated. + */ + private processedSelectionEnd: number; + + constructor(ele: HTMLInputElement) { + super(); + + this.root = ele; + this._cachedSelectionStart = -1; + + // Intended to facilitate reimplmentation of the old `forceScroll` as an event handler + // defined externally, but automatically set on class construction. + Input.constructorExtensions(this); + } + + /** + * This may be set to define additional construction behaviors to perform, such as + * automatically setting handlers for defined events. + */ + public static constructorExtensions: (constructingInstance: Input) => void = () => {}; + + get isSynthetic(): boolean { + return false; + } + + getElement(): HTMLInputElement { + return this.root; + } + + clearSelection(): void { + // Processes our codepoint-based variants of selectionStart and selectionEnd. + this.getCaret(); // updates processedSelectionStart if required + this.root.value = this.root.value._kmwSubstring(0, this.processedSelectionStart) + this.root.value._kmwSubstring(this.processedSelectionEnd); //I3319 + + this.setCaret(this.processedSelectionStart); + } + + isSelectionEmpty(): boolean { + return this.root.selectionStart == this.root.selectionEnd; + } + + hasSelection(): boolean { + return true; + } + + invalidateSelection() { + // Since .selectionStart will never return this value, we use it to indicate + // the need to refresh our processed indices. + this._cachedSelectionStart = -1; + } + + getCaret(): number { + if(this.root.selectionStart != this._cachedSelectionStart) { + this._cachedSelectionStart = this.root.selectionStart; // KMW-1 + this.processedSelectionStart = this.root.value._kmwCodeUnitToCodePoint(this.root.selectionStart); // I3319 + this.processedSelectionEnd = this.root.value._kmwCodeUnitToCodePoint(this.root.selectionEnd); // I3319 + } + return this.root.selectionDirection == 'forward' ? this.processedSelectionEnd : this.processedSelectionStart; + } + + getDeadkeyCaret(): number { + return this.getCaret(); + } + + setCaret(caret: number) { + this.setSelection(caret, caret, "none"); + } + + setSelection(start: number, end: number, direction: "forward" | "backward" | "none") { + let domStart = this.root.value._kmwCodePointToCodeUnit(start); + let domEnd = this.root.value._kmwCodePointToCodeUnit(end); + this.root.setSelectionRange(domStart, domEnd, direction); + + this.processedSelectionStart = start; + this.processedSelectionEnd = end; + + this.events.emit('scrollfocusrequest', this.root); + + this.root.setSelectionRange(domStart, domEnd, direction); + } + + getSelectionDirection(): "forward" | "backward" | "none" { + return this.root.selectionDirection; + } + + getTextBeforeCaret(): string { + this.getCaret(); + return this.getText()._kmwSubstring(0, this.processedSelectionStart); + } + + getSelectedText(): string { + this.getCaret(); + return this.getText()._kmwSubstring(this.processedSelectionStart, this.processedSelectionEnd); + } + + setTextBeforeCaret(text: string) { + this.getCaret(); + let selectionLength = this.processedSelectionEnd - this.processedSelectionStart; + let direction = this.getSelectionDirection(); + let newCaret = text._kmwLength(); + this.root.value = text + this.getText()._kmwSubstring(this.processedSelectionStart); + + this.setSelection(newCaret, newCaret + selectionLength, direction); + } + + protected setTextAfterCaret(s: string) { + let c = this.getCaret(); + let direction = this.getSelectionDirection(); + + this.root.value = this.getTextBeforeCaret() + s; + this.setSelection(this.processedSelectionStart, this.processedSelectionEnd, direction); + } + + getTextAfterCaret(): string { + this.getCaret(); + return this.getText()._kmwSubstring(this.processedSelectionEnd); + } + + getText(): string { + return this.root.value; + } + + deleteCharsBeforeCaret(dn: number) { + if(dn > 0) { + let curText = this.getTextBeforeCaret(); + let caret = this.processedSelectionStart; + + if(dn > caret) { + dn = caret; + } + + this.adjustDeadkeys(-dn); + this.setTextBeforeCaret(curText.kmwSubstring(0, caret - dn)); + this.setCaret(caret - dn); + } + } + + insertTextBeforeCaret(s: string) { + if(!s) { + return; + } + + let caret = this.getCaret(); + let front = this.getTextBeforeCaret(); + let back = this.getText()._kmwSubstring(this.processedSelectionStart); + + this.adjustDeadkeys(s._kmwLength()); + this.root.value = front + s + back; + this.setCaret(caret + s._kmwLength()); + } + + handleNewlineAtCaret(): void { + const inputEle = this.root; + // Can't occur for Mocks - just Input types. + if (inputEle && (inputEle.type == 'search' || inputEle.type == 'submit')) { + inputEle.disabled=false; + inputEle.form.submit(); + } else { + this.events.emit('unhandlednewline', inputEle); + } + } + + doInputEvent() { + this.dispatchInputEventOn(this.root); + } +} \ No newline at end of file diff --git a/web/src/engine/element-wrappers/src/outputTarget.ts b/web/src/engine/element-wrappers/src/outputTarget.ts new file mode 100644 index 0000000000..1ef15dd46f --- /dev/null +++ b/web/src/engine/element-wrappers/src/outputTarget.ts @@ -0,0 +1,36 @@ +import { OutputTarget as OutputTargetBase } from "@keymanapp/keyboard-processor"; +import EventEmitter from 'eventemitter3'; + +export default abstract class OutputTarget extends OutputTargetBase { + // JS/TS can't do true multiple inheritance, so we maintain class events on a readonly field. + public readonly events: EventEmitter = new EventEmitter(); + + /** + * Returns the underlying element / document modeled by the wrapper. + */ + abstract getElement(): HTMLElement; + + public focus(): void { + const ele = this.getElement(); + if(ele.focus) { + ele.focus(); + } + } + + /** + * A helper method for doInputEvent; creates a simple common event and default dispatching. + * @param elem + */ + protected dispatchInputEventOn(elem: HTMLElement) { + let event: InputEvent; + + // `undefined` in pre-Chrome Edge and Chrome for Android before version 60. + if(window['InputEvent']) { // can't condition on the type directly; TS optimizes that out. + event = new InputEvent('input', {"bubbles": true, "cancelable": false}); + } + + if(elem && event) { + elem.dispatchEvent(event); + } + } +} \ No newline at end of file diff --git a/web/src/engine/main/dom/targets/readme.md b/web/src/engine/element-wrappers/src/readme.md similarity index 100% rename from web/src/engine/main/dom/targets/readme.md rename to web/src/engine/element-wrappers/src/readme.md diff --git a/web/src/engine/element-wrappers/src/textarea.ts b/web/src/engine/element-wrappers/src/textarea.ts new file mode 100644 index 0000000000..5f720c4dde --- /dev/null +++ b/web/src/engine/element-wrappers/src/textarea.ts @@ -0,0 +1,221 @@ +import OutputTarget from './outputTarget.js'; + +interface EventMap { + /** + * Used to facilitate a pre-modularization utility method we wish to maintain: + ``` +export function forceScroll(element: HTMLInputElement | HTMLTextAreaElement) { + // Only executes when com.keyman.DOMEventHandlers is defined. + // + // We bypass this whenever operating in the embedded format. + if(com && com.keyman && com.keyman['DOMEventHandlers'] && !com.keyman['singleton']['isEmbedded']) { + let DOMEventHandlers = com.keyman['DOMEventHandlers']; + + let selectionStart = element.selectionStart; + let selectionEnd = element.selectionEnd; + + DOMEventHandlers.states._IgnoreBlurFocus = true; + //Forces scrolling; the re-focus triggers the scroll, at least. + element.blur(); + element.focus(); + DOMEventHandlers.states._IgnoreBlurFocus = false; + + // On Edge, it appears that the blur/focus combination will reset the caret position + // under certain scenarios during unit tests. So, we re-set it afterward. + element.selectionStart = selectionStart; + element.selectionEnd = selectionEnd; + } +} + ``` + * References to the event-handlers & related states objects are not available within this submodule. + * + * It is the parts between and including the _IgnoreBlurFocus references that must be + * implemented externally. + */ + 'scrollfocusrequest': (element: HTMLTextAreaElement) => void, +} + +export default class TextArea extends OutputTarget { + root: HTMLTextAreaElement; + + /** + * Tracks the most recently-cached selection start index. + */ + private _cachedSelectionStart: number + + /** + * Tracks the most recently processed, extended-string-based selection start index. + * When the element's selectionStart value changes, this should be invalidated. + */ + private processedSelectionStart: number; + + /** + * Tracks the most recently processed, extended-string-based selection end index. + * When the element's selectionEnd value changes, this should be invalidated. + */ + private processedSelectionEnd: number; + + /** + * Used to temporarily store the y-axis scroll coordinate. + */ + private scrollTop?: number; + + /** + * Used to temporarily store the x-axis scroll coordinate. + */ + private scrollLeft?: number; + + constructor(ele: HTMLTextAreaElement) { + super(); + + this.root = ele; + this._cachedSelectionStart = -1; + // Intended to facilitate reimplmentation of the old `forceScroll` as an event handler + // defined externally, but automatically set on class construction. + TextArea.constructorExtensions(this); + } + + /** + * This may be set to define additional construction behaviors to perform, such as + * automatically setting handlers for defined events. + */ + public static constructorExtensions: (constructingInstance: TextArea) => void = () => {}; + + get isSynthetic(): boolean { + return false; + } + + getElement(): HTMLTextAreaElement { + return this.root; + } + + clearSelection(): void { + // Processes our codepoint-based variants of selectionStart and selectionEnd. + this.getCaret(); // updates processedSelectionStart if required + this.root.value = this.root.value._kmwSubstring(0, this.processedSelectionStart) + this.root.value._kmwSubstring(this.processedSelectionEnd); //I3319 + + this.setCaret(this.processedSelectionStart); + } + + isSelectionEmpty(): boolean { + return this.root.selectionStart == this.root.selectionEnd; + } + + hasSelection(): boolean { + return true; + } + + invalidateSelection() { + // Since .selectionStart will never return this value, we use it to indicate + // the need to refresh our processed indices. + this._cachedSelectionStart = -1; + } + + getCaret(): number { + if(this.root.selectionStart != this._cachedSelectionStart) { + this._cachedSelectionStart = this.root.selectionStart; // KMW-1 + this.processedSelectionStart = this.root.value._kmwCodeUnitToCodePoint(this.root.selectionStart); // I3319 + this.processedSelectionEnd = this.root.value._kmwCodeUnitToCodePoint(this.root.selectionEnd); // I3319 + } + return this.root.selectionDirection == 'forward' ? this.processedSelectionEnd : this.processedSelectionStart; + } + + getDeadkeyCaret(): number { + return this.getCaret(); + } + + setCaret(caret: number) { + this.setSelection(caret, caret, "none"); + } + + setSelection(start: number, end: number, direction: "forward" | "backward" | "none") { + let domStart = this.root.value._kmwCodePointToCodeUnit(start); + let domEnd = this.root.value._kmwCodePointToCodeUnit(end); + this.root.setSelectionRange(domStart, domEnd, direction); + + this.processedSelectionStart = start; + this.processedSelectionEnd = end; + + this.events.emit('scrollfocusrequest', this.root); + + this.root.setSelectionRange(domStart, domEnd, direction); + } + + getSelectionDirection(): "forward" | "backward" | "none" { + return this.root.selectionDirection; + } + + getTextBeforeCaret(): string { + this.getCaret(); + return this.getText()._kmwSubstring(0, this.processedSelectionStart); + } + + setTextBeforeCaret(text: string) { + this.getCaret(); + let selectionLength = this.processedSelectionEnd - this.processedSelectionStart; + let direction = this.getSelectionDirection(); + let newCaret = text._kmwLength(); + this.root.value = text + this.getText()._kmwSubstring(this.processedSelectionStart); + + this.setSelection(newCaret, newCaret + selectionLength, direction); + } + + protected setTextAfterCaret(s: string) { + let c = this.getCaret(); + let direction = this.getSelectionDirection(); + + this.root.value = this.getTextBeforeCaret() + s; + this.setSelection(this.processedSelectionStart, this.processedSelectionEnd, direction); + } + + getTextAfterCaret(): string { + this.getCaret(); + return this.getText()._kmwSubstring(this.processedSelectionEnd); + } + + getSelectedText(): string { + this.getCaret(); + return this.getText()._kmwSubstring(this.processedSelectionStart, this.processedSelectionEnd); + } + + getText(): string { + return this.root.value; + } + + deleteCharsBeforeCaret(dn: number) { + if(dn > 0) { + let curText = this.getTextBeforeCaret(); + let caret = this.processedSelectionStart; + + if(dn > caret) { + dn = caret; + } + + this.adjustDeadkeys(-dn); + this.setTextBeforeCaret(curText.kmwSubstring(0, caret - dn)); + this.setCaret(caret - dn); + } + } + + insertTextBeforeCaret(s: string) { + if(!s) { + return; + } + + let caret = this.getCaret(); + let front = this.getTextBeforeCaret(); + let back = this.getText()._kmwSubstring(this.processedSelectionStart); + + this.adjustDeadkeys(s._kmwLength()); + this.root.value = front + s + back; + this.setCaret(caret + s._kmwLength()); + } + + handleNewlineAtCaret(): void { + this.insertTextBeforeCaret('\n'); + } + + doInputEvent() { + this.dispatchInputEventOn(this.root); + } +} \ No newline at end of file diff --git a/web/src/engine/element-wrappers/src/utils.ts b/web/src/engine/element-wrappers/src/utils.ts new file mode 100644 index 0000000000..459ac0a352 --- /dev/null +++ b/web/src/engine/element-wrappers/src/utils.ts @@ -0,0 +1,43 @@ +/** + * Checks the type of an input DOM-related object while ensuring that it is checked against the correct prototype, + * as class prototypes are (by specification) scoped upon the owning Window. + * + * See https://stackoverflow.com/questions/43587286/why-does-instanceof-return-false-on-chrome-safari-and-edge-and-true-on-firefox + * for more details. + * + * @param {Element|Event} Pelem An element of the web page or one of its IFrame-based subdocuments. + * @param {string} className The plain-text name of the expected Element type. + * @return {boolean} + */ +export function nestedInstanceOf(Pelem: Event|EventTarget, className: string): boolean { + var scopedClass; + + if(!Pelem) { + // If we're bothering to check something's type, null references don't match + // what we're looking for. + return false; + } + if (Pelem['Window']) { // Window objects contain the class definitions for types held within them. So, we can check for those. + return className == 'Window'; + } else if (Pelem['defaultView']) { // Covers Document. + scopedClass = Pelem['defaultView'][className]; + } else if(Pelem['ownerDocument']) { + scopedClass = (Pelem as Node).ownerDocument.defaultView[className]; + } else if(Pelem['target']) { + var event = Pelem as Event; + + if(this.instanceof(event.target, 'Window')) { + scopedClass = event.target[className]; + } else if(this.instanceof(event.target, 'Document')) { + scopedClass = (event.target as Document).defaultView[className]; + } else if(this.instanceof(event.target, 'HTMLElement')) { + scopedClass = (event.target as HTMLElement).ownerDocument.defaultView[className]; + } + } + + if(scopedClass) { + return Pelem instanceof scopedClass; + } else { + return false; + } +} \ No newline at end of file diff --git a/web/src/engine/element-wrappers/src/wrapElement.ts b/web/src/engine/element-wrappers/src/wrapElement.ts new file mode 100644 index 0000000000..3a3e4d89e5 --- /dev/null +++ b/web/src/engine/element-wrappers/src/wrapElement.ts @@ -0,0 +1,31 @@ +import type OutputTarget from './outputTarget.js'; +import Input from './input.js'; +import TextArea from './textarea.js'; +import DesignIFrame from './designIFrame.js'; +import ContentEditable from './contentEditable.js'; +import { nestedInstanceOf } from './utils.js'; + +export default function wrapElement(e: HTMLElement): OutputTarget { + // Complex type scoping is implemented here so that kmwutils.ts is not a dependency for test compilations. + + if(nestedInstanceOf(e, "HTMLInputElement")) { + return new Input( e); + } else if(nestedInstanceOf(e, "HTMLTextAreaElement")) { + return new TextArea( e); + } else if(nestedInstanceOf(e, "HTMLIFrameElement")) { + let iframe = e; + + if(iframe.contentWindow && iframe.contentWindow.document && iframe.contentWindow.document.designMode == "on") { + return new DesignIFrame(iframe); + } else if (e.isContentEditable) { + // Do content-editable