diff --git a/web/history.md b/web/history.md index ccc0866ff7..8e4fddff2a 100644 --- a/web/history.md +++ b/web/history.md @@ -26,6 +26,7 @@ * Added automated testing for KeymanWeb builds. (#350) * Fixed bugs in the handling of deadkeys. (#281) * Change from ISO 639-3 language codes to BCP-47 language codes +* Now generates 'change' and 'input' events from keyboard and OSK input (#42) (#571) ## 2017-07-10 2.0.473 stable * 2.0 stable release build. diff --git a/web/source/build_recorder.sh b/web/source/build_recorder.sh old mode 100644 new mode 100755 diff --git a/web/source/kmwcallback.ts b/web/source/kmwcallback.ts index 167fe21441..1f22249161 100644 --- a/web/source/kmwcallback.ts +++ b/web/source/kmwcallback.ts @@ -823,6 +823,11 @@ class KeyboardInterface { if(dn >= 0) { this._DeadkeyAdjustPos(this._SelPos(Pelem), -dn + s._kmwLength()); // I3318,I3319 } + + if((dn >= 0 || s) && Pelem == DOMEventHandlers.states.activeElement) { + // Record that we've made an edit. + DOMEventHandlers.states.changed = true; + } return; } @@ -932,6 +937,11 @@ class KeyboardInterface { if(typeof(this.keymanweb.refreshElementContent) == 'function') { this.keymanweb.refreshElementContent(Pelem); } + + if((dn >= 0 || s) && Pelem == DOMEventHandlers.states.activeElement) { + // Record that we've made an edit. + DOMEventHandlers.states.changed = true; + } } /** @@ -1185,6 +1195,32 @@ class KeyboardInterface { } // I3318 - deadkey changes END + doInputEvent(_target: HTMLElement|Document) { + var event: Event; + // TypeScript doesn't yet recognize InputEvent as a type! + if(typeof window['InputEvent'] == 'function') { + event = new window['InputEvent']('input', {"bubbles": true, "cancelable": false}); + } // No else - there is no supported version in some browsers. + + // Ensure that touch-aliased elements fire as if from the aliased element. + if(_target['base'] && _target['base']['kmw_ip']) { + _target = _target['base']; + } + + if(_target && event) { + _target.dispatchEvent(event); + } + } + + defaultBackspace(Pelem?: HTMLElement|Document) { + if(!Pelem) { + Pelem = this.keymanweb.domManager.getLastActiveElement(); + } + + this.output(1, Pelem, ""); + this.doInputEvent(Pelem); + } + /** * Function processKeystroke * Scope Private @@ -1205,7 +1241,13 @@ class KeyboardInterface { this.keymanweb.util.activeDevice = device; // Calls the start-group of the active keyboard. - return this.keymanweb.keyboardManager.activeKeyboard['gs'](element, keystroke); + var matched = this.keymanweb.keyboardManager.activeKeyboard['gs'](element, keystroke); + + if(matched) { + this.doInputEvent(element); + } + + return matched; } /** diff --git a/web/source/kmwdom.ts b/web/source/kmwdom.ts index 8695aa07b4..0921e2c586 100644 --- a/web/source/kmwdom.ts +++ b/web/source/kmwdom.ts @@ -62,6 +62,10 @@ class DOMManager { if(this.attachmentObserver.disconnect) { this.attachmentObserver.disconnect(); } + + for(let input of this.inputList) { + this.disableInputElement(input); + } } /** diff --git a/web/source/kmwdomevents.ts b/web/source/kmwdomevents.ts index 00b9fab6a9..a59e6111dc 100644 --- a/web/source/kmwdomevents.ts +++ b/web/source/kmwdomevents.ts @@ -20,6 +20,8 @@ focusing: boolean; focusTimer: number; + changed: boolean; // Tracks if the element has been edited since gaining focus. + /* ----------------------- Static event-related methods ------------------------ */ setFocusTimer(): void { @@ -323,9 +325,31 @@ class DOMEventHandlers { this.keyman.osk._Hide(false); } + this.doChangeEvent(Ltarg); + return true; }.bind(this); + doChangeEvent(_target: HTMLElement|Document) { + if(DOMEventHandlers.states.changed) { + var event: Event; + if(typeof Event == 'function') { + event = new Event('change', {"bubbles": true, "cancelable": false}); + } else { // IE path + event = document.createEvent("HTMLEvents"); + event.initEvent('change', true, false); + } + + // Ensure that touch-aliased elements fire as if from the aliased element. + if(_target['base'] && _target['base']['kmw_ip']) { + _target = _target['base']; + } + _target.dispatchEvent(event); + } + + DOMEventHandlers.states.changed = false; + } + /** * Function doControlBlurred * Scope Private @@ -699,14 +723,14 @@ class DOMEventHandlers { // Support backspace in simulated input DIV from physical keyboard where not matched in rule I3363 (Build 301) if(Levent.Lcode == 8 && !LeventMatched && Levent.Ltarg.className != null && Levent.Ltarg.className.indexOf('keymanweb-input') >= 0) { - kbdInterface.output(1, DOMEventHandlers.states.lastActiveElement, ""); + this.keyman.interface.defaultBackspace(); } } else { // Mnemonic layout if(Levent.Lcode == 8) { // I1595 - Backspace for mnemonic DOMEventHandlers.states._KeyPressToSwallow = 1; if(!kbdInterface.processKeystroke(util.physicalDevice,Levent.Ltarg,Levent)) { - kbdInterface.output(1, DOMEventHandlers.states.lastActiveElement, ""); // I3363 (Build 301) + this.keyman.interface.defaultBackspace(); } return false; //added 16/3/13 to fix double backspace on mnemonic layouts on desktop } @@ -1353,8 +1377,10 @@ class DOMTouchHandlers extends DOMEventHandlers { // This works OK for iOS, but may need something else for other platforms if(('relatedTarget' in e) && e.relatedTarget) { var elem: HTMLElement = e.relatedTarget as HTMLElement; + this.doChangeEvent(elem); if(elem.nodeName != 'DIV' || elem.className.indexOf('keymanweb-input') == -1) { - this.cancelInput(); return; + this.cancelInput(); + return; } } diff --git a/web/source/kmwosk.ts b/web/source/kmwosk.ts index c37c44d71e..d51ef9cbfa 100644 --- a/web/source/kmwosk.ts +++ b/web/source/kmwosk.ts @@ -915,7 +915,7 @@ if(!window['keyman']['initialized']) { switch(code) { case osk.keyCodes['K_BKSP']: //Only desktop UI, not touch devices. TODO: add repeat while mouse down for desktop UI - kbdInterface.output(1, keymanweb.domManager.getLastActiveElement(), ""); + kbdInterface.defaultBackspace(); break; case osk.keyCodes['K_TAB']: keymanweb.domManager.moveToNext(keyShiftState); @@ -973,7 +973,7 @@ if(!window['keyman']['initialized']) { // // Failed to move right - there's nothing to delete. // break; // } - // kbdInterface.output(1, keymanweb.domManager.getLastActiveElement(), ""); + // kbdInterface.defaultBackspace(); // } } } diff --git a/web/unit_tests/cases/events.js b/web/unit_tests/cases/events.js new file mode 100644 index 0000000000..b92edf40d9 --- /dev/null +++ b/web/unit_tests/cases/events.js @@ -0,0 +1,166 @@ +var assert = chai.assert; + +describe('Event Management', function() { + + before(function(done) { + this.timeout(20000); + fixture.setBase('unit_tests/fixtures'); + fixture.load("eventTestConfig.html"); + + setupKMW(null, function() { + // We use this keyboard since we only need minimal input functionality for these tests. + // Smaller is better when dealing with net latency. + loadKeyboardFromJSON("/keyboards/test_simple_deadkeys.json", function() { + // Interestingly, when auto-testing there's a Safari bug that prevents + // this from being preserved after the first forced blur command below. + keyman.globalKeyboard = "Keyboard_test_simple_deadkeys"; + done(); + }, 10000); + }, 10000); + + }); + + after(function() { + teardownKMW(); + }); + + it('Keystroke-based onChange event generation', function(done) { + var simple_A = {"type":"key","key":"a","code":"KeyA","keyCode":65,"modifierSet":0,"location":0}; + var event = new KMWRecorder.PhysicalInputEvent(simple_A); + + var ele = document.getElementById("input"); + var aliasing = false; + + ele.onchange = function() { + ele.onchange = null; + done(); + } + + if(ele['kmw_ip']) { + ele = ele['kmw_ip']; + aliasing = true; + } + + // A bit of a force-hack to ensure the element is seen as active for the tests. + window['DOMEventHandlers'].states.lastActiveElement = ele; + window['DOMEventHandlers'].states.activeElement = ele; + + event.simulateEventOn(ele); + + var focusEvent; + + if(typeof FocusEvent == 'function') { + focusEvent = new FocusEvent('blur', {relatedTarget: ele}); + } else { + focusEvent = document.createEvent("FocusEvent"); + focusEvent.initFocusEvent("blur", true, false, ele.ownerDocument.defaultView, 0, ele); + } + + if(focusEvent) + ele.dispatchEvent(focusEvent); + }); + + it('OSK-based onChange event generation', function(done) { + var simple_A = {"type":"osk","keyID":"default-K_A"}; + var event = new KMWRecorder.OSKInputEvent(simple_A); + + var ele = document.getElementById("input"); + var aliasing = false; + + ele.onchange = function() { + ele.onchange = null; + done(); + } + + if(ele['kmw_ip']) { + ele = ele['kmw_ip']; + aliasing = true; + } + + // A bit of a force-hack to ensure the element is seen as active for the tests. + window['DOMEventHandlers'].states.lastActiveElement = ele; + window['DOMEventHandlers'].states.activeElement = ele; + + event.simulateEventOn(ele); + + var focusEvent; + + if(typeof FocusEvent == 'function') { + focusEvent = new FocusEvent('blur', {relatedTarget: ele}); + } else { + focusEvent = document.createEvent("FocusEvent"); + focusEvent.initFocusEvent("blur", true, false, ele.ownerDocument.defaultView, 0, ele); + } + + if(focusEvent) + ele.dispatchEvent(focusEvent); + }); + + it('Keystroke-based onInput event generation', function(done) { + // Not all browsers support InputEvent. Bypass the test for these. + if(typeof InputEvent != 'function') { + console.log("InputEvent not supported."); + done(); + return; + } + + var simple_A = {"type":"key","key":"a","code":"KeyA","keyCode":65,"modifierSet":0,"location":0}; + var event = new KMWRecorder.PhysicalInputEvent(simple_A); + + var ele = document.getElementById("input"); + var aliasing = false; + + var counterObj = {i:0}; + var fin = 3; + + ele.addEventListener("input", function() { + counterObj.i++; + if(counterObj.i == fin) { + done(); + } + }); + + if(ele['kmw_ip']) { + ele = ele['kmw_ip']; + aliasing = true; + } + + event.simulateEventOn(ele); + event.simulateEventOn(ele); + event.simulateEventOn(ele); + }); + + it('OSK-based onInput event generation', function(done) { + // Not all browsers support InputEvent. Bypass the test for these. + if(typeof InputEvent != 'function') { + console.log("InputEvent not supported."); + done(); + return; + } + + var simple_A = {"type":"osk","keyID":"default-K_A"}; + var event = new KMWRecorder.OSKInputEvent(simple_A); + + var ele = document.getElementById("input"); + var aliasing = false; + + var counterObj = {i:0}; + var fin = 3; + + ele.addEventListener("input", function() { + counterObj.i++; + if(counterObj.i == fin) { + done(); + } + }); + + if(ele['kmw_ip']) { + ele = ele['kmw_ip']; + aliasing = true; + } + + event.simulateEventOn(ele); + event.simulateEventOn(ele); + event.simulateEventOn(ele); + }); +}); \ No newline at end of file diff --git a/web/unit_tests/fixtures/eventTestConfig.html b/web/unit_tests/fixtures/eventTestConfig.html new file mode 100644 index 0000000000..b3a77bac09 --- /dev/null +++ b/web/unit_tests/fixtures/eventTestConfig.html @@ -0,0 +1,2 @@ + + \ No newline at end of file