Implements interface for design-mode IFrames, adds related testing.

This commit is contained in:
Joshua A. Horton 2019-01-18 13:59:26 +07:00
parent 104fe77f2c
commit 65fbf2fc61
8 changed files with 494 additions and 2 deletions

View file

@ -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 = <Text> 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();
}
}
}

View file

@ -6,6 +6,8 @@
///<reference path="textarea.ts" />
// Defines a basic content-editable wrapper.
///<reference path="contentEditable.ts" />
// Defines a basic design-mode IFrame wrapper.
///<reference path="designIFrame.ts" />
// Makes TS aware of Window-based type prototypes
///<reference path="../kmwexthtml.ts" />

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<script>
function setDesign() {
document.designMode = 'on';
}
</script>
</head>
<body onload='setDesign()'>
Edit me!
</body>
</html>

View file

@ -84,6 +84,7 @@
<input type='button' id='btnInput' onclick='addInput();' value='Create Inputs.' />
<input type="button" id='btnText' onClick='addText();' value='Create Textarea.' />
<input type='button' id='btnIFrame' onclick='addIFrame();' value='Create IFrame.' />
<input type='button' id='btnDesignIFrame' onclick='addDesignIFrame();' value='Create design-mode IFrame.' />
<input type='button' id='btnEditable' onclick='addEditable();' value='Create editable DIV.' />
<hr/>
<div>
@ -100,6 +101,10 @@
<p><em>Note:</em> The iframe section should not actually attach/enable for touch devices
and does not support <code>setKeyboardForControl</code>.</p>
</div>
<div id='DynamicDesignFrames'><h3>Design-mode IFrames:</h3>
<p><em>Note:</em> The iframe section should not actually attach/enable for touch devices
and does not support <code>setKeyboardForControl</code>.</p>
</div>
<div id='DynamicEditables'><h3><code>contenteditable</code> Elements:</h3>
<p><em>Note:</em> This section should not actually attach/enable for touch devices.</p>
</div>

View file

@ -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");

View file

@ -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);
});
});
});
});
});

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<script>
function setDesign() {
document.designMode = 'on';
}
</script>
</head>
<body onload='setDesign()'>
Edit me!
</body>
</html>

View file

@ -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');