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 '[
]', + '[]
' + ], 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); +} +