mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-31 04:37:41 +00:00
Implements interface for design-mode IFrames, adds related testing.
This commit is contained in:
parent
104fe77f2c
commit
65fbf2fc61
8 changed files with 494 additions and 2 deletions
209
web/source/dom/designIFrame.ts
Normal file
209
web/source/dom/designIFrame.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
13
web/testing/attachment-api/editableFrame.html
Normal file
13
web/testing/attachment-api/editableFrame.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script>
|
||||
function setDesign() {
|
||||
document.designMode = 'on';
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body onload='setDesign()'>
|
||||
Edit me!
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
13
web/unit_tests/resources/html/editableFrame.html
Normal file
13
web/unit_tests/resources/html/editableFrame.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script>
|
||||
function setDesign() {
|
||||
document.designMode = 'on';
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body onload='setDesign()'>
|
||||
Edit me!
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue