diff --git a/common/web/types/package.json b/common/web/types/package.json index 7823f0fa87..d29766e567 100644 --- a/common/web/types/package.json +++ b/common/web/types/package.json @@ -78,7 +78,6 @@ "src/schemas/*", "tests/", "src/keyboard-object.ts", - "src/outputTarget.interface.ts", "src/*.d.ts", "src/main.ts", "src/schema-validators.ts", diff --git a/common/web/types/src/keyboard-object.ts b/common/web/types/src/keyboard-object.ts index afd0029af0..3ce697ef4b 100644 --- a/common/web/types/src/keyboard-object.ts +++ b/common/web/types/src/keyboard-object.ts @@ -8,8 +8,8 @@ export type ComplexKeyboardStore = (string | { t: 'd', d: number } | { ['t']: 'b // A stub for KeyEvent which is properly defined in KeymanWeb type KeyEventStub = {}; -// A stub for OutputTarget which is properly defined in KeymanWeb -type OutputTargetStub = {}; +// A stub for TextStore which is properly defined in KeymanWeb +type TextStoreStub = {}; export interface EncodedVisualKeyboard { /** Represents CSS font styling to use for VisualKeyboard text */ @@ -43,31 +43,31 @@ export type KeyboardObject = { * group-start: the function triggering processing for the keyboard's * "Unicode" start group, corresponding to `begin Unicode > use(_____)` in * Keyman keyboard language. - * @param outputTarget The context to which the keystroke applies + * @param textStore The context to which the keystroke applies * @param keystroke The full, pre-processed keystroke triggering * keyboard-rule application. */ - gs(outputTarget: OutputTargetStub, keystroke: KeyEventStub): boolean; + gs(textStore: TextStoreStub, keystroke: KeyEventStub): boolean; /** * group-newcontext: the function triggering processing for the keyboard's * "NewContext" start group, corresponding to `begin NewContext > use(_____)` * in Keyman keyboard language. - * @param outputTarget The new context to be used with future keystrokes + * @param textStore The new context to be used with future keystrokes * @param keystroke A 'null' `KeyEvent` providing current modifier + state information. */ - gn?(outputTarget: OutputTargetStub, keystroke: KeyEventStub): boolean; + gn?(textStore: TextStoreStub, keystroke: KeyEventStub): boolean; /** * group-postkeystroke: the function triggering processing for the keyboard's * "PostKeystroke" start group, corresponding to `begin PostKeystroke > * use(_____)` in Keyman keyboard language. - * @param outputTarget The context altered by a recent keystroke. As a + * @param textStore The context altered by a recent keystroke. As a * precondition, all changes due to `gs` / `begin Unicode` should already be * applied. * @param keystroke A 'null' `KeyEvent` providing current modifier + state information. */ - gpk?(outputTarget: OutputTargetStub, keystroke: KeyEventStub): boolean; + gpk?(textStore: TextStoreStub, keystroke: KeyEventStub): boolean; /** * Keyboard ID: the uniquely-identifying name for this keyboard. Includes the standard @@ -175,6 +175,6 @@ export type KeyboardObject = { * @param {number} _PData 1 or 0 * @returns */ - KNS?: (_PCommand: number, _PTarget: OutputTargetStub, _PData: number) => void; + KNS?: (_PCommand: number, _PTarget: TextStoreStub, _PData: number) => void; } & Record<`s${number}`, string> diff --git a/package-lock.json b/package-lock.json index 8ec6d9b7c3..06ae6b6c60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14222,6 +14222,9 @@ "name": "@keymanapp/web-utils", "extraneous": true, "license": "MIT", + "dependencies": { + "@keymanapp/common-types": "*" + }, "devDependencies": { "@keymanapp/keyman-version": "*", "@keymanapp/resources-gosh": "*", diff --git a/web/README.md b/web/README.md index 01581fc234..de8f638e5f 100644 --- a/web/README.md +++ b/web/README.md @@ -117,7 +117,7 @@ graph TD; subgraph ClassicWeb["`**ClassicWeb** Intermediate-level engine modules`"] - Elements["/web/src/engine/element-wrappers"]; + Elements["/web/src/engine/element-text-stores"]; Elements-->JSProc; KeyboardStorage["/web/src/engine/keyboard-storage"]; KeyboardStorage-->Interfaces; diff --git a/web/build.sh b/web/build.sh index 5a9152c0d7..e1dee7d941 100755 --- a/web/build.sh +++ b/web/build.sh @@ -112,7 +112,7 @@ build_action() { precompile "${dir}" done - cp "${KEYMAN_ROOT}/web/src/test/auto/dom/cases/attachment/outputTargetForElement.tests.html" \ + cp "${KEYMAN_ROOT}/web/src/test/auto/dom/cases/attachment/textStoreForElement.tests.html" \ "${KEYMAN_ROOT}/web/build/test/dom/cases/attachment/" } diff --git a/web/docs/engine/reference/interface/contextExOutput.md b/web/docs/engine/reference/interface/contextExOutput.md index 35a1801088..73a1a97c8a 100644 --- a/web/docs/engine/reference/interface/contextExOutput.md +++ b/web/docs/engine/reference/interface/contextExOutput.md @@ -11,13 +11,13 @@ gap between desktop and web core functionality for `context(n)` matching on `not ## Syntax ```js - keyman.interface.contextExOutput(dn, outputTarget, contextLength, contextOffset); + keyman.interface.contextExOutput(dn, textStore, contextLength, contextOffset); ``` or ```js - KeymanWeb.KCXO(dn, outputTarget, contextLength, contextOffset); // Shorthand + KeymanWeb.KCXO(dn, textStore, contextLength, contextOffset); // Shorthand ``` ## Parameters @@ -26,8 +26,8 @@ or : Type: `number` : number of characters to delete left of cursor -`outputTarget` -: Type: `OutputTarget` +`textStore` +: Type: `TextStore` : target to output to `contextLength` diff --git a/web/docs/internal/context-state-management.md b/web/docs/internal/context-state-management.md index 910129ac0b..9c25186f7b 100644 --- a/web/docs/internal/context-state-management.md +++ b/web/docs/internal/context-state-management.md @@ -1,72 +1,169 @@ # Context State Management -## The `OutputTarget` Abstraction +## The `TextStore` Abstraction -The `OutputTarget` abstraction and its associated types and classes exist to facilitate handling different types of context sources within Keyman Engine for Web through a common interface. In essence, any implementing type is valid within the engine as a "target" for "output" from any existing keyboard supported by the engine. Through 18.0, only JS-based keyboards were supported due to lack of implementation of alternate keystroke-processing engines. +The `TextStore` abstraction and its associated types and classes exist +to facilitate handling different types of context sources within Keyman +Engine for Web through a common interface. In essence, any implementing +type is valid within the engine as a "target" for "output" from any +existing keyboard supported by the engine. Through 18.0, only JS-based +keyboards were supported due to lack of implementation of alternate +keystroke-processing engines. -At the most basic level, the abstraction is defined at [web/src/engine/keyboard/outputTarget.interface.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/keyboard/src/outputTarget.interface.ts). The methods defined therein support properties and methods for fetching, setting, and manipulating deadkey markers, text-selection, and text within whatever context source it represents. JS-keyboard keystroke processing directly uses these methods during operation. -- `epic/web-core` note: moved to `outputTarget.ts` in the same folder, dropping the `.interface` component. +The base implmentation may be found at +[web/src/engine/keyboard/textStore.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/keyboard/src/textStore.ts). +The methods defined therein support properties and methods for fetching, +setting, and manipulating deadkey markers, text-selection, and text +within whatever context source it represents. JS-keyboard keystroke +processing directly uses these methods during operation. -The base implementation for this type may be found at [web/src/engine/js-processor/src/outputTarget.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/js-processor/src/outputTarget.ts). This implementation provides common support for deadkey tracking, matching, and handling - the same strategy is used regardless of context-source type. A few additional methods are provided to assist with comparison and contrast between two different context states and restoration of a prior context state. -- `epic/web-core` note: renamed `OutputTargetBase`, moved to `outputTargetBase.ts` in the same folder +Since the context sources are the same for all keyboard processors, the +all use the same base implementation, although not all funcitonality is +needed by all keyboard processors (e.g. deadkey tracking functionality +is only needed by the JS-keyboard processor). This implementation +provides common support for deadkey tracking, matching, and handling - +the same strategy is used regardless of context-source type. A few +additional methods are provided to assist with comparison and contrast +between two different context states and restoration of a prior context +state. + +- note: previously called `OutputTarget` ### Deadkey management -Specifics for the implementation of JS-keyboard deadkeys can be found here: https://github.com/keymanapp/keyman/blob/b4df4ab80862bc90da42bcdbd333df0a14da01ca/web/src/engine/js-processor/src/deadkeys.ts#L2-L6 -- `epic/web-core` note: unaltered. +Specifics for the implementation of JS-keyboard deadkeys can be found +here: [web/src/engine/keyboard/src/deadkeys.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/keyboard/src/deadkeys.ts) -`ordinal`: if two deadkeys are in the same "virtual" position, this field resolves which came first. -`matched`: used during keyboard-rule processing. +`ordinal`: if two deadkeys are in the same "virtual" position, this +field resolves which came first. `matched`: used during keyboard-rule +processing. -Note that we do NOT actually insert the deadkeys into the raw text-context! Web's pattern here is different than that of Keyman Core. +Note that we do NOT actually insert the deadkeys into the raw +text-context! Web's pattern here is different than that of Keyman Core. ## Manipulating context-states -Initially used to support predictive-text, the `Transform` type aims to encapsulate the minimal information necessary to transition from one context-state to another. It is both selection-agnostic and deadkey-agnostic. `Transform`s are used both by predictive-text and by the webview-embedded build of the engine in order to succinctly communicate the data needed to update context upon receiving keystrokes. -- `Transform`'s specification may be found at [common/web/types/src/lexical-model-types.ts](https://github.com/keymanapp/keyman/blob/master/common/web/types/src/lexical-model-types.ts). +Initially used to support predictive-text, the `Transform` type aims to +encapsulate the minimal information necessary to transition from one +context-state to another. It is both selection-agnostic and +deadkey-agnostic. `Transform`s are used both by predictive-text and by +the webview-embedded build of the engine in order to succinctly +communicate the data needed to update context upon receiving keystrokes. + +- `Transform`'s specification may be found at + [common/web/types/src/lexical-model-types.ts](https://github.com/keymanapp/keyman/blob/master/common/web/types/src/lexical-model-types.ts). `Transform`s consist of three values: -- `deleteLeft` - the number of codepoints prior to the caret/selection that should be deleted -- `insert` - the text to insert at the caret and/or replace currently-selected text -- `deleteRight` - the number of codepoints _after_ the caret/selection that should be deleted - - Note that `deleteRight` does not currently see actual use due to iOS platform limitations. -This type may then be used as an argument to `OutputTarget.apply()` (defined on `OutputTarget` (`epic/web-core`: `OutputTargetBase`)) to update any context source accordingly. +- `deleteLeft` - the number of codepoints prior to the caret/selection + that should be deleted +- `insert` - the text to insert at the caret and/or replace + currently-selected text +- `deleteRight` - the number of codepoints _after_ the caret/selection + that should be deleted + - Note that `deleteRight` does not currently see actual use due to iOS + platform limitations. -It is possible to determine the `Transform` needed to transition from one `OutputTarget` to another using `OutputTarget.buildTransformFrom` (defined on `OutputTarget` (`epic/web-core`: `OutputTargetBase`)). +This type may then be used as an argument to `TextStore.apply()` +(defined on `TextStore`) to update any context source accordingly. -### The `Mock` - representing context-state +It is possible to determine the `Transform` needed to transition from +one `TextStore` to another using `TextStore.buildTransformFrom` +(defined on `TextStore`). -The comparison and contrast methods mentioned above for `OutputTarget` are of particular use for predictive text, which usually operates with a headless implementation of the type, termed a `Mock`. This class may be found in [web/src/engine/js-processor/src/mock.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/js-processor/src/mock.ts). It is a fully-featured implementation of the `OutputTarget` interface. -- `epic/web-core` note: unaltered. +### The `SyntheticTextStore` - representing context-state -It is possible to make a `Mock`-based clone of any `OutputTarget`-derived type - a feature leveraged significantly within the inner workings of Keyman Engine for Web. As JS keyboards can have side effects beyond text-manipulation, predictive text generally operates by first _cloning_ the "true" context source. `Mock`s are also used when saving context states within the engine for later reference and/or reuse - a feature also utilized significantly for multitap support. +The comparison and contrast methods mentioned above for `TextStore` +are of particular use for predictive text, which usually operates with a +headless implementation of the type, termed a `SyntheticTextStore`. This class may be +found in +[web/src/engine/keyboard/src/syntheticTextStore.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/keyboard/src/syntheticTextStore.ts). +It is a fully-featured implementation of the `TextStore` interface. -`Mock`s can also easily be constructed from scratch for a simple string. Optionally, caret position or selection data may be specified at construction time as well. `epic/web-core`: in theory, this should make them easy to utilize for integration with Keyman Core. +It is possible to make a `SyntheticTextStore`-based clone of any +`TextStore`-derived type - a feature leveraged significantly within +the inner workings of Keyman Engine for Web. As JS keyboards can have +side effects beyond text-manipulation, predictive text generally +operates by first _cloning_ the "true" context source. `SyntheticTextStore`s are +also used when saving context states within the engine for later +reference and/or reuse - a feature also utilized significantly for +multitap support. + +`SyntheticTextStore`s can also easily be constructed from scratch for a simple string. +Optionally, caret position or selection data may be specified at +construction time as well. `epic/web-core`: in theory, this should make +them easy to utilize for integration with Keyman Core. + +- note: previously called `Mock` ### The `Transcription` - representing context-state transitions -The `Transcription` class (defined within [web/src/engine/js-processor/src/outputTarget.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/js-processor/src/outputTarget.ts)) is the type within the engine that most closely matches a sense of transition in context state. These are generally constructed by comparing two `OutputTarget` instances to each other via `OutputTarget.buildTranscriptionFrom`, with the base instance corresponding to the "new" state and the first parameter matching the original state before transition. -- `epic/web-core` note: moved to `outputTargetBase.ts` in the same folder; the method is on `OutputTargetBase`. +The `Transcription` class (defined in +[web/src/engine/js-processor/src/transcription.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/js-processor/src/transcription.ts)) +is the type within the engine that most closely matches a sense of +transition in context state. These are generally constructed by +comparing two `TextStore` instances to each other via +`TextStore.buildTranscriptionFrom`, with the base instance +corresponding to the "new" state and the first parameter matching the +original state before transition. -`Transcription`s are granted unique identifiers and are used within the engine for preservation of recent context states. These identifiers are currently generated within the class's constructor and are internally set. When predictive-text generates new suggestions or a multitap needs to revert to a prior context, both will use the `Transcription`'s unique identifier in order to find the corresponding context state and leverage it as needed for their operations. +`Transcription`s are granted unique identifiers and are used within the +engine for preservation of recent context states. These identifiers are +currently generated within the class's constructor and are internally +set. When predictive-text generates new suggestions or a multitap needs +to revert to a prior context, both will use the `Transcription`'s unique +identifier in order to find the corresponding context state and leverage +it as needed for their operations. Important fields: -- `keystroke` - the keystroke that triggered the context change corresponding to this `Transcription` + +- `keystroke` - the keystroke that triggered the context change + corresponding to this `Transcription` - `transform` - the direct effects of the keystroke - - This uses a specialized variant that also notes if the transition destroyed previously-existing selected text. -- `preInput` - the state of the context immediately before the keystroke was processed -Note that the transition metadata does not include deadkeys generated by its triggering `keystroke`. The decision was made long ago to forgo directly recording deadkey changes when recording `Transcriptions`, as any operation that restores an old context also seeks to apply deadkey-destroying operations immediately afterward. Should we ever need to do so, PR #1611 contains code that was originally designed for actively detecting and recording deadkey transition data in `Transcription`s. + - This uses a specialized variant that also notes if the transition + destroyed previously-existing selected text. -A cache of recent context-state transitions is stored at `keyman.core.contextCache`, with `keyman.core` being an instance of `InputProcessor` ([web/src/engine/main/src/headless/inputProcessor.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/main/src/headless/inputProcessor.ts)), the component responsible for linking keystroke processing with predictive-text support and restoration of context-state for multitap-generated keystrokes. +- `preInput` - the state of the context immediately before the keystroke + was processed + +Note that the transition metadata does not include deadkeys generated by +its triggering `keystroke`. The decision was made long ago to forgo +directly recording deadkey changes when recording `Transcriptions`, as +any operation that restores an old context also seeks to apply +deadkey-destroying operations immediately afterward. Should we ever +need to do so, PR #1611 contains code that was originally designed for +actively detecting and recording deadkey transition data in +`Transcription`s. + +A cache of recent context-state transitions is stored at +`keyman.core.contextCache`, with `keyman.core` being an instance of +`InputProcessor` +([web/src/engine/main/src/headless/inputProcessor.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/main/src/headless/inputProcessor.ts)), +the component responsible for linking keystroke processing with +predictive-text support and restoration of context-state for +multitap-generated keystrokes. ### JS-keyboard keystroke processing -For JS-keyboard keystroke processing, a `Mock` clone of the context is generated before any actual keyboard rule checks are applied. This provides a clear "before" state (eventually saved at `Transcription.preInput`) useful for determining the scope of the keystroke's changes once processing is completed via `buildTranscriptionFrom`. +For JS-keyboard keystroke processing, a `SyntheticTextStore` clone of the context is +generated before any actual keyboard rule checks are applied. This +provides a clear "before" state (eventually saved at +`Transcription.preInput`) useful for determining the scope of the +keystroke's changes once processing is completed via +`buildTranscriptionFrom`. -Once keystroke processing is completed by a JS-keyboard, the JS-processor constructs a `ProcessorAction` object describing all primary and side effects of the keystroke. Defined at [web/src/engine/keyboard/src/keyboards/processorAction.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/keyboard/src/keyboards/processorAction.ts), all of its fields aside from `transcription` are specific to JS-keyboard side effects, some of which do need special handling and support outside of the keystroke processor. None of these side effects apply for common-case keystrokes and so have default handling in place within the engine for cases where they are not needed. +Once keystroke processing is completed by a JS-keyboard, the +JS-processor constructs a `ProcessorAction` object describing all +primary and side effects of the keystroke. Defined at +[web/src/engine/keyboard/src/keyboards/processorAction.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/keyboard/src/keyboards/processorAction.ts), +all of its fields aside from `transcription` are specific to JS-keyboard +side effects, some of which do need special handling and support outside +of the keystroke processor. None of these side effects apply for +common-case keystrokes and so have default handling in place within the +engine for cases where they are not needed. --- -In case referenced classes/files have moved: this doc was last updated in 19.0-alpha, based upon PR #14001. +In case referenced classes/files have moved: this doc was last updated +in 19.0-alpha, based upon PR #14001. diff --git a/web/docs/internal/keystroke-lifecycle.md b/web/docs/internal/keystroke-lifecycle.md index 9940ac6d8e..68dfc14d02 100644 --- a/web/docs/internal/keystroke-lifecycle.md +++ b/web/docs/internal/keystroke-lifecycle.md @@ -126,7 +126,7 @@ The bulk of DOM key events trigger keystroke processing on key-down, though modi The `app/webview` version of the engine, which is designed to be used while embedded in a platform-specific host app, does not have direct access to standard hardware keystroke events, as those are handled by the host app's OS and by native code handlers run outside the host app's WebView containing the app/webview Web engine. It is the responsibility of the host app to handle hardware keystroke events and preprocess them on behalf of the Web engine, then forward them to the Web engine via JS call into the WebView. The `PassthroughKeyboard` class within `app/webview` space provides the method `raiseKeyEvent` as an internal API for this purpose, which converts the mobile-app format for hardware keystroke into the internal `KeyEvent` format. `raiseKeyEvent` also handles mnemonic keystroke processing and remapping. -Also note that this variant does not model the user's text context with Web elements - it is entirely managed through the `Mock` type. +Also note that this variant does not model the user's text context with Web elements - it is entirely managed through the `SyntheticTextStore` type. ### On-screen keyboard diff --git a/web/docs/internal/keystroke-processing.md b/web/docs/internal/keystroke-processing.md index 0945615c09..60c2c44f7e 100644 --- a/web/docs/internal/keystroke-processing.md +++ b/web/docs/internal/keystroke-processing.md @@ -6,7 +6,7 @@ In addition to handling keystroke events produced from hardware keyboards, Keyma Defined at [web/src/engine/keyboard/src/keyEvent.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/keyboard/src/keyEvent.ts), the `KeyEvent` is used to represent incoming _and_ potential keystrokes. This type is what the JS-keyboard processor references when evaluating keyboard rules during its keystroke processing. For versions of the engine that support AltGr aliasing, such aliasing will be applied during generation of `KeyEvent` objects. -Note that for predictive-text's fat-finger correction functionality, the engine will also generate versions of this type for nearby but _unpressed_ keys into the keystroke processing engine as well in order to facilitate predictions that follow context manipulations that could have resulted from specialized rules or reorders on neighboring keys. `Mock`-cloned copies of the active context-state will be leveraged to prevent unwanted manipulation of the true context source. +Note that for predictive-text's fat-finger correction functionality, the engine will also generate versions of this type for nearby but _unpressed_ keys into the keystroke processing engine as well in order to facilitate predictions that follow context manipulations that could have resulted from specialized rules or reorders on neighboring keys. `SyntheticTextStore`-cloned copies of the active context-state will be leveraged to prevent unwanted manipulation of the true context source. ### `isSynthetic` `isSynthetic` should be set to `true` if generated through interaction with an on-screen-keyboard or for fat-finger simulation. It should only be set to `false` if sending the basic key-event data through to the destination, without rule processing, leads to default handling picking up the slack. Browsers provide default handling of keystrokes not directly defined within keyboards, but this is not available for keystrokes against the engine's OSK without internal support. @@ -31,7 +31,7 @@ See also: [context-state-management.md](context-state-management.md#js-keyboard The "first stop" for incoming keystrokes is the `InputProcessor`, found at [web/src/engine/main/src/headless/inputProcessor.ts](https://github.com/keymanapp/keyman/blob/master/web/src/engine/main/src/headless/.inputProcessor.ts), through its `processKeyEvent` method. This class manages higher-level functionality triggered by keystroke events while deferring actual interpretation of the incoming keystroke further down the line. Of particular note is that it also handles control-flows that require restoration of previously-occuring contexts. -This class is the connection point for generating prediction requests and receiving corresponding suggestions. In order to facilitate higher-quality predictive-text when enabled, the `InputProcessor` will _also_ generate and trigger processing for nearby keys. This process allows transforms, reorders, and KMN keyboard rules to take effect and be used as alternative context roots for predictions. These are generally run against `Mock`-based clones of the true context source and are additionally prevented from triggering long-term side-effects, such as changes to KMN-keyboard variable stores, by only calling `KeyboardProcessor.finalizeProcessorAction` for the true input keystroke's result object. +This class is the connection point for generating prediction requests and receiving corresponding suggestions. In order to facilitate higher-quality predictive-text when enabled, the `InputProcessor` will _also_ generate and trigger processing for nearby keys. This process allows transforms, reorders, and KMN keyboard rules to take effect and be used as alternative context roots for predictions. These are generally run against `SyntheticTextStore`-based clones of the true context source and are additionally prevented from triggering long-term side-effects, such as changes to KMN-keyboard variable stores, by only calling `KeyboardProcessor.finalizeProcessorAction` for the true input keystroke's result object. Keys generated by OSK multitap need special handling here as well; they should always be applied to the context state as it existed at the time of the initial tap. To facilitate this, the `InputProcessor` will directly rewind the active context-source to match the corresponding context state before requesting that the `KeyEvent` be processed. @@ -64,7 +64,7 @@ The method linked above is the primary entrypoint for rule processing of individ #### JS-keyboard interfacing -Certain Keyman language features can make permanent side-effect changes to state. In order to prevent these from taking place for every keystroke, the method that interfaces with JS keyboards - `JSKeyboardInterface.process` - saves the context state (as a `Mock`) and current variable store values, then prepares a fresh `ProcessorAction` instance, before passing control off to the keyboard's backing script. (Note that `JSKeyboardInterface` itself primarily consists of keyboard-script API called by JS-keyboard script.) +Certain Keyman language features can make permanent side-effect changes to state. In order to prevent these from taking place for every keystroke, the method that interfaces with JS keyboards - `JSKeyboardInterface.process` - saves the context state (as a `SyntheticTextStore`) and current variable store values, then prepares a fresh `ProcessorAction` instance, before passing control off to the keyboard's backing script. (Note that `JSKeyboardInterface` itself primarily consists of keyboard-script API called by JS-keyboard script.) A few of the keyboard-script API methods will mark `ProcessorAction` properties directly when called, but the bulk of its data will be set once the keyboard-script returns control to Keyman Engine for Web. At this time, variable store values will also be reverted to prevent possible cross-contamination effects when predictive text is active - they're reapplied later if `KeyboardProcessor.finalizeProcessorAction` is leveraged on the resulting instance. Components documented in [context-state-management.md](./context-state-management.md) are then leveraged to determine the total change to context caused by the keystroke. diff --git a/web/package.json b/web/package.json index e5344ce1b4..f1eae1b51b 100644 --- a/web/package.json +++ b/web/package.json @@ -27,10 +27,10 @@ "types": "./build/engine/obj/dom-utils/index.d.ts", "import": "./build/engine/obj/dom-utils/index.js" }, - "./engine/element-wrappers": { - "es6-bundling": "./src/engine/src/element-wrappers/index.ts", - "types": "./build/engine/obj/element-wrappers/index.d.ts", - "import": "./build/engine/obj/element-wrappers/index.js" + "./engine/element-text-stores": { + "es6-bundling": "./src/engine/src/element-text-stores/index.ts", + "types": "./build/engine/obj/element-text-stores/index.d.ts", + "import": "./build/engine/obj/element-text-stores/index.js" }, "./engine/events": { "es6-bundling": "./src/engine/src/events/index.ts", diff --git a/web/src/app/browser/src/beepHandler.ts b/web/src/app/browser/src/beepHandler.ts index 9d1896b10a..76936c220c 100644 --- a/web/src/app/browser/src/beepHandler.ts +++ b/web/src/app/browser/src/beepHandler.ts @@ -1,6 +1,6 @@ import { type JSKeyboardInterface } from 'keyman/engine/js-processor'; import { JSKeyboard, type KeyboardMinimalInterface } from 'keyman/engine/keyboard'; -import { DesignIFrame, OutputTargetElementWrapper } from 'keyman/engine/element-wrappers'; +import { DesignIFrameElementTextStore, AbstractElementTextStore } from 'keyman/engine/element-text-stores'; // Utility object used to handle beep (keyboard error response) operations. class BeepData { @@ -33,15 +33,15 @@ export class BeepHandler { * @param {Object} Pelem element to flash * Description Flash body as substitute for audible beep; notify embedded device to vibrate */ - beep(outputTarget: OutputTargetElementWrapper) { - if (!(outputTarget instanceof OutputTargetElementWrapper)) { + beep(textStore: AbstractElementTextStore) { + if (!(textStore instanceof AbstractElementTextStore)) { return; } // All code after this point is DOM-based, triggered by the beep. - let Pelem: HTMLElement = outputTarget.getElement(); - if(outputTarget instanceof DesignIFrame) { - Pelem = outputTarget.docRoot; // I1446 - beep sometimes fails to flash when using OSK and rich control + let Pelem: HTMLElement = textStore.getElement(); + if(textStore instanceof DesignIFrameElementTextStore) { + Pelem = textStore.docRoot; // I1446 - beep sometimes fails to flash when using OSK and rich control } if(!Pelem) { diff --git a/web/src/app/browser/src/configuration.ts b/web/src/app/browser/src/configuration.ts index 0b01bbe130..26caf1ac3d 100644 --- a/web/src/app/browser/src/configuration.ts +++ b/web/src/app/browser/src/configuration.ts @@ -1,8 +1,8 @@ import { EngineConfiguration, InitOptionSpec, InitOptionDefaults } from "keyman/engine/main"; -import { OutputTargetElementWrapper as DOMOutputTarget } from 'keyman/engine/element-wrappers'; -import { OutputTargetInterface, ProcessorAction } from 'keyman/engine/keyboard'; -import { isEmptyTransform } from 'keyman/engine/js-processor'; +import { AbstractElementTextStore } from 'keyman/engine/element-text-stores'; +import { TextStore, ProcessorAction } from 'keyman/engine/keyboard'; +import { isEmptyTransform } from 'keyman/common/web-utils'; import { AlertHost } from "./utils/alertHost.js"; import { whenDocumentReady } from "./utils/documentReady.js"; @@ -66,14 +66,14 @@ export class BrowserConfiguration extends EngineConfiguration { return baseReport; } - onRuleFinalization(ruleBehavior: ProcessorAction, outputTarget: OutputTargetInterface) { + onRuleFinalization(ruleBehavior: ProcessorAction, textStore: TextStore) { // TODO: Patch up to modularized form. But that doesn't exist yet for some of these... // If the transform isn't empty, we've changed text - which should produce a 'changed' event in the DOM. const ruleTransform = ruleBehavior.transcription.transform; if(!isEmptyTransform(ruleTransform)) { - if(outputTarget instanceof DOMOutputTarget) { - outputTarget.changed = true; + if (textStore instanceof AbstractElementTextStore) { + textStore.changed = true; } } } diff --git a/web/src/app/browser/src/context/focusAssistant.ts b/web/src/app/browser/src/context/focusAssistant.ts index 6a7519eefb..ee67fde283 100644 --- a/web/src/app/browser/src/context/focusAssistant.ts +++ b/web/src/app/browser/src/context/focusAssistant.ts @@ -13,7 +13,7 @@ export class FocusStateAPIObject { activated: boolean; /** - * Indicates that KMW is actively maintaining focus on the currently active OutputTarget control + * Indicates that KMW is actively maintaining focus on the currently active TextStore control * while some UI element (the OSK, a keyboard-change UI) is the current focus of user-interaction. */ activationPending: boolean; @@ -35,7 +35,7 @@ interface EventMap { // Formerly handled under "UIManager". /** * This class provides fields and methods useful for assisting context management. Control focus (and - * thus, activation of the corresponding OutputTarget) should not be lost to non-context components of + * thus, activation of the corresponding TextStore) should not be lost to non-context components of * KMW, such as the OSK or a keyboard selector. */ export class FocusAssistant extends EventEmitter { @@ -57,16 +57,16 @@ export class FocusAssistant extends EventEmitter { * Long-term idea here: about all of the relevant OSK events that would interact with this have "enter" and * "leave" variants - we could take a stack of `Promise`s. On a `Promise` fulfillment, remove it from the * stack. When the last one is removed, the focus-maintenance state would end, allowing further events - * to deactivate the active OutputTarget. + * to deactivate the active TextStore. */ /** - * Indicates that KMW is actively maintaining focus on the currently active OutputTarget control, rather + * Indicates that KMW is actively maintaining focus on the currently active TextStore control, rather * than losing focus while some UI element (the OSK, a keyboard-change UI) is the most direct recipient * of browser focus due to user-interaction - generally, with non-context engine components. * - * While the flag is active, the context-management system should not deactivate an OutputTarget upon - * its element's loss of focus within the page unless setting a different OutputTarget as active. + * While the flag is active, the context-management system should not deactivate an TextStore upon + * its element's loss of focus within the page unless setting a different TextStore as active. * * TODO: (potential) Future enhancement - this should not be possible to set if there is no currently-active * context target to maintain. diff --git a/web/src/app/browser/src/contextManager.ts b/web/src/app/browser/src/contextManager.ts index 3826f78bb4..d079ea6d9d 100644 --- a/web/src/app/browser/src/contextManager.ts +++ b/web/src/app/browser/src/contextManager.ts @@ -1,9 +1,9 @@ import { JSKeyboard, type Keyboard, KeyboardScriptError } from 'keyman/engine/keyboard'; import { type KeyboardStub } from 'keyman/engine/keyboard-storage'; import { CookieSerializer } from 'keyman/engine/dom-utils'; -import { eventOutputTarget, outputTargetForElement, PageContextAttachment } from 'keyman/engine/attachment'; +import { textStoreForEvent, textStoreForElement, PageContextAttachment } from 'keyman/engine/attachment'; import { DomEventTracker, LegacyEventEmitter } from 'keyman/engine/events'; -import { DesignIFrame, OutputTargetElementWrapper, nestedInstanceOf } from 'keyman/engine/element-wrappers'; +import { DesignIFrameElementTextStore, AbstractElementTextStore, nestedInstanceOf } from 'keyman/engine/element-text-stores'; import { ContextManagerBase, type KeyboardInterfaceBase, @@ -47,8 +47,8 @@ export default class ContextManager extends ContextManagerBase('KeymanWeb_Keyboard'); readonly focusAssistant = new FocusAssistant(() => this.activeTarget?.isForcingScroll()); readonly page: PageContextAttachment; - private mostRecentTarget: OutputTargetElementWrapper; - private currentTarget: OutputTargetElementWrapper; + private mostRecentTarget: AbstractElementTextStore; + private currentTarget: AbstractElementTextStore; private globalKeyboard: {keyboard: JSKeyboard, metadata: KeyboardStub}; @@ -92,7 +92,7 @@ export default class ContextManager extends ContextManagerBase { - if(!(elem._kmwAttachment.interface instanceof DesignIFrame)) { + if(!(elem._kmwAttachment.textStore instanceof DesignIFrameElementTextStore)) { // For anything attached but (design-mode) iframes... // This block: has to do with maintaining focus. @@ -126,7 +126,7 @@ export default class ContextManager extends ContextManagerBase { + get activeTarget(): AbstractElementTextStore { /* * Assumption: the maintainingFocus flag may only be set when there is a current target. * This is not enforced proactively at present, but the assumption should hold. (2023-05-03) @@ -184,7 +184,7 @@ export default class ContextManager extends ContextManagerBase { + get lastActiveTarget(): AbstractElementTextStore { return this.mostRecentTarget; } @@ -229,7 +229,7 @@ export default class ContextManager extends ContextManagerBase, sendEvents?: boolean) { + public setActiveTarget(target: AbstractElementTextStore, sendEvents?: boolean) { const previousTarget = this.mostRecentTarget; const originalTarget = this.activeTarget; // may differ, depending on focus state. @@ -261,7 +261,7 @@ export default class ContextManager extends ContextManagerBase { + protected currentKeyboardSrcTarget(): AbstractElementTextStore { const target = this.currentTarget || this.mostRecentTarget; if(this.isTargetKeyboardIndependent(target)) { @@ -379,15 +379,15 @@ export default class ContextManager extends ContextManagerBase): boolean { - const attachmentInfo = target?.getElement()._kmwAttachment; + private isTargetKeyboardIndependent(textStore: AbstractElementTextStore): boolean { + const attachment = textStore?.getElement()._kmwAttachment; // If null or undefined, we're in 'global' mode. - return !!(attachmentInfo?.keyboard || attachmentInfo?.keyboard === ''); + return !!(attachment?.keyboard || attachment?.keyboard === ''); } // Note: is part of the keyboard activation process. Not to be called directly by published API. - activateKeyboardForTarget(kbd: { keyboard: JSKeyboard, metadata: KeyboardStub }, target: OutputTargetElementWrapper) { + activateKeyboardForTarget(kbd: { keyboard: JSKeyboard, metadata: KeyboardStub }, target: AbstractElementTextStore) { const attachment = target?.getElement()._kmwAttachment; if(!attachment) { @@ -421,8 +421,8 @@ export default class ContextManager extends ContextManagerBase, kbdId: string, langId: string) { - if(target instanceof DesignIFrame) { + public setKeyboardForTarget(target: AbstractElementTextStore, kbdId: string, langId: string) { + if(target instanceof DesignIFrameElementTextStore) { console.warn("'keymanweb.setKeyboardForControl' cannot set keyboard on iframes."); return; } @@ -456,7 +456,7 @@ export default class ContextManager extends ContextManagerBase) { + public getKeyboardStubForTarget(target: AbstractElementTextStore) { if(!this.isTargetKeyboardIndependent(target)) { return this.globalKeyboard.metadata; } else { @@ -614,16 +614,16 @@ export default class ContextManager extends ContextManagerBase): boolean { + _CommonFocusHelper(textStore: AbstractElementTextStore): boolean { const focusAssistant = this.focusAssistant; const activeKeyboard = this.activeKeyboard?.keyboard; if(!focusAssistant.restoringFocus) { - outputTarget?.deadkeys().clear(); - activeKeyboard?.notify(0, outputTarget, 1); // I2187 + textStore?.deadkeys().clear(); + activeKeyboard?.notify(0, textStore, 1); // I2187 } - if(!focusAssistant.restoringFocus && this.mostRecentTarget != outputTarget) { + if(!focusAssistant.restoringFocus && this.mostRecentTarget != textStore) { focusAssistant.maintainingFocus = false; } focusAssistant.restoringFocus = false; @@ -642,19 +642,19 @@ export default class ContextManager extends ContextManagerBase { - // Step 1: determine the corresponding OutputTarget instance. - const target = eventOutputTarget(e); + // Step 1: determine the corresponding TextStore instance. + const target = textStoreForEvent(e); if(!target) { // Probably should also make a warning or error? return true; } // ???? ?: ensure it's properly active? - // if(target instanceof DesignIFrame) { //**TODO: check case reference + // if(target instanceof DesignIFrameElementTextStore) { //**TODO: check case reference // // But... the following should already have been done during attachment... // // attachmentEngine._AttachToIframe(Ltarg as HTMLIFrameElement); // target.docRoot - // Ltarg=Ltarg.contentWindow.document.body; // And we only care about Ltarg b/c of finding the OutputTarget. + // Ltarg=Ltarg.contentWindow.document.body; // And we only care about Ltarg b/c of finding the TextStore. // } // Step 2: Make the newly-focused control the active control, and thus the active context. @@ -685,8 +685,8 @@ export default class ContextManager extends ContextManagerBase) { + doChangeEvent(target: AbstractElementTextStore) { if(target.changed) { const event = new Event('change', {"bubbles": true, "cancelable": false}); target.getElement().dispatchEvent(event); diff --git a/web/src/app/browser/src/defaultBrowserRules.ts b/web/src/app/browser/src/defaultBrowserRules.ts index ec87f459d4..f85baac16d 100644 --- a/web/src/app/browser/src/defaultBrowserRules.ts +++ b/web/src/app/browser/src/defaultBrowserRules.ts @@ -3,7 +3,7 @@ import { Codes, DefaultRules, type KeyEvent, - type OutputTargetInterface + type TextStore } from 'keyman/engine/keyboard'; import ContextManager from './contextManager.js'; @@ -32,7 +32,7 @@ export default class DefaultBrowserRules extends DefaultRules { /** * applyCommand - used when a ProcessorAction represents a non-text "command" within the Engine. */ - applyCommand(Lkc: KeyEvent, outputTarget: OutputTargetInterface): void { + applyCommand(Lkc: KeyEvent, textStore: TextStore): void { const code = this.codeForEvent(Lkc); const moveToNext = (back: boolean) => { @@ -56,6 +56,6 @@ export default class DefaultBrowserRules extends DefaultRules { break; } - super.applyCommand(Lkc, outputTarget); + super.applyCommand(Lkc, textStore); } } \ No newline at end of file diff --git a/web/src/app/browser/src/hardwareEventKeyboard.ts b/web/src/app/browser/src/hardwareEventKeyboard.ts index fde312baef..1ce8893744 100644 --- a/web/src/app/browser/src/hardwareEventKeyboard.ts +++ b/web/src/app/browser/src/hardwareEventKeyboard.ts @@ -4,8 +4,8 @@ import { ModifierKeyConstants } from '@keymanapp/common-types'; import { HardKeyboardBase, processForMnemonicsAndLegacy } from 'keyman/engine/main'; import { DomEventTracker } from 'keyman/engine/events'; -import { DesignIFrame, nestedInstanceOf } from 'keyman/engine/element-wrappers'; -import { eventOutputTarget, outputTargetForElement } from 'keyman/engine/attachment'; +import { DesignIFrameElementTextStore, nestedInstanceOf } from 'keyman/engine/element-text-stores'; +import { textStoreForEvent, textStoreForElement } from 'keyman/engine/attachment'; import ContextManager from './contextManager.js'; @@ -235,9 +235,9 @@ export default class HardwareEventKeyboard extends HardKeyboardBase { const eventTracker = this.domEventTracker; page.on('enabled', (Pelem) => { - const target = outputTargetForElement(Pelem); + const target = textStoreForElement(Pelem); - if(!(target instanceof DesignIFrame)) { + if(!(target instanceof DesignIFrameElementTextStore)) { // These need to be on the actual input element, as otherwise the keyboard will disappear on touch. eventTracker.attachDOMEvent(Pelem, 'keypress', this._KeyPress); eventTracker.attachDOMEvent(Pelem, 'keydown', this._KeyDown); @@ -251,9 +251,9 @@ export default class HardwareEventKeyboard extends HardKeyboardBase { }); page.on('disabled', (Pelem) => { - const target = outputTargetForElement(Pelem); + const target = textStoreForElement(Pelem); - if(!(target instanceof DesignIFrame)) { + if(!(target instanceof DesignIFrameElementTextStore)) { eventTracker.detachDOMEvent(Pelem, 'keypress', this._KeyPress); eventTracker.detachDOMEvent(Pelem, 'keydown', this._KeyDown); eventTracker.detachDOMEvent(Pelem, 'keyup', this._KeyUp); @@ -277,7 +277,7 @@ export default class HardwareEventKeyboard extends HardKeyboardBase { */ _KeyDown: (e: KeyboardEvent) => boolean = (e) => { const activeKeyboard = this.contextManager.activeKeyboard; - const target = eventOutputTarget(e); + const target = textStoreForEvent(e); if(!target || activeKeyboard == null) { return true; @@ -298,7 +298,7 @@ export default class HardwareEventKeyboard extends HardKeyboardBase { * Description Processes keypress event (does not pass data to keyboard) */ _KeyPress: (e: KeyboardEvent) => boolean = (e) => { - const target = eventOutputTarget(e); + const target = textStoreForEvent(e); if(!target || this.contextManager.activeKeyboard?.keyboard == null) { return true; } @@ -312,7 +312,7 @@ export default class HardwareEventKeyboard extends HardKeyboardBase { * Description Processes keyup event and passes event data to keyboard */ _KeyUp: (e: KeyboardEvent) => boolean = (e) => { - const target = eventOutputTarget(e); + const target = textStoreForEvent(e); const Levent = preprocessKeyboardEvent(e, this.processor, this.hardDevice); if(Levent == null || target == null) { return true; @@ -399,8 +399,8 @@ export default class HardwareEventKeyboard extends HardKeyboardBase { return true; } - const outputTarget = eventOutputTarget(e); - return this.processor.doModifierPress(Levent, outputTarget, false); + const textStore = textStoreForEvent(e); + return this.processor.doModifierPress(Levent, textStore, false); } private keyPress(e: KeyboardEvent): boolean { diff --git a/web/src/app/browser/src/keyboardInterface.ts b/web/src/app/browser/src/keyboardInterface.ts index 69b1bc7447..ded3a1d71c 100644 --- a/web/src/app/browser/src/keyboardInterface.ts +++ b/web/src/app/browser/src/keyboardInterface.ts @@ -1,4 +1,4 @@ -import { type OutputTargetElementWrapper } from 'keyman/engine/element-wrappers'; +import { type AbstractElementTextStore } from 'keyman/engine/element-text-stores'; import { FloatingOSKView } from 'keyman/engine/osk'; import { KeyboardInterfaceBase } from 'keyman/engine/main'; @@ -29,7 +29,7 @@ export class KeyboardInterface extends KeyboardInterfaceBase { /** * Legacy entry points (non-standard names)- included only to allow existing IME keyboards to continue to be used */ - getLastActiveElement(): OutputTargetElementWrapper { + getLastActiveElement(): AbstractElementTextStore { return this.engine.contextManager.lastActiveTarget; } diff --git a/web/src/app/browser/src/keymanEngine.ts b/web/src/app/browser/src/keymanEngine.ts index 237eaf7b4e..1479f9be79 100644 --- a/web/src/app/browser/src/keymanEngine.ts +++ b/web/src/app/browser/src/keymanEngine.ts @@ -1,7 +1,7 @@ import { KeymanWebKeyboard } from '@keymanapp/common-types'; import { KeymanEngineBase, DeviceDetector } from 'keyman/engine/main'; import { getAbsoluteY } from 'keyman/engine/dom-utils'; -import { OutputTargetElementWrapper } from 'keyman/engine/element-wrappers'; +import { AbstractElementTextStore } from 'keyman/engine/element-text-stores'; import { TwoStateActivator, VisualKeyboard @@ -20,7 +20,7 @@ import { PageIntegrationHandlers } from './context/pageIntegrationHandlers.js'; import { LanguageMenu } from './languageMenu.js'; import { setupOskListeners } from './oskConfiguration.js'; import { whenDocumentReady } from './utils/documentReady.js'; -import { outputTargetForElement } from 'keyman/engine/attachment'; +import { textStoreForElement } from 'keyman/engine/attachment'; import { UtilApiEndpoint} from './utilApiEndpoint.js'; import { UIModule } from './uiModuleInterface.js'; @@ -76,7 +76,7 @@ export class KeymanEngine extends KeymanEngineBase { - const e = (target as OutputTargetElementWrapper)?.getElement(); + const e = (target as AbstractElementTextStore)?.getElement(); if(this.osk) { (this.osk.activationModel as TwoStateActivator).activationTrigger = e; } @@ -302,7 +302,7 @@ export class KeymanEngine extends KeymanEngineBase void; -export class ContextHost extends Mock { +/** + * WebView-specific synthetic TextStore implementation that can + * communicate and synchronize with the host app despite not being + * backed by any sort of Web element. + */ +export class HostTextStore extends SyntheticTextStore { readonly oninserttext?: OnInsertTextFunc; - private savedState: Mock; + private savedState: SyntheticTextStore; constructor(oninserttext: OnInsertTextFunc) { super(); @@ -32,7 +35,7 @@ export class ContextHost extends Mock { if(transcription) { //TODO-web-core: shouldn't need cast in the future? - const preInput = transcription.preInput as Mock; + const preInput = transcription.preInput as SyntheticTextStore; // If our saved state matches the `preInput` from the incoming transcription, just reuse its transform. // Will generally not match during multitap operations, though. // @@ -57,18 +60,17 @@ export class ContextHost extends Mock { } saveState() { - this.savedState = Mock.from(this); + this.savedState = SyntheticTextStore.from(this); } - restoreTo(original: OutputTargetInterface): void { - this.savedState = Mock.from(this); - // TODO-web-core - super.restoreTo(original as OutputTargetBase); + restoreTo(original: TextStore): void { + this.savedState = SyntheticTextStore.from(this); + super.restoreTo(original); } updateContext(text: string, selStart: number, selEnd: number): boolean { let shouldResetContext = false; - const tempMock = new Mock(text, selStart ?? KMWString.length(text), selEnd ?? KMWString.length(text)); + const tempMock = new SyntheticTextStore(text, selStart ?? KMWString.length(text), selEnd ?? KMWString.length(text)); const newLeft = tempMock.getTextBeforeCaret(); const oldLeft = this.getTextBeforeCaret(); @@ -110,15 +112,15 @@ export class ContextHost extends Mock { // and we want a consistent interface for context synchronization between // host app + app/webview KMW. this.setSelection(KMWString.length(this.text)); - this.savedState = Mock.from(this); + this.savedState = SyntheticTextStore.from(this); } } export default class ContextManager extends ContextManagerBase { - // Change of context? Just replace the Mock. Context will be ENTIRELY controlled + // Change of context? Just replace the SyntheticTextStore. 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 _hostTextStore: HostTextStore; private _activeKeyboard: {keyboard: JSKeyboard, metadata: KeyboardStub}; @@ -127,20 +129,20 @@ export default class ContextManager extends ContextManagerBase { - (this.context as ContextHost).updateHost(ruleBehavior.transcription); + (this.context as HostTextStore).updateHost(ruleBehavior.transcription); } config.stubNamespacer = (stub) => { diff --git a/web/src/common/web-utils/build.sh b/web/src/common/web-utils/build.sh index 6c5da28e42..1106ace477 100755 --- a/web/src/common/web-utils/build.sh +++ b/web/src/common/web-utils/build.sh @@ -22,6 +22,7 @@ BUNDLE_CMD="node ${KEYMAN_ROOT}/web/src/tools/es-bundling/build/common-bundle.mj builder_describe \ "Compiles the web-oriented utility function module." \ "@/common/web/keyman-version" \ + "@/common/web/types" \ "@/web/src/tools/es-bundling" \ clean configure build test diff --git a/web/src/common/web-utils/src/index.ts b/web/src/common/web-utils/src/index.ts index 30f4821590..343e1486e6 100644 --- a/web/src/common/web-utils/src/index.ts +++ b/web/src/common/web-utils/src/index.ts @@ -23,6 +23,8 @@ export { default as TimeoutPromise, timedPromise } from "./timeoutPromise.js"; export { default as PriorityQueue, QueueComparator } from "./priority-queue.js" +export { isEmptyTransform } from './isEmptyTransform.js'; + // // Uncomment the following line and run the bundled output to verify successful // // esbuild bundling of this submodule: // console.log(Version.CURRENT.toString()); diff --git a/web/src/common/web-utils/src/isEmptyTransform.ts b/web/src/common/web-utils/src/isEmptyTransform.ts new file mode 100644 index 0000000000..82832d1fdd --- /dev/null +++ b/web/src/common/web-utils/src/isEmptyTransform.ts @@ -0,0 +1,21 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ + +import { LexicalModelTypes } from '@keymanapp/common-types'; + +// Also relies on string-extensions provided by the web-utils package. + +/** + * Determines whether a lexical model transform is empty (has no effect). + * + * @param transform - The lexical model transform to check. + * @returns True if the transform is empty (null, or has no insertions or deletions), + * false otherwise. + */ +export function isEmptyTransform(transform: LexicalModelTypes.Transform) { + if (!transform) { + return true; + } + return transform.insert === '' && transform.deleteLeft === 0 && (transform.deleteRight ?? 0) === 0; +} diff --git a/web/src/engine/src/element-wrappers/outputTargetElementWrapper.ts b/web/src/engine/element-text-stores/abstractElementTextStore.ts similarity index 89% rename from web/src/engine/src/element-wrappers/outputTargetElementWrapper.ts rename to web/src/engine/element-text-stores/abstractElementTextStore.ts index 4fd4771a60..e22d6588ae 100644 --- a/web/src/engine/src/element-wrappers/outputTargetElementWrapper.ts +++ b/web/src/engine/element-text-stores/abstractElementTextStore.ts @@ -1,7 +1,7 @@ -import { OutputTargetBase } from "keyman/engine/js-processor"; +import { TextStore } from "keyman/engine/keyboard"; import { EventEmitter } from 'eventemitter3'; -export abstract class OutputTargetElementWrapper extends OutputTargetBase { +export abstract class AbstractElementTextStore extends TextStore { // JS/TS can't do true multiple inheritance, so we maintain class events on a readonly field. public readonly events: EventEmitter = new EventEmitter(); diff --git a/web/src/engine/src/element-wrappers/contentEditable.ts b/web/src/engine/element-text-stores/contentEditableElementTextStore.ts similarity index 97% rename from web/src/engine/src/element-wrappers/contentEditable.ts rename to web/src/engine/element-text-stores/contentEditableElementTextStore.ts index e2ffbdd3e8..0a42117402 100644 --- a/web/src/engine/src/element-wrappers/contentEditable.ts +++ b/web/src/engine/element-text-stores/contentEditableElementTextStore.ts @@ -1,4 +1,4 @@ -import { OutputTargetElementWrapper } from './outputTargetElementWrapper.js'; +import { AbstractElementTextStore } from './abstractElementTextStore.js'; import { KMWString } from 'keyman/common/web-utils'; class SelectionCaret { @@ -21,7 +21,7 @@ class SelectionRange { } } -export class ContentEditable extends OutputTargetElementWrapper<{}> { +export class ContentEditableElementTextStore extends AbstractElementTextStore<{}> { root: HTMLElement; constructor(ele: HTMLElement) { diff --git a/web/src/engine/element-text-stores/createTextStoreForElement.ts b/web/src/engine/element-text-stores/createTextStoreForElement.ts new file mode 100644 index 0000000000..a591ae32f9 --- /dev/null +++ b/web/src/engine/element-text-stores/createTextStoreForElement.ts @@ -0,0 +1,42 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ + +import { type AbstractElementTextStore } from './abstractElementTextStore.js'; +import { InputElementTextStore } from './inputElementTextStore.js'; +import { TextAreaElementTextStore } from './textAreaElementTextStore.js'; +import { DesignIFrameElementTextStore } from './designIFrameElementTextStore.js'; +import { ContentEditableElementTextStore } from './contentEditableElementTextStore.js'; +import { nestedInstanceOf } from './utils.js'; + +/** + * Wraps an HTMLElement in a concrete text-store implementation. + * + * @param e - The HTMLElement to create a text-store for. + * @returns A concrete AbstractElementTextStore for the element, or null if the element + * type is not supported or no suitable store can be created. + */ +export function createTextStoreForElement(e: HTMLElement): AbstractElementTextStore { + // Complex type scoping is implemented here so that kmwutils.ts is not a dependency for test compilations. + + if(nestedInstanceOf(e, "HTMLInputElement")) { + return new InputElementTextStore( e); + } else if(nestedInstanceOf(e, "HTMLTextAreaElement")) { + return new TextAreaElementTextStore( e); + } else if(nestedInstanceOf(e, "HTMLIFrameElement")) { + const iframe = e; + + if(iframe.contentWindow && iframe.contentWindow.document && iframe.contentWindow.document.designMode == "on") { + return new DesignIFrameElementTextStore(iframe); + } else if (e.isContentEditable) { + // Do content-editable