diff --git a/common/predictive-text/unit_tests/in_browser/base.conf.cjs b/common/predictive-text/unit_tests/in_browser/base.conf.cjs index d7888b1160..6fa9de9f22 100644 --- a/common/predictive-text/unit_tests/in_browser/base.conf.cjs +++ b/common/predictive-text/unit_tests/in_browser/base.conf.cjs @@ -72,8 +72,8 @@ module.exports = { proxies: { "/resources/": "/base/common/test/resources/", - "/node_modules/": "/base/node_modules/", - "/@keymanapp/lm-worker/": "/base/node_modules/@keymanapp/lm-worker/" + // "/node_modules/": "/base/node_modules/", + // "/@keymanapp/lm-worker/": "/base/node_modules/@keymanapp/lm-worker/" }, // web server port diff --git a/web/package.json b/web/package.json index f400a17d77..08687f65d3 100644 --- a/web/package.json +++ b/web/package.json @@ -59,5 +59,6 @@ "@keymanapp/web-utils": "*", "@types/node": "^11.9.4", "eventemitter3": "^4.0.0" - } + }, + "type": "module" } 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..7c78fc09df --- /dev/null +++ b/web/src/engine/device-detect/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/device-detect/obj/kmwdevice.js', + }, + external: ['fs', 'vm'], + outdir: '../../../build/engine/device-detect/lib/', + outExtension: { '.js': '.mjs' }, + tsconfig: './tsconfig.json', + target: "es5" +}); diff --git a/web/src/engine/device-detect/kmwdevice.ts b/web/src/engine/device-detect/kmwdevice.ts index 7a596cd25b..a7fcdb049b 100644 --- a/web/src/engine/device-detect/kmwdevice.ts +++ b/web/src/engine/device-detect/kmwdevice.ts @@ -1,9 +1,9 @@ -import StyleConstants from 'utils/styleConstants.js'; +import StyleConstants from './utils/styleConstants.js'; import { DeviceSpec, Version } from "@keymanapp/web-utils/build/obj/index.js"; // The Device object definition ------------------------------------------------- -export class Device { +export default class Device { touchable: boolean; OS: string; formFactor: string; 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/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..eb215f69ca --- /dev/null +++ b/web/src/engine/element-wrappers/src/contentEditable.ts @@ -0,0 +1,267 @@ +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); + } + + 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..795d80d274 --- /dev/null +++ b/web/src/engine/element-wrappers/src/designIFrame.ts @@ -0,0 +1,352 @@ +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); + } + + 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..8bdbbeb4d9 --- /dev/null +++ b/web/src/engine/element-wrappers/src/input.ts @@ -0,0 +1,233 @@ +import OutputTarget, { BaseEventMap } from './outputTarget.js'; + +interface EventMap extends BaseEventMap { + /** + * 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); + } + + 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..1678b19e16 --- /dev/null +++ b/web/src/engine/element-wrappers/src/outputTarget.ts @@ -0,0 +1,62 @@ +import OutputTargetBase from "@keymanapp/keyboard-processor/build/obj/text/outputTarget.js"; +import EventEmitter from 'eventemitter3'; + +export interface BaseEventMap { + /** + * Meant to facilitate the following code that existed pre-modularization: + ``` + // This class has non-integrated unit tests in which the `singleton` object doesn't exist. + // Thus, we need to test for this case. + let keyman = com.keyman['singleton']; + + // Signal the necessary text changes to the embedding app, if it exists. + if(keyman && keyman['oninserttext'] && keyman.isEmbedded) { + keyman['oninserttext'](transform.deleteLeft, transform.insert, transform.deleteRight); + } + ``` + */ + 'oninserttext': (deleteLeft: number, insert: string, deleteRight: number) => void; +} + +export default abstract class OutputTarget extends OutputTargetBase { + // JS/TS can't do 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); + } + } + + apply(transform: Transform) { + super.apply(transform); + + // The TS compiler can't quite handle this typing scenario properly; the following cast + // allows us to work around its type inference limitations. + const baseEvents = (this.events as unknown as EventEmitter); + baseEvents.emit('oninserttext', transform.deleteLeft, transform.insert, transform.deleteRight); + } +} \ 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..f46eb2b85f --- /dev/null +++ b/web/src/engine/element-wrappers/src/textarea.ts @@ -0,0 +1,216 @@ +import OutputTarget, { BaseEventMap } from './outputTarget.js'; + +interface EventMap extends BaseEventMap { + /** + * 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); + } + + 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