From 396cf3935fb2da155a0f5a80ed5a852dbf91b5ff Mon Sep 17 00:00:00 2001 From: Daniel Danilatos Date: Thu, 12 Feb 2015 14:32:14 +1100 Subject: [PATCH 1/2] range iterator --- src/range.js | 154 +++++++++++++++++++++++++++++++++++++++++++++- src/range.spec.js | 121 ++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 src/range.spec.js diff --git a/src/range.js b/src/range.js index ef2db25..f4d4e33 100644 --- a/src/range.js +++ b/src/range.js @@ -1,4 +1,9 @@ +'use strict'; + +var util = require('./util'); var Point = require('./point'); +var assert = util.assert; + module.exports = Range; Range.collapsed = function(point) { @@ -17,8 +22,11 @@ Range.prototype.isCollapsed = function() { return this.anchor.compare(this.focus) === 0; }; +/** + * Deep-copies the range (copies the underlying points too) + */ Range.prototype.copy = function() { - return new Range(this.anchor, this.focus); + return new Range(this.anchor.copy(), this.focus.copy()); }; /** @@ -51,3 +59,147 @@ Range.prototype.order = function() { return this; }; + +Range.prototype.outwardNormalized = function() { + return (this.isOrdered() + ? new Range(this.anchor.leftNormalized(), this.focus.rightNormalized()) + : new Range(this.focus.leftNormalized(), this.anchor.rightNormalized()) + ); +}; + +Range.prototype.isEquivalentTo = function(other) { + return this.anchor.isEquivalentTo(other.anchor) && + this.anchor.isEquivalentTo(other.anchor); +}; + +Range.prototype.isUnorderedEquivalentTo = function(other) { + return this.getStart().isEquivalentTo(other.getStart()) && + this.getEnd().isEquivalentTo(other.getEnd()); +}; + +/** + * Returns a left-to-right iterator (regardless of range's ordering). + */ +Range.prototype.iterateRight = function() { + return new RightIterator(this.getStart(), this.getEnd()); +}; + + +var RightIterator = function(start, end) { + this.start = start.leftNormalized(); + this.end = end.rightNormalized(); + + this.point = start.rightNormalized(); +}; + +RightIterator.prototype.isAtEnd = function() { + var cmp = this.point.compare(this.end); + assert(cmp <= 0); + + return cmp === 0; +}; + +RightIterator.prototype.skipText = function() { + if (this.isAtEnd()) { + return null; + } + + var original = this.point.leftNormalized(); + var totalChars = 0; + while (!this.isAtEnd()) { + if (!this.point.hasTextAfter()) { + break; + } + + // start offset within the text node we are entirely or partially skipping. + var startOffset; + var textNode = this.point.nodeAfter(); + + if (textNode) { + startOffset = 0; + + // assume no comments, etc. + // will need to handle them if encountered, + // probably by skipping over but not updating count. + + assert(textNode.nodeType === 3); + } else { + startOffset = this.point.offset; + textNode = this.point.node; + } + + assert(typeof startOffset === 'number'); + + // end offset within the text node we are entirely or partially skipping. + var endOffset; + var endIsInSameNode = (this.end.type === Point.types.TEXT + && this.end.node === textNode); + + endOffset = endIsInSameNode ? this.end.offset : textNode.length; + + + assert(endOffset >= startOffset); + + totalChars += endOffset - startOffset; + + if (endOffset === textNode.length) { + this.point.moveToAfter(textNode); + } else { + this.point.moveToText(textNode, endOffset); + assert(this.isAtEnd()); + } + } + + if (totalChars === 0) { + return null; + } + + return new FlatTextRange(original, this.point.rightNormalized(), totalChars); +}; + +RightIterator.prototype.enterElement = function() { + if (this.isAtEnd()) { + return null; + } + + var node = this.point.nodeAfter(); + if (node && node.nodeType === 1) { + this.point.moveToStart(node); + return node; + } + + return null; +}; + +RightIterator.prototype.leaveElement = function() { + if (this.isAtEnd()) { + return null; + } + + var container = this.point.containingElement(); + assert(container); // not expecting to leave the dom, end point should have been well defined. + + var after = Point.after(container); + if (after.compare(this.end) > 0) { + return null; + } + + this.point = after; + + return container; +}; + +var FlatTextRange = function(start, end, length) { + assert(length > 0); + this.start = start; + this.end = end; + this.length = length; +}; + +FlatTextRange.prototype.wrap = function(elem) { + this.start = this.start.ensureInsertable(this.start); + this.end = this.end.ensureInsertable(this.end); + + assert(false); // todo +}; + diff --git a/src/range.spec.js b/src/range.spec.js new file mode 100644 index 0000000..265b529 --- /dev/null +++ b/src/range.spec.js @@ -0,0 +1,121 @@ +var Point = require('./point'); +var Range = require('./point'); +var tutil = require('./test-util'); + +var dom = tutil.dom; +var domrange = tutil.domrange; + +var promised = tutil.promised; + +describe('Range Iterator', function() { + it('does not alter the range', promised(function() { + return tutil.rangeCases([ + '|', + '[abc]', + '

[

]', + '[

]

' + ], function(elem, range) { + var copy = range.copy(); + + var it = range.iterateRight(); + + it.skipText(); + it.leaveElement(); + it.enterElement(); + + expect(range.isEquivalentTo(copy)).toBe(true); + }); + })); + + it('should terminate immediately when collapsed', promised(function() { + return tutil.rangeCases([ + '|', + '

|

', + '

a|

', + '

|b

', + '

a|b

', + ], function(elem, range) { + expect(range.iterateRight().isAtEnd()).toBe(true); + }); + })); + + it('should not be at end when not collapsed', promised(function() { + return tutil.rangeCases([ + '[a]', + '

[

]', + '[

]

', + '[

x]

', + ], function(elem, range) { + expect(range.iterateRight().isAtEnd()).toBe(false); + }); + })); + + it('should skip text to the end', promised(function() { + return tutil.rangeCases([ + '[ab]', + '[ab]', + '[ab]', + ], function(elem, range) { + // so we can safely munge up the text nodes + range = range.outwardNormalized(); + + var it = range.iterateRight(); + expect(it.skipText()).not.toBe(null); + expect(it.isAtEnd()).toBe(true); + + range.getStart().nodeAfter().splitText(1) + + // check it skips over multiple text nodes + var it = range.iterateRight(); + expect(it.skipText()).not.toBe(null); + expect(it.isAtEnd()).toBe(true); + }); + })); + + it('should skip text to the end 2', promised(function() { + return tutil.rangeCases([ + '[ab]xy', + 'xy[ab]', + 'wx[ab]yz', + ], function(elem, range) { + // so we can safely munge up the text nodes + range = range.outwardNormalized(); + + var it = range.iterateRight(); + expect(it.skipText()).not.toBe(null); + expect(it.isAtEnd()).toBe(true); + + }); + })); + + it('should skip text', promised(function() { + return tutil.rangeCases([ + 'xy[abcd]', + ], function(elem, range) { + // so we can safely munge up the text nodes + range = range.outwardNormalized(); + + var it = range.iterateRight(); + expect(it.skipText().length).toBe(2); + expect(it.point.nodeAfter()).toBe(elem.lastChild); + expect(it.isAtEnd()).toBe(false); + + }); + })); + + + // todo: lots more tests. + + +}); + +function expectBefore(point1, point2) { + expect(point1.compare(point2)).toBeLessThan(0); +} +function expectAfter(point1, point2) { + expect(point1.compare(point2)).toBeGreaterThan(0); +} +function expectEquivalent(point1, point2) { + expect(point1.compare(point2)).toBe(0); +} + From 744beb7c97d880aca6b5f7071a0b0f72c533f05f Mon Sep 17 00:00:00 2001 From: Daniel Danilatos Date: Thu, 12 Feb 2015 14:32:24 +1100 Subject: [PATCH 2/2] inline decorator range attribute parsing --- src/client.js | 4 +- src/event-router.js | 2 + src/inline-decorator.js | 156 +++++++++++++++++++++++++++++++++++ src/inline-decorator.spec.js | 76 +++++++++++++++++ src/package.json | 3 +- 5 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 src/inline-decorator.js create mode 100644 src/inline-decorator.spec.js diff --git a/src/client.js b/src/client.js index 91c53f1..2bf8a49 100644 --- a/src/client.js +++ b/src/client.js @@ -3,7 +3,9 @@ window.qed = { Editor: require('./editor'), Point: require('./point'), Range: require('./range'), - Toolbar: require('./toolbar') + Toolbar: require('./toolbar'), + + InlineDecorator: require('./inline-decorator') // TODO: Just export all the classes so people can put them together // however they like. diff --git a/src/event-router.js b/src/event-router.js index 029479d..f746825 100644 --- a/src/event-router.js +++ b/src/event-router.js @@ -294,6 +294,8 @@ var EventRouter = module.exports = function EventRouter(getRootElem, registry, s } }; +// TODO: possibly replace bubble utility functions with range iterators + /** * Alters the given point such that: * - if there is a contextual element to apply a directional (left/right) diff --git a/src/inline-decorator.js b/src/inline-decorator.js new file mode 100644 index 0000000..4b12619 --- /dev/null +++ b/src/inline-decorator.js @@ -0,0 +1,156 @@ +'use strict'; + +var util = require('./util'); +var assert = util.assert; + +/** + * Inline styles & attributes utility + * + * wnd - optional arg to override window for testing + */ +module.exports = function InlineDecorator(wnd) { + if (!wnd) { + wnd = window; + } + + var me = this; + + // TODO: Generalise the Registry class for use here. + // Decide if we want to allow extensible attribute definitions, + // or extensible element types, or both (kind of an m x n problem). + // For now, hard coding the behaviour. + // Note: possible way is to abolish any querying - inline attributes + // are sourced from the model only (can have some standard extraction + // logic for pasting of arbitrary rich text) - and rendering is + // registered with the attribute (cf annotation painting in wave editor). + var attrParsers = { + }; + + /** + * Returns a map of attrName -> [list of values] that apply over a given range + * in the document. + */ + me.getRangeAttributes = function(range) { + assert(range && range.anchor && range.focus); + + assert(util.isEditable(range.anchor.containingElement())); + + var attrs = {}; + for (var k in supportedAttributes) { + attrs[k] = []; + } + + // Special case - if our range is collapsed, + // then we treat the + if (range.isCollapsed()) { + accumulate(range.getStart().containingElement()); + return attrs; + } + + var it = range.iterateRight(); + + // Here, we accumulate styles over all selectable content. + // That is, styles for text, and certain element boundaries + // like inter-paragraph newlines. + while (true) { + var el = it.enterElement(); + if (el) { + if (isIgnored(el)) { + // jump back out to skip entire element contents. + el = it.leaveElement(); + + assert(el); // otherwise end is inside an ignored widget or something?? + } else if (elementHasSelectableBoundary(el)) { + accumulate(el); + } + + continue; + } + + var el = it.leaveElement(); + if (el) { + continue; + } + + + var txt = it.skipText(); + if (txt) { + accumulate(txt.start.containingElement()); + continue; + } + + assert(it.isAtEnd()); + + return attrs; + } + + + function accumulate(elem) { + var computed = hackComputedStyle(elem, wnd); + + var len = supportedAttributes.length; + for (var attr in supportedAttributes) { + var val = computed[attr]; + if (val && attrs[attr].indexOf(val) < 0) { + attrs[attr].push(val); + } + } + } + + function hackComputedStyle(elem) { + var computed = util.computedStyle(elem, wnd); + + // HACK(dan): jsdom doesn't seem to implement this properly for testing afaik + // quick hack, good enough for cases tested. + if (!computed['font-weight']) { + computed = util.shallowCopy(supportedAttributes); + if (util.isElemType(elem, 'i')) { + computed['font-style'] = 'italic'; + } + if (util.isElemType(elem, 'b')) { + computed['font-weight'] = 'bold'; + } + } + + return computed; + } + }; + + // todo: links + var supportedAttributes = { // defaults (unused for now?) + 'font-weight' : 'normal', + 'font-style' : 'normal', + 'text-decoration' : 'none', + 'color' : 'inherit' + }; + + this.getDefaults = function() { + return util.shallowCopy(supportedAttributes); + }; + + /** + * Returns true if the element has a selectable boundary. + * E.g. the conceptual newline betwen paragraphs. + * + * An inline styling span does not have a selectable boundary. + */ + function elementHasSelectableBoundary(el) { + return util.isBlock(el, wnd); + } + + function isIgnored(el) { + // Probably some kind of fancy nested widget. + // Treat it as a style-inert black box. + if (!util.isEditable(el)) { + return true; + } + + // Ignore BRs, don't let their state mess with the logic. + if (util.isElemType(el, 'br')) { + return true; + } + + return false; + } + +}; diff --git a/src/inline-decorator.spec.js b/src/inline-decorator.spec.js new file mode 100644 index 0000000..ecb56c8 --- /dev/null +++ b/src/inline-decorator.spec.js @@ -0,0 +1,76 @@ +var _ = require('lodash'); +var util = require('./util'); +var assert = util.assert; +var InlineDecorator = require('./inline-decorator'); +var tutil = require('./test-util'); +var Point = require('./point'); +var Q = require('q'); + +describe('InlineDecorator', function() { + var testCases = [ + ['|', {}], + ['|a', {}], + ['a|', {}], + ['[a]', {}], + + ['|', {'font-weight':['bold']}], + ['|a', {'font-weight':['bold']}], + ['a|', {'font-weight':['bold']}], + ['[a]', {'font-weight':['bold']}], + + ['

a|bcsup

hellosup

', {}], + ['

abc|sup

hellosup

', {'font-style':['italic']}], + ['

abcsup

hello|sup

', {'font-weight':['bold']}], + ['

ab[cs]up

hellosup

', {'font-style':['italic','normal']}], + ['

|

', {}] + + ]; + + it('getRangeAttributes', tutil.promised(function() { + return tutil.rangeCases(testCases, function(elem, range, expected) { + + // So decorator thinks it's in an editor + elem.contentEditable = 'true'; + + assert(elem.$wnd); + + var idec = new InlineDecorator(elem.$wnd); + + var fullExpected = expandWithDefaults(expected, idec.getDefaults()); + var attrs = idec.getRangeAttributes(range); + + sortArrays(attrs); + sortArrays(fullExpected); + + expect(attrs).toEqual(fullExpected); + }, function wrap(testContent) { + return '
' + testContent + '
'; + }); + })); + +}); + +function sortArrays(mapOfArrays) { + for (var k in mapOfArrays) { + mapOfArrays[k].sort(); + } +} + +/** + * Expands the given attrs to encompass the full set of keys with at least + * one value for each key, given the defaults. + */ +function expandWithDefaults(attrs, defaults) { + + var filled = _.assign({}, attrs); + for (var k in defaults) { + if (!filled[k]) { + filled[k] = []; + } + if (filled[k].length === 0) { + filled[k].push(defaults[k]); + } + } + + return filled; +} diff --git a/src/package.json b/src/package.json index 74f3f5a..e822a03 100644 --- a/src/package.json +++ b/src/package.json @@ -8,6 +8,7 @@ "webpack": "1.4.9", "jsdom": "1.0.3", "jasmine-node": "1.14.5", - "q": "1.0.1" + "q": "1.0.1", + "lodash": "3.1.0" } }