From 65fbf2fc6166fdc1f8a83631025d0686cd9653aa Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 18 Jan 2019 13:59:26 +0700 Subject: [PATCH] Implements interface for design-mode IFrames, adds related testing. --- web/source/dom/designIFrame.ts | 209 +++++++++++++++++ web/source/dom/editableElement.ts | 2 + web/testing/attachment-api/editableFrame.html | 13 ++ web/testing/attachment-api/index.html | 5 + web/testing/attachment-api/utilities.js | 21 ++ web/unit_tests/cases/element_interfaces.js | 214 +++++++++++++++++- .../resources/html/editableFrame.html | 13 ++ web/unit_tests/test_utils.js | 19 ++ 8 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 web/source/dom/designIFrame.ts create mode 100644 web/testing/attachment-api/editableFrame.html create mode 100644 web/unit_tests/resources/html/editableFrame.html diff --git a/web/source/dom/designIFrame.ts b/web/source/dom/designIFrame.ts new file mode 100644 index 0000000000..148ef78aa6 --- /dev/null +++ b/web/source/dom/designIFrame.ts @@ -0,0 +1,209 @@ +namespace com.keyman.dom { + 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 class DesignIFrame implements EditableElement { + root: HTMLIFrameElement; + doc: Document; + docRoot: HTMLElement; + + constructor(ele: HTMLIFrameElement) { + 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!"; + } + } + + getElement(): HTMLIFrameElement { + return this.root; + } + + hasSelection(): boolean { + let Lsel = this.doc.getSelection(); + + // Keeping this around for design-mode IFrames due to the parallel with content-editable elements. + var ie11ParentChild = function(parent, child) { + // It's explicitly a text node bug. + if(child.nodeType != 3) { + return null; + } + let code = child.compareDocumentPosition(parent); + + return (code & 8) != 0; // Yep. Text node contains its root. + } + + if(this.docRoot != Lsel.anchorNode && !this.docRoot.contains(Lsel.anchorNode) && !ie11ParentChild(this.docRoot, Lsel.anchorNode)) { + return false; + } + + if(this.docRoot != Lsel.focusNode && !this.docRoot.contains(Lsel.focusNode) && !ie11ParentChild(this.docRoot, Lsel.anchorNode)) { + return false; + } + + 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); + } + } + } + + 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()) { + return; + } + + let start = this.getCarets().start; + + if(start.node.nodeType != 3) { + if(dn > 0) { + 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); + + 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; + } + + // While Selection.extend() is really nice for this, IE doesn't support it whatsoever. + // However, IE (11, at least) DOES support setting selections via ranges, so we can still + // manage the caret properly. + 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, s.length); + range.collapse(true); + range.insertNode(n); + } + + 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(); + } + } +} \ No newline at end of file diff --git a/web/source/dom/editableElement.ts b/web/source/dom/editableElement.ts index c6796a6ffa..b7f718fea8 100644 --- a/web/source/dom/editableElement.ts +++ b/web/source/dom/editableElement.ts @@ -6,6 +6,8 @@ /// // Defines a basic content-editable wrapper. /// +// Defines a basic design-mode IFrame wrapper. +/// // Makes TS aware of Window-based type prototypes /// diff --git a/web/testing/attachment-api/editableFrame.html b/web/testing/attachment-api/editableFrame.html new file mode 100644 index 0000000000..604434258e --- /dev/null +++ b/web/testing/attachment-api/editableFrame.html @@ -0,0 +1,13 @@ + + + + + + + Edit me! + + \ No newline at end of file diff --git a/web/testing/attachment-api/index.html b/web/testing/attachment-api/index.html index 6cfd1f75ae..d4bfe758d2 100644 --- a/web/testing/attachment-api/index.html +++ b/web/testing/attachment-api/index.html @@ -84,6 +84,7 @@ +
@@ -100,6 +101,10 @@

Note: The iframe section should not actually attach/enable for touch devices and does not support setKeyboardForControl.

+

Design-mode IFrames:

+

Note: The iframe section should not actually attach/enable for touch devices + and does not support setKeyboardForControl.

+

contenteditable Elements:

Note: This section should not actually attach/enable for touch devices.

diff --git a/web/testing/attachment-api/utilities.js b/web/testing/attachment-api/utilities.js index f95d056b4c..e7bc28fd0d 100644 --- a/web/testing/attachment-api/utilities.js +++ b/web/testing/attachment-api/utilities.js @@ -180,6 +180,27 @@ function addIFrame() { masterDiv.appendChild(newDiv); } +function addDesignIFrame() { + var masterDiv = document.getElementById('DynamicDesignFrames'); + var frame = document.createElement("iframe"); + var i = inputCounter++; + + frame.height = "100"; + frame.id = 'designIFrame' + i; + frame.src = "editableFrame.html"; + + // var doc = frame.contentDocument; + // doc.designMode = "on"; + + frame.onload = function() { + keyman.attachToControl(frame); + } + + var newDiv = generateDiagnosticDiv(frame); + masterDiv.appendChild(newDiv); + return frame.id; +} + function addEditable() { var masterDiv = document.getElementById('DynamicEditables'); var editable = document.createElement("div"); diff --git a/web/unit_tests/cases/element_interfaces.js b/web/unit_tests/cases/element_interfaces.js index 2cf645d67b..94ca85d72f 100644 --- a/web/unit_tests/cases/element_interfaces.js +++ b/web/unit_tests/cases/element_interfaces.js @@ -112,7 +112,7 @@ if(typeof InterfaceTests == 'undefined') { // These functions simply make the basic (within a single text node) tests // compatible with the more advanced element types; more complex tests may - // be in order. + // be in order. They can probably be shared with design-mode IFrames. InterfaceTests.ContentEditable = {}; InterfaceTests.ContentEditable.setupElement = function() { @@ -123,6 +123,14 @@ if(typeof InterfaceTests == 'undefined') { return {elem: elem, wrapper: wrapper, node: null}; } + InterfaceTests.ContentEditable.setupDummyElement = function() { + var id = DynamicElements.addEditable(); + var elem = document.getElementById(id); + var wrapper = new com.keyman.dom.ContentEditable(elem); + + return {elem: elem, wrapper: wrapper, node: null}; + } + InterfaceTests.ContentEditable.resetWithText = function(pair, string) { this.setText(pair, string); this.setSelectionRange(pair, 0, 0); @@ -197,6 +205,114 @@ if(typeof InterfaceTests == 'undefined') { } //#endregion + //#region Defines helpers related to design-mode IFrame test setup. + + // These functions simply make the basic (within a single text node) tests + // compatible with the more advanced element types; more complex tests may + // be in order. They can probably be shared with ContentEditables. + InterfaceTests.DesignIFrame = {}; + + InterfaceTests.DesignIFrame.InitAsyncElements = function(done) { + // DynamicElements.addDesignIFrame takes an async callback + // triggered upon the IFrame's load. + var obj = this; + + var id1 = DynamicElements.addDesignIFrame(function() { + var id2 = DynamicElements.addDesignIFrame(function() { + elem1 = document.getElementById(id1); + elem2 = document.getElementById(id2); + + obj.mainPair = {elem: elem1, wrapper: new com.keyman.dom.DesignIFrame(elem1), document: elem1.contentWindow.document}; + obj.dummyPair = {elem: elem2, wrapper: new com.keyman.dom.DesignIFrame(elem2), document: elem1.contentWindow.document}; + + done(); + }); + }); + } + + InterfaceTests.DesignIFrame.setupElement = function() { + return this.mainPair; + } + + InterfaceTests.DesignIFrame.setupDummyElement = function() { + return this.dummyPair; + } + + InterfaceTests.DesignIFrame.resetWithText = function(pair, string) { + this.setText(pair, string); + this.setSelectionRange(pair, 0, 0); + } + + // Implemented for completeness and generality with other tests. + InterfaceTests.DesignIFrame.setCaret = function(pair, index) { + this.setSelectionRange(pair, index, index); + } + + // Implemented for completeness and generality with other tests. + InterfaceTests.DesignIFrame.getCaret = function(pair) { + var sel = pair.document.getSelection(); + + if(sel.focusNode.compareDocumentPosition(pair.elem) == 16) { // Contained by + return sel.focusOffset; + } else { + console.warn("Selection during test is in unexpected configuration!"); + } + } + + InterfaceTests.DesignIFrame.setSelectionRange = function(pair, start, end) { + var device = new com.keyman.Device(); + device.detect(); + + var node = pair.document.documentElement.childNodes[0]; + var sel = pair.document.getSelection(); + var range; + + var setIESelection = function(node, sel, start, end) { + if(start > end) { + // The Range API doesn't allow 'backward' configurations. + var temp = end; + end = start; + start = temp; + } + + range = pair.document.createRange(); + range.setStart(node, start); + range.setEnd(node, end); + + sel.removeAllRanges(); + sel.addRange(range); + } + + if(node.nodeType == 3) { + if(device.browser == 'ie') { + setIESelection(node, sel, start, end); + } else { + sel.removeAllRanges(); + // Does not work on IE! + try { + sel.setPosition(node, start); + sel.extend(node, end); + } catch (e) { + // Sometimes fails in Firefox during CI. Not sure why. + console.warn("Error occurred while setting Selection via setPosition/extend: " + e.toString()); + setIESelection(node, sel, start, end); + } + } + } else { + console.warn("Problem detected when setting up a selection range!"); + range = pair.document.createRange(); + range.setStart(node, start); + range.setEnd(node, start); + sel.removeAllRanges(); + sel.addRange(range); + } + } + + InterfaceTests.DesignIFrame.setText = function(pair, text) { + pair.document.documentElement.innerText = text; + } + //#endregion + //#region Defines common test patterns across element tests InterfaceTests.Tests = {}; @@ -710,7 +826,7 @@ if(typeof InterfaceTests == 'undefined') { InterfaceTests.Tests.getSelectionUnowned = function(testObj) { var Apple = InterfaceTests.Strings.Apple; var pair = testObj.setupElement(); - var dummy = testObj.setupElement(); + var dummy = testObj.setupDummyElement(); // All we need is some basic sample text to get started. testObj.resetWithText(pair, Apple.mixed); @@ -1008,4 +1124,98 @@ describe('Element Input/Output Interfacing', function() { }); }); }); + + /** + * TODO: Design and implement some 'complex', cross-doc selection tests if possible. + */ + describe('Wrapper: Design-Mode IFrames', function() { + // We're asynchronously loading IFrames, and sequentially at that. + // We'll need a larger timeout. + this.timeout(kmwconfig.timeouts.scriptLoad); + + beforeEach(function(done) { + // Per-test creation of reg. pair and dummy elements, since IFrames are async. + // Relies on the main-level's renewal of the overall fixture to be processed first. + InterfaceTests.DesignIFrame.InitAsyncElements(done); + }); + + /** + * Sadly, JavaScript appears to disallow programmatically setting an outer document's selection + * to Nodes inside an iframe's document, which would be needed to properly emulate actual use-case + * selections we'd want to test with these methods. :( + */ + // describe.skip('Caret Handling', function() { + // describe('hasSelection', function() { + // it('correctly recognizes Selection ownership', function () { + // InterfaceTests.Tests.getSelectionOwned(InterfaceTests.DesignIFrame); + // }); + + // it('correctly rejects lack of Selection ownership', function () { + // InterfaceTests.Tests.getSelectionUnowned(InterfaceTests.DesignIFrame); + // }); + + // // Need to design a test (and necessary helpers!) for a 'partial ownership rejection' test. + // }); + // }); + + describe('Text Retrieval', function(){ + describe('getText', function() { + it('correctly returns text (no active selection)', function() { + InterfaceTests.Tests.getTextNoSelection(InterfaceTests.DesignIFrame); + }); + + it('correctly returns text (with active selection)', function() { + InterfaceTests.Tests.getTextWithSelection(InterfaceTests.DesignIFrame); + }); + }); + + describe('getTextBeforeCaret', function() { + it('correctly returns text (no active selection)', function() { + InterfaceTests.Tests.getTextBeforeCaretNoSelection(InterfaceTests.DesignIFrame); + }); + + it('correctly returns text (with active selection)', function() { + InterfaceTests.Tests.getTextBeforeCaretWithSelection(InterfaceTests.DesignIFrame); + }); + }); + + describe('getTextAfterCaret', function() { + it('correctly returns text (no active selection)', function() { + InterfaceTests.Tests.getTextAfterCaretNoSelection(InterfaceTests.DesignIFrame); + }); + + it('correctly returns text (with active selection)', function() { + InterfaceTests.Tests.getTextAfterCaretWithSelection(InterfaceTests.DesignIFrame); + }); + }); + }); + + describe('Text Mutation', function() { + describe('clearSelection', function() { + it('properly deletes selected text', function() { + InterfaceTests.Tests.clearSelection(InterfaceTests.DesignIFrame); + }); + }); + + describe('deleteCharsBeforeCaret', function() { + it("correctly deletes characters from 'context' (no active selection)", function() { + InterfaceTests.Tests.deleteCharsBeforeCaretNoSelection(InterfaceTests.DesignIFrame); + }); + + it("correctly deletes characters from 'context' (with active selection)", function() { + InterfaceTests.Tests.deleteCharsBeforeCaretWithSelection(InterfaceTests.DesignIFrame); + }); + }); + + describe('insertTextBeforeCaret', function() { + it("correctly replaces the element's 'context' (no active selection)", function() { + InterfaceTests.Tests.insertTextBeforeCaretNoSelection(InterfaceTests.DesignIFrame); + }); + + it("correctly replaces the element's 'context' (with active selection)", function() { + InterfaceTests.Tests.insertTextBeforeCaretWithSelection(InterfaceTests.DesignIFrame); + }); + }); + }); + }); }); \ No newline at end of file diff --git a/web/unit_tests/resources/html/editableFrame.html b/web/unit_tests/resources/html/editableFrame.html new file mode 100644 index 0000000000..604434258e --- /dev/null +++ b/web/unit_tests/resources/html/editableFrame.html @@ -0,0 +1,13 @@ + + + + + + + Edit me! + + \ No newline at end of file diff --git a/web/unit_tests/test_utils.js b/web/unit_tests/test_utils.js index 7d5ca2ffbf..800b96891d 100644 --- a/web/unit_tests/test_utils.js +++ b/web/unit_tests/test_utils.js @@ -333,6 +333,25 @@ if(typeof(DynamicElements) == 'undefined') { masterDiv.appendChild(frame); return frame.id; } + + DynamicElements.addDesignIFrame = function(loadCallback) { + var masterDiv = document.getElementById('DynamicElements'); + var frame = document.createElement("iframe"); + var i = inputCounter++; + + frame.height = "100"; + frame.id = 'designIFrame' + i; + frame.src = "resources/html/editableFrame.html"; + + if(loadCallback) { + frame.addEventListener('load', function() { + loadCallback(); + }); + } + + masterDiv.appendChild(frame); + return frame.id; + } DynamicElements.addEditable = function() { var masterDiv = document.getElementById('DynamicElements');