editor: Merge key event handling refactorings
authorAleksander Łukasz <aleksander.lukasz@nowoczesnapolska.org.pl>
Thu, 14 Aug 2014 14:21:47 +0000 (16:21 +0200)
committerAleksander Łukasz <aleksander.lukasz@nowoczesnapolska.org.pl>
Thu, 14 Aug 2014 14:21:47 +0000 (16:21 +0200)
src/editor/modules/documentCanvas/canvas/canvas.js
src/editor/modules/documentCanvas/canvas/documentElement.js
src/editor/modules/documentCanvas/canvas/keyEvent.js [new file with mode: 0644]
src/editor/modules/documentCanvas/canvas/keyboard.js
src/editor/modules/documentCanvas/canvas/selection.js [new file with mode: 0644]
src/editor/plugins/core/core.js
src/editor/plugins/core/core.test.js
src/smartxml/core.js
src/smartxml/smartxml.js

index c964779..0259452 100644 (file)
@@ -11,8 +11,10 @@ define([
 'modules/documentCanvas/canvas/genericElement',
 'modules/documentCanvas/canvas/nullElement',
 'modules/documentCanvas/canvas/gutter',
+'modules/documentCanvas/canvas/selection',
+'modules/documentCanvas/canvas/keyEvent',
 'libs/text!./canvas.html'
-], function($, _, Backbone, logging, documentElement, keyboard, utils, wlxmlListener, ElementsRegister, genericElement, nullElement, gutter, canvasTemplate) {
+], function($, _, Backbone, logging, documentElement, keyboard, utils, wlxmlListener, ElementsRegister, genericElement, nullElement, gutter, selection, keyEvent, canvasTemplate) {
     
 'use strict';
 /* global document:false, window:false, Node:false, gettext */
@@ -145,15 +147,20 @@ $.extend(Canvas.prototype, Backbone.Events, {
         this.rootWrapper.append(this.rootElement.dom);
     },
 
+
+    triggerKeyEvent: function(keyEvent, selection) {
+        selection = selection || this.getSelection();
+        if(selection && (selection.type === 'caret' || selection.type === 'textSelection') && selection.toDocumentFragment().isValid()) {
+            keyboard.handleKeyEvent(keyEvent, selection);
+        }
+    },
+
     setupEventHandling: function() {
         var canvas = this;
 
         /* globals document */
         $(document.body).on('keydown', function(e) {
-            var cursor = canvas.getCursor();
-            if(cursor.isSelecting() || Object.keys(cursor.getPosition()).length) {
-                keyboard.handleKey(e, canvas);
-            }
+            canvas.triggerKeyEvent(keyEvent.fromNativeEvent(e));
         });
 
         this.rootWrapper.on('mouseup', function() {
@@ -316,9 +323,14 @@ $.extend(Canvas.prototype, Backbone.Events, {
     },
 
     triggerSelectionChanged: function() {
-        this.trigger('selectionChanged', this.getSelection());
         var s = this.getSelection(),
-            f = s.toDocumentFragment();
+            f;
+        if(!s) {
+            return;
+        }
+        this.trigger('selectionChanged', s);
+        f = s.toDocumentFragment();
+
         if(f && f instanceof f.RangeFragment) {
             if(this.currentNodeElement) {
                 this.currentNodeElement.updateState({active: false});
@@ -328,7 +340,7 @@ $.extend(Canvas.prototype, Backbone.Events, {
     },
 
     getSelection: function() {
-        return new Selection(this);
+        return selection.fromNativeSelection(this);
     },
 
     select: function(fragment) {
@@ -344,6 +356,13 @@ $.extend(Canvas.prototype, Backbone.Events, {
         }
     },
 
+    setSelection: function(selection) {
+        this.select(this, selection.toDocumentFragment());
+    },
+
+    createSelection: function(params) {
+        return selection.fromParams(this, params);
+    },
     setCurrentElement: function(element, params) {
         if(!element) {
             logger.debug('Invalid element passed to setCurrentElement: ' + element);
@@ -439,69 +458,6 @@ $.extend(Canvas.prototype, Backbone.Events, {
 });
 
 
-var isText = function(node) {
-    return node && node.nodeType === Node.TEXT_NODE && $(node.parentNode).is('[document-text-element]');
-};
-
-var Selection = function(canvas) {
-    this.canvas = canvas;
-    var nativeSelection = this.nativeSelection = window.getSelection();
-    Object.defineProperty(this, 'type', {
-        get: function() {
-            if(nativeSelection.focusNode) {
-                if(nativeSelection.isCollapsed && isText(nativeSelection.focusNode)) {
-                    return 'caret';
-                }
-                if(isText(nativeSelection.focusNode) && isText(nativeSelection.anchorNode)) {
-                    return 'textSelection';
-                }
-            }
-            if(canvas.getCurrentNodeElement()) {
-                return 'node';
-            }
-        }
-    });
-};
-
-$.extend(Selection.prototype, {
-    toDocumentFragment: function() {
-        var doc = this.canvas.wlxmlDocument,
-            anchorElement = this.canvas.getDocumentElement(this.nativeSelection.anchorNode),
-            focusElement = this.canvas.getDocumentElement(this.nativeSelection.focusNode),
-            anchorNode = anchorElement ? anchorElement.wlxmlNode : null,
-            focusNode = focusElement ? focusElement.wlxmlNode : null;
-        if(this.type === 'caret') {
-            return doc.createFragment(doc.CaretFragment, {node: anchorNode, offset: this.nativeSelection.anchorOffset});
-        }
-        if(this.type === 'textSelection') {
-            if(!anchorNode || !focusNode) {
-                return;
-            }
-            if(anchorNode.isSiblingOf(focusNode)) {
-                return doc.createFragment(doc.TextRangeFragment, {
-                    node1: anchorNode,
-                    offset1: this.nativeSelection.anchorOffset,
-                    node2: focusNode,
-                    offset2: this.nativeSelection.focusOffset,
-                });
-            }
-            else {
-                var siblingParents = doc.getSiblingParents({node1: anchorNode, node2: focusNode});
-                return doc.createFragment(doc.RangeFragment, {
-                    node1: siblingParents.node1,
-                    node2: siblingParents.node2
-                });
-            }
-        }
-        if(this.type === 'node') {
-            return doc.createFragment(doc.NodeFragment, {node: this.canvas.getCurrentNodeElement().wlxmlNode});
-        }
-    },
-    sameAs: function(other) {
-        void(other);
-    }
-});
-
 var Cursor = function(canvas) {
     this.canvas = canvas;
     this.selection = window.getSelection();
index 5e653b8..7bbc5c1 100644 (file)
@@ -78,6 +78,9 @@ $.extend(DocumentElement.prototype, {
     sameNode: function(other) {
         return other && (typeof other === typeof this) && other.dom[0] === this.dom[0];
     },
+    isRootElement: function() {
+        return this.sameNode(this.canvas.rootElement);
+    },
 
     trigger: function() {
         this.canvas.eventBus.trigger.apply(this.canvas.eventBus, Array.prototype.slice.call(arguments, 0));
diff --git a/src/editor/modules/documentCanvas/canvas/keyEvent.js b/src/editor/modules/documentCanvas/canvas/keyEvent.js
new file mode 100644 (file)
index 0000000..a5ce48b
--- /dev/null
@@ -0,0 +1,40 @@
+define(function(require) {
+    
+'use strict';
+
+var _ = require('libs/underscore'),
+    keyboard = require('modules/documentCanvas/canvas/keyboard');
+
+var KEYS = keyboard.KEYS;
+
+var KeyEvent = function(params) {
+    this.key = params.key;
+    this.ctrlKey = params.ctrlKey;
+    this._nativeEvent = params._nativeEvent;
+};
+
+_.extend(KeyEvent.prototype, KEYS, {
+    forKey: function(k) {
+        return k === this.key;
+    },
+    preventDefault: function() {
+        if(this._nativeEvent) {
+            this._nativeEvent.preventDefault();
+        }
+    }
+});
+
+return {
+    fromParams: function(params) {
+        return new KeyEvent(params);
+    },
+    fromNativeEvent: function(e) {
+        return this.fromParams({
+            key: e.which,
+            ctrlKey: e.ctrlKey,
+            _nativeEvent: e
+        });
+    }
+};
+
+});
\ No newline at end of file
index 9f58066..6b32da0 100644 (file)
@@ -1,8 +1,7 @@
 define([
 'libs/jquery',
-'modules/documentCanvas/canvas/documentElement',
 'modules/documentCanvas/canvas/utils'
-], function($, documentElement, utils) {
+], function($, utils) {
     
 'use strict';
 /* globals gettext */
@@ -18,26 +17,6 @@ var KEYS = {
     X: 88
 };
 
-var handleKey = function(event, canvas) {
-    handlers.some(function(handler) {
-        if(handles(handler, event) && handler[event.type]) {
-            handler[event.type](event, canvas);
-            return true;
-        }
-    });
-};
-
-var handles = function(handler, event) {
-    if(handler.key === event.which) {
-        return true;
-    }
-    if(handler.keys && handler.keys.indexOf(event.which) !== -1) {
-        return true;
-    }
-    return false;
-};
-
-
 var scroll = function(place, textElement) {
     var rect = textElement.getBoundingClientRect(),
         scroll = $('#rng-module-documentCanvas-contentWrapper'),
@@ -86,321 +65,322 @@ var getFirstRectBelow = function(node, y) {
     return toret;
 };
 
-var handlers = [];
-
-
-handlers.push({key: KEYS.ENTER,
-    keydown: function(event, canvas) {
-        event.preventDefault();
-        var cursor = canvas.getCursor(),
-            position = cursor.getPosition(),
-            element = position.element;
+var handleKeyEvent = function(e, s) {
+    keyEventHandlers.some(function(handler) {
+        if(handler.applies(e, s)) {
+            handler.run(e, s);
+            return true;
+        }
+    });
+};
+// todo: whileRemoveWholetext
+var keyEventHandlers = [
+    {
+        applies: function(e, s) {
+            return e.ctrlKey &&
+                e.key === KEYS.X &&
+                s.type === 'textSelection' &&
+                s.startsAtBeginning() &&
+                s.endsAtEnd();
+        },
+        run: function(e,s) {
+            void(s);
+            e.preventDefault();
+        }
+    },
+    {
+        applies: function(e, s) {
+            return e.key === KEYS.ARROW_UP && s.type === 'caret';
+        },
+        run: function(e, s) {
+            /* globals window */
+            var caretRect = window.getSelection().getRangeAt(0).getClientRects()[0],
+                frameRects = s.element.dom[0].getClientRects(),
+                caretTop = caretRect.bottom - caretRect.height,
+                position, target,rect, scrolled;
 
-        if(Object.keys(cursor.getPosition()).length === 0) {
-            var currentElement = canvas.getCurrentNodeElement();
-            if(currentElement && !currentElement.wlxmlNode.isRoot()) {
-                canvas.wlxmlDocument.transaction(function() {
-                    var added = currentElement.wlxmlNode.after({
-                        tagName: currentElement.wlxmlNode.getTagName() || 'div',
-                        attrs: {'class': currentElement.wlxmlNode.getClass() || 'p'}
-                    });
-                    added.append({text:''});
-                    return added;
-                }, {
-                    metadata: {
-                        description: gettext('Splitting text')
-                    },
-                    success: function(ret) {
-                        canvas.setCurrentElement(utils.getElementForNode(ret), {caretTo: 'start'});
+            
+            if((frameRects[0].bottom === caretRect.bottom) || (caretRect.left < frameRects[0].left)) {
+                e.preventDefault();
+                s.canvas.rootWrapper.find('[document-text-element]').each(function() {
+                    var test = getLastRectAbove(this, caretTop);
+                    if(test) {
+                        target = this;
+                        rect = test;
+                    } else {
+                        return false;
                     }
                 });
-
+                if(target) {
+                    scrolled = scroll('top', target);
+                    position = utils.caretPositionFromPoint(caretRect.left, rect.bottom - 1 - scrolled);
+                    s.canvas.setCurrentElement(s.canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
+                }
             }
-            return;
-        }
-
-        if(!cursor.isSelecting()) {
-            if(event.ctrlKey) {
-                if(element instanceof documentElement.DocumentTextElement) {
-                    element = element.parent();
+            if(target) {
+                scrolled = scroll('top', target);
+                var left = caretRect.left;
+                if(left > rect.left + rect.width) {
+                    left = rect.left + rect.width;
+                } else if(left < rect.left ) {
+                    left = rect.left;
                 }
-
-                canvas.wlxmlDocument.transaction(function() {
-                    var added = element.wlxmlNode.after(
-                        {tagName: element.wlxmlNode.getTagName() || 'div', attrs: {'class': element.wlxmlNode.getClass() || 'p'}}
-                    );
-                    added.append({text: ''});
-                    return added;
-                }, {
-                    metadata: {
-                        description: gettext('Splitting text')
-                    },
-                    success: function(ret) {
-                        canvas.setCurrentElement(utils.getElementForNode(ret), {caretTo: 'start'});
+                position = utils.caretPositionFromPoint(left, rect.bottom - 1 - scrolled);
+                s.canvas.setCurrentElement(s.canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
+            }
+        }
+    },
+    {
+        applies: function(e, s) {
+            return e.key === KEYS.ARROW_DOWN && s.type === 'caret';
+        },
+        run: function(e, s) {
+            /* globals window */
+            var caretRect = window.getSelection().getRangeAt(0).getClientRects()[0],
+                frameRects = s.element.dom[0].getClientRects(),
+                lastRect = frameRects[frameRects.length-1],
+                position, target,rect, scrolled;
+
+            if(lastRect.bottom === caretRect.bottom || (caretRect.left > lastRect.left + lastRect.width)) {
+                e.preventDefault();
+                s.canvas.rootWrapper.find('[document-text-element]').each(function() {
+                    var test = getFirstRectBelow(this, caretRect.bottom);
+                    if(test) {
+                        target = this;
+                        rect = test;
+                        return false;
                     }
                 });
-
-            } else {
-
-                if(!(element.parent().parent())) {
-                    return false; // top level element is unsplittable
+                if(target) {
+                    scrolled = scroll('bottom', target);
+                    position = utils.caretPositionFromPoint(caretRect.left, rect.top +1 - scrolled);
+                    s.canvas.setCurrentElement(s.canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
                 }
-
-                var node = position.element.wlxmlNode,
-                    result, goto, gotoOptions;
-
-                node.document.transaction(function() {
-                    result = position.element.wlxmlNode.breakContent({offset: position.offset});
-                }, {
-                    metadata: {
-                        description: gettext('Splitting text')
-                    }
-                });
-
-                if(result.emptyText) {
-                    goto = result.emptyText;
-                    gotoOptions = {};
-                } else {
-                    goto = result.second;
-                    gotoOptions = {caretTo: 'start'};
+            }
+            if(target) {
+                scrolled = scroll('bottom', target);
+                var left = caretRect.left;
+                if(left > rect.left + rect.width) {
+                    left = rect.left + rect.width;
+                } else if(left < rect.left ) {
+                    left = rect.left;
                 }
-
-                canvas.setCurrentElement(utils.getElementForNode(goto), gotoOptions);
+                position = utils.caretPositionFromPoint(left, rect.top +1 - scrolled);
+                s.canvas.setCurrentElement(s.canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
             }
         }
-    }
-});
-
-handlers.push({keys: [KEYS.ARROW_UP],
-    keydown: function(event, canvas) {
-        /* globals window */
-        var element = canvas.getCursor().getPosition().element,
-            caretRect = window.getSelection().getRangeAt(0).getClientRects()[0],
-            frameRects = element.dom[0].getClientRects(),
-            caretTop = caretRect.bottom - caretRect.height,
-            position, target,rect, scrolled;
-
-        
-        if((frameRects[0].bottom === caretRect.bottom) || (caretRect.left < frameRects[0].left)) {
-            event.preventDefault();
-            canvas.rootWrapper.find('[document-text-element]').each(function() {
-                var test = getLastRectAbove(this, caretTop);
-                if(test) {
-                    target = this;
-                    rect = test;
-                } else {
-                    return false;
+    },
+    {
+        applies: function(e, s) {
+            return e.key === KEYS.ARROW_LEFT && s.type === 'caret';
+        },
+        run: function(e, s) {
+            /* globals window */
+            var prev;
+
+            if(s.offset === 0) {
+                e.preventDefault();
+                prev = s.canvas.getPreviousTextElement(s.element);
+                if(prev) {
+                    scroll('top', prev.dom[0]);
+                    s.canvas.setCurrentElement(s.canvas.getDocumentElement(prev.dom.contents()[0]), {caretTo: 'end'});
                 }
-            });
-            if(target) {
-                scrolled = scroll('top', target);
-                position = utils.caretPositionFromPoint(caretRect.left, rect.bottom - 1 - scrolled);
-                canvas.setCurrentElement(canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
             }
         }
-    }
-});
-
-handlers.push({keys: [KEYS.ARROW_DOWN],
-    keydown: function(event, canvas) {
-        /* globals window */
-        var element = canvas.getCursor().getPosition().element,
-            caretRect = window.getSelection().getRangeAt(0).getClientRects()[0],
-            frameRects = element.dom[0].getClientRects(),
-            lastRect = frameRects[frameRects.length-1],
-            position, target,rect, scrolled;
-
-        if(lastRect.bottom === caretRect.bottom || (caretRect.left > lastRect.left + lastRect.width)) {
-            event.preventDefault();
-            canvas.rootWrapper.find('[document-text-element]').each(function() {
-                var test = getFirstRectBelow(this, caretRect.bottom);
-                if(test) {
-                    target = this;
-                    rect = test;
-                    return false;
+    },
+    {
+        applies: function(e, s) {
+            return e.key === KEYS.ARROW_RIGHT && s.type === 'caret';
+        },
+        run: function(e, s) {
+            /* globals window */
+            var next;
+            if(s.isAtEnd()) {
+                e.preventDefault();
+                next = s.canvas.getNextTextElement(s.element);
+                if(next) {
+                    scroll('bottom', next.dom[0]);
+                    s.canvas.setCurrentElement(s.canvas.getDocumentElement(next.dom.contents()[0]), {caretTo: 0});
+                }
+            } else {
+                var secondToLast = (s.offset === s.element.wlxmlNode.getText().length -1);
+                if(secondToLast) {
+                    // Only Flying Spaghetti Monster knows why this is need for FF (for versions at least 26 to 31)
+                    e.preventDefault();
+                    s.canvas.setCurrentElement(s.element, {caretTo: 'end'});
                 }
-            });
-            if(target) {
-                scrolled = scroll('bottom', target);
-                position = utils.caretPositionFromPoint(caretRect.left, rect.top +1 - scrolled);
-                canvas.setCurrentElement(canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
             }
         }
-    }
-});
-
-handlers.push({keys: [KEYS.ARROW_LEFT],
-    keydown: function(event, canvas) {
-        /* globals window */
-        var position = canvas.getCursor().getPosition(),
-            element = position.element,
-            prev;
-
-        if(position.offset === 0) {
-            event.preventDefault();
-            prev = canvas.getPreviousTextElement(element);
-            if(prev) {
-                scroll('top', prev.dom[0]);
-                canvas.setCurrentElement(canvas.getDocumentElement(prev.dom.contents()[0]), {caretTo: 'end'});
-            }
+    },
+    {
+        applies: function(e, s) {
+            return s.type === 'caret' &&
+                s.element.wlxmlNode.parent().is({tagName: 'span'}) &&
+                s.element.wlxmlNode.getText().length === 1 &&
+                s.offset === 1 &&
+                (e.key === KEYS.BACKSPACE);
+        },
+        run: function(e, s) {
+            var params = {},
+                prevTextNode = s.element.canvas.getPreviousTextElement(s.element).wlxmlNode;
+            e.preventDefault();
+            s.element.wlxmlNode.parent().detach(params);
+            s.canvas.setCurrentElement(
+                (params.ret && params.ret.mergedTo) || prevTextNode,
+                {caretTo: params.ret ? params.ret.previousLen : (prevTextNode ? prevTextNode.getText().length : 0)});
         }
-    }
-});
-
-handlers.push({keys: [KEYS.ARROW_RIGHT],
-    keydown: function(event, canvas) {
-        /* globals window */
-        var position = canvas.getCursor().getPosition(),
-            element = position.element,
-            next;
-        if(position.offsetAtEnd) {
-            event.preventDefault();
-            next = canvas.getNextTextElement(element);
-            if(next) {
-                scroll('bottom', next.dom[0]);
-                canvas.setCurrentElement(canvas.getDocumentElement(next.dom.contents()[0]), {caretTo: 0});
+    },
+    {
+        applies: function(e, s) {
+            return s.type === 'caret' && (
+                (s.isAtBeginning() && e.key === KEYS.BACKSPACE) ||
+                (s.isAtEnd() && e.key === KEYS.DELETE)
+            );
+        },
+        run: function(e,s) {
+            var direction, caretTo, cursorAtOperationEdge, goto, element;
+
+            if(e.key === KEYS.BACKSPACE) {
+                direction = 'above';
+                caretTo = 'end';
+                cursorAtOperationEdge = s.isAtBeginning();
+                element = s.element;
             }
-        } else {
-            var secondToLast = (position.offset === element.wlxmlNode.getText().length -1);
-            if(secondToLast) {
-                // Only Flying Spaghetti Monster knows why this is need for FF (for versions at least 26 to 31)
-                event.preventDefault();
-                canvas.setCurrentElement(element, {caretTo: 'end'});
+            else {
+                direction = 'below';
+                caretTo = 'start';
+                cursorAtOperationEdge = s.isAtEnd();
+                element = cursorAtOperationEdge && s.canvas.getNearestTextElement(direction, s.element);
             }
-        }
 
-    }
-});
-
-var selectsWholeTextElement = function(cursor) {
-    if(cursor.isSelecting() && cursor.getSelectionStart().offsetAtBeginning && cursor.getSelectionEnd().offsetAtEnd) {
-        return true;
-    }
-    return false;
-};
-
-handlers.push({key: KEYS.X,
-    keydown: function(event, canvas) {
-        if(event.ctrlKey && selectsWholeTextElement(canvas.getCursor())) {
-            event.preventDefault();
-        }
-    }
-});
+            if(!cursorAtOperationEdge || !element) {
+                return;
+            }
 
-handlers.push({keys: [KEYS.BACKSPACE, KEYS.DELETE],
-    keydown: function(event, canvas) {
-        var cursor = canvas.getCursor(),
-            position = canvas.getCursor().getPosition(),
-            element = position.element,
-            node = element ? element.wlxmlNode : null,
-            direction = 'above',
-            caretTo = 'end',
-            goto;
+            e.preventDefault();
 
-        if(!element || !node) {
-            return;
+            s.canvas.wlxmlDocument.transaction(function() {
+                if(element.wlxmlNode.getIndex() === 0) {
+                    goto = element.wlxmlNode.parent().moveUp();
+                } else {
+                    goto = element.wlxmlNode.moveUp();
+                }
+                if(goto) {
+                   s.canvas.setCurrentElement(goto.node, {caretTo: goto.offset});
+                }
+            }, {
+                metadata: {
+                    description: gettext('Remove text')
+                }
+            });
         }
-            
-        if(event.which === KEYS.DELETE) {
-            direction = 'below';
-            caretTo = 'start';
+    },
+
+    {
+        applies: function(e,s) {
+            return s.type === 'caret' && s.element.getText().length === 1 && (e.key === KEYS.BACKSPACE || e.key === KEYS.DELETE);
+        },
+        run: function(e,s) {
+            e.preventDefault();
+            s.element.wlxmlNode.setText('');
+            s.canvas.setCurrentElement(s.element, {caretTo: 0});
         }
+    },
+
+    {
+        applies: function(e, s) {
+            return s.type === 'textSelection' && (e.key === KEYS.BACKSPACE || e.key === KEYS.DELETE);
+        },
+        run: function(e, s) {
+            var direction = 'above',
+                caretTo = 'end',
+                goto;
+
+            if(e.key === KEYS.DELETE) {
+                direction = 'below';
+                caretTo = 'start';
+            }
 
-        if(cursor.isSelecting()) {
-            event.preventDefault();
-            var start = cursor.getSelectionStart(),
-                end = cursor.getSelectionEnd();
+            e.preventDefault();
 
-            if(direction === 'above') {
-                if(start.offsetAtBeginning) {
-                    goto = canvas.getNearestTextElement('above', start.element);
+            if(s.startsAtBeginning && s.endsAtEnd && s.startElement.sameNode(s.endElement)) {
+                goto = s.startElement;
+                caretTo = s.startOffset;
+            } else if(direction === 'above') {
+                if(s.startsAtBeginning()) {
+                    goto = s.canvas.getNearestTextElement('above', s.startElement);
                     caretTo = 'end';
                 } else {
-                    goto = start.element;
-                    caretTo = start.offset;
+                    goto = s.startElement;
+                    caretTo = s.startOffset;
                 }
             } else {
-                if(end.offsetAtEnd) {
-                    goto = canvas.getNearestTextElement('below', start.element);
+                if(s.endsAtEnd()) {
+                    goto = s.canvas.getNearestTextElement('below', s.startElement);
                     caretTo = 'start';
                 } else {
-                    goto = end.element;
+                    goto = s.endElement;
                     caretTo = 0;
                 }
             }
 
-            canvas.wlxmlDocument.deleteText({
-                from: {
-                    node: start.element.wlxmlNode,
-                    offset: start.offset
-                },
-                to: {
-                    node: end.element.wlxmlNode,
-                    offset: end.offset
-                }
-            });
-            if(goto) {
-                canvas.setCurrentElement(goto, {caretTo: caretTo});
-            }
-            return;
-        }
-            
-        var cursorAtOperationEdge = position.offsetAtBeginning;
-        if(event.which === KEYS.DELETE) {
-            cursorAtOperationEdge = position.offsetAtEnd;
-        }
-
-        var willDeleteWholeText = function() {
-            return element.getText().length === 1 || selectsWholeTextElement(cursor);
-        };
-
-        canvas.wlxmlDocument.transaction(function() {
-            if(willDeleteWholeText()) {
-                event.preventDefault();
-                node.setText('');
-            }
-            else if(element.isEmpty()) {
-                event.preventDefault();
-                var parent = element.parent(),
-                    grandParent = parent ? parent.parent() : null;
-                if(!grandParent && parent.children().length === 1) {
-                    return;
-                }
-                if(parent.children().length === 1 && parent.children()[0].sameNode(element)) {
-                    if(grandParent && grandParent.children().length === 1) {
-                        goto = grandParent.wlxmlNode.append({text: ''});
-                    } else {
-                        goto = canvas.getNearestTextElement(direction, element);
+            var doc = s.canvas.wlxmlDocument;
+            doc.transaction(function() {
+                
+                doc.deleteText({
+                    from: {
+                        node: s.startElement.wlxmlNode,
+                        offset: s.startOffset
+                    },
+                    to: {
+                        node: s.endElement.wlxmlNode,
+                        offset: s.endOffset
                     }
-                    parent.wlxmlNode.detach();
-                } else {
-                    goto = canvas.getNearestTextElement(direction, element);
-                    element.wlxmlNode.detach();
-                }
-                canvas.setCurrentElement(goto, {caretTo: caretTo});
-            }
-            else if(cursorAtOperationEdge) {
-                if(direction === 'below') {
-                    element = canvas.getNearestTextElement(direction, element);
-                }
-                if(element) {
-                    goto = element.wlxmlNode.mergeContentUp();
+                });
+
+            }, {
+                success: function() {
                     if(goto) {
-                        canvas.setCurrentElement(goto.node, {caretTo: goto.offset});
+                        s.canvas.setCurrentElement(goto, {caretTo: caretTo});
                     }
                 }
-                event.preventDefault();
-            }
-        }, {
-            metadata: {
-                description: gettext('Remove text')
+            });
+
+        }
+    },
+    {
+        applies: function(e, s) {
+            return s.type === 'caret' && e.key === KEYS.ENTER && !s.element.parent().isRootElement();
+        },
+        run: function(e, s) {
+            var result, goto, gotoOptions;
+            void(e);
+            e.preventDefault();
+            s.canvas.wlxmlDocument.transaction(function() {
+                result = s.element.wlxmlNode.breakContent({offset: s.offset});
+            }, {
+                metadata: {
+                    description: gettext('Splitting text'),
+                    fragment: s.toDocumentFragment()
+                }
+            });
+
+            if(result.emptyText) {
+                goto = result.emptyText;
+                gotoOptions = {};
+            } else {
+                goto = result.second;
+                gotoOptions = {caretTo: 'start'};
             }
-        });
+
+            s.canvas.setCurrentElement(utils.getElementForNode(goto), gotoOptions);
+        }
     }
-});
+];
 
 return {
-    handleKey: handleKey
+    handleKeyEvent: handleKeyEvent,
+    KEYS: KEYS
 };
 
 });
\ No newline at end of file
diff --git a/src/editor/modules/documentCanvas/canvas/selection.js b/src/editor/modules/documentCanvas/canvas/selection.js
new file mode 100644 (file)
index 0000000..4de392b
--- /dev/null
@@ -0,0 +1,157 @@
+define(function(require) {
+    
+'use strict';
+
+var $ = require('libs/jquery');
+
+var Selection = function(canvas, params) {
+    this.canvas = canvas;
+    $.extend(this, params);
+};
+
+var CaretSelection = function(canvas, params) {
+    Selection.call(this, canvas, params);
+};
+CaretSelection.prototype = Object.create(Selection.prototype);
+$.extend(CaretSelection.prototype, {
+    toDocumentFragment: function() {
+        var doc = this.canvas.wlxmlDocument;
+        return doc.createFragment(doc.CaretFragment, {node: this.element.wlxmlNode, offset: this.offset});
+    },
+    isAtEdge: function() {
+        return this.isAtBeginning() || this.isAtEnd();
+    },
+    isAtBeginning: function() {
+        return this.offset === 0;
+    },
+    isAtEnd: function() {
+        return this.offset === this.element.getText().length;
+    }
+});
+
+var TextSelection = function(canvas, params) {
+    var anchorFirst;
+
+    Selection.call(this, canvas, params);
+
+    if(this.anchorElement.sameNode(this.focusElement)) {
+        anchorFirst = this.anchorOffset <= this.focusOffset;
+    } else {
+        /*jshint bitwise: false*/
+        /* globals Node */
+        anchorFirst = this.anchorElement.dom[0].compareDocumentPosition(this.focusElement.dom[0]) & Node.DOCUMENT_POSITION_FOLLOWING;
+    }
+
+    if(anchorFirst) {
+        this.startElement = this.anchorElement;
+        this.startOffset = this.anchorOffset;
+        this.endElement = this.focusElement;
+        this.endOffset = this.focusOffset;
+
+    } else {
+        this.startElement = this.focusElement;
+        this.startOffset = this.focusOffset;
+        this.endElement = this.anchorElement;
+        this.endOffset = this.anchorOffset;
+    }
+};
+TextSelection.prototype = Object.create(Selection.prototype);
+$.extend(TextSelection.prototype, {
+    toDocumentFragment: function() {
+        var doc = this.canvas.wlxmlDocument,
+            anchorNode = this.anchorElement ? this.anchorElement.wlxmlNode : null,
+            focusNode = this.focusElement ? this.focusElement.wlxmlNode : null;
+        
+        if(!anchorNode || !focusNode) {
+            return;
+        }
+
+        if(anchorNode.isSiblingOf(focusNode)) {
+            return doc.createFragment(doc.TextRangeFragment, {
+                node1: anchorNode,
+                offset1: this.anchorOffset,
+                node2: focusNode,
+                offset2: this.focusOffset,
+            });
+        }
+        else {
+            var siblingParents = doc.getSiblingParents({node1: anchorNode, node2: focusNode});
+            return doc.createFragment(doc.RangeFragment, {
+                node1: siblingParents.node1,
+                node2: siblingParents.node2
+            });
+        }
+    },
+    startsAtBeginning: function() {
+        return this.startOffset === 0;
+    },
+    endsAtEnd: function() {
+        return this.endOffset === this.endElement.getText().length;
+    }
+});
+
+var NodeSelection = function(canvas, params) {
+    Selection.call(this, canvas, params);
+};
+NodeSelection.prototype = Object.create(Selection.prototype);
+$.extend(NodeSelection.prototype, {
+    toDocumentFragment: function() {
+        var doc = this.canvas.wlxmlDocument;
+        doc.createFragment(doc.NodeFragment, {node: this.element.wlxmlNode});
+    }
+});
+
+
+var isText = function(node) {
+    /* globals Node */
+    return node && node.nodeType === Node.TEXT_NODE && $(node.parentNode).is('[document-text-element]');
+};
+
+var types = {
+    caret: CaretSelection,
+    textSelection: TextSelection,
+    nodeSelection: NodeSelection
+};
+
+return {
+    fromParams: function(canvas, params) {
+        return new types[params.type](canvas, params);
+    },
+    fromNativeSelection: function(canvas) {
+        /* globals window */
+        var nativeSelection =  window.getSelection(),
+            params = {},
+            element, anchorElement, focusElement;
+            
+        if(nativeSelection.focusNode) {
+            if(nativeSelection.isCollapsed && isText(nativeSelection.focusNode)) {
+                element = canvas.getDocumentElement(nativeSelection.focusNode);
+                params = {
+                    type: 'caret',
+                    element: element,
+                    offset: element.isEmpty() ? 0 : nativeSelection.focusOffset
+                };
+            } else if(isText(nativeSelection.focusNode) && isText(nativeSelection.anchorNode)) {
+                anchorElement = canvas.getDocumentElement(nativeSelection.anchorNode);
+                focusElement = canvas.getDocumentElement(nativeSelection.focusNode);
+                params = {
+                    type: 'textSelection',
+                    anchorElement: anchorElement,
+                    anchorOffset: anchorElement.isEmpty() ? 0 : nativeSelection.anchorOffset,
+                    focusElement: focusElement,
+                    focusOffset: focusElement.isEmpty() ? 0 : nativeSelection.focusOffset
+                };
+            }
+        } else if((element = canvas.getCurrentNodeElement())) {
+            params = {
+                type: 'nodeSelection',
+                element: element
+            };
+        }
+        if(params.type) {
+            return this.fromParams(canvas, params);
+        }
+    }
+};
+
+});
\ No newline at end of file
index 509d743..e300232 100644 (file)
@@ -8,7 +8,7 @@ var _ = require('libs/underscore'),
     footnote = require('plugins/core/footnote'),
     switchTo = require('plugins/core/switch'),
     lists = require('plugins/core/lists'),
-    plugin = {name: 'core', actions: [], canvas: {}, documentExtension: {textNode: {}}},
+    plugin = {name: 'core', actions: [], canvas: {}, documentExtension: {textNode: {}, documentNode: {}}},
     Dialog = require('views/dialog/dialog'),
     canvasElements = require('plugins/core/canvasElements'),
     metadataEditor = require('plugins/core/metadataEditor/metadataEditor');
@@ -19,6 +19,7 @@ plugin.documentExtension.textNode.transformations = {
     breakContent: {
         impl: function(args) {
             var node = this,
+                isSpan = node.parent().getTagName() === 'span',
                 parentDescribingNodes = [],
                 newNodes, emptyText;
             newNodes = node.split({offset: args.offset});
@@ -40,10 +41,51 @@ plugin.documentExtension.textNode.transformations = {
             parentDescribingNodes.forEach(function(node) {
                 newNodes.first.append(node);
             });
+
+            var parent, newNode;
+
+            var copyNode = function(n) {
+                var attrs = {};
+                n.getAttrs().forEach(function(attr) {
+                    attrs[attr.name] = attr.value;
+                });
+
+                return node.document.createDocumentNode({
+                    tagName: n.getTagName(),
+                    attrs: attrs
+                });
+            };
+
+            var move = function(node, to) {
+                var copy;
+                if(!node.containsNode(newNodes.second)) {
+                    to.append(node);
+                    return false;
+                } else {
+                    if(!node.sameNode(newNodes.second)) {
+                        copy = to.append(copyNode(node));
+                        node.contents().some(function(n) {
+                            return move(n, copy);
+                        });
+                    }
+                    return true;
+                }
+            };
+
+            if(isSpan) {
+                newNodes.first.parents().some(function(p) {
+                    if(p.getTagName() !== 'span') {
+                        parent = p;
+                        return true;
+                    }
+                });
+                newNode = parent.before({tagName: parent.getTagName(), attrs: {'class': parent.getClass()}});
+                parent.contents().some(function(n) {
+                    return move(n, newNode);
+                });
+            }
+
             return _.extend(newNodes, {emptyText: emptyText});
-        },
-        getChangeRoot: function() {
-            return this.context.parent().parent();
         }
     },
     mergeContentUp: function() {
@@ -82,6 +124,154 @@ plugin.documentExtension.textNode.transformations = {
     }
 };
 
+plugin.documentExtension.documentNode.transformations = {
+    moveUp: function() {
+        var toMerge = this,
+            prev = toMerge.prev();
+
+        var merge = function(from, to) {
+            var toret;
+            from.contents().forEach(function(node, idx) {
+                var len, ret;
+                if(idx === 0 && node.nodeType === Node.TEXT_NODE) {
+                    len = node.getText().length;
+                }
+                ret = to.append(node);
+                
+                if(idx === 0 && ret.nodeType === Node.TEXT_NODE) {
+                    toret = {
+                        node: ret,
+                        offset: ret.getText().length - len
+                    };
+                } else if(!toret) {
+                    toret = {
+                        node: ret.getFirstTextNode(),
+                        offset: 0
+                    };
+                }
+            });
+            from.detach();
+            return toret;
+        };
+
+        var strategies = [
+            {
+                applies: function() {
+                    return toMerge.nodeType === Node.TEXT_NODE && prev.is({tagName: 'span'});
+                },
+                run: function() {
+                    var textNode = prev.getLastTextNode(),
+                        txt, prevText, prevTextLen;
+                    if(textNode) {
+                        txt = textNode.getText();
+                        if(txt.length > 1) {
+                            textNode.setText(txt.substr(0, txt.length-1));
+                            return {node: toMerge, offset: 0};
+                        } else {
+                            if((prevText = prev.prev()) && prevText.nodeType === Node.TEXT_NODE) {
+                                prevTextLen = prevText.getText().length;
+                            }
+                            prev.detach();
+                            return {
+                                node: prevText ? prevText : toMerge,
+                                offset : prevText ? prevTextLen : 0
+                            };
+                        }
+                    }
+                }
+            },
+            {
+                applies: function() {
+                    return toMerge.is({tagName: 'div', 'klass': 'p'}) || (toMerge.is({tagName: 'div'}) && toMerge.getClass() === '');
+                },
+                run: function() {
+                    if(prev && prev.is('p') || prev.is({tagName: 'header'})) {
+                        return merge(toMerge, prev);
+                    }
+                    if(prev && prev.is('list')) {
+                        var items = prev.contents().filter(function(n) { return n.is('item');});
+                        return merge(toMerge, items[items.length-1]);
+                    }
+                }
+            },
+            {
+                applies: function() {
+                    return toMerge.is({tagName: 'span'});
+                },
+                run: function() {
+                    /* globals Node */
+                    var toret = {node: toMerge.contents()[0] , offset: 0},
+                        txt, txtNode, parent;
+                    if(!prev) {
+                        toMerge.parents().some(function(p) {
+                            if(p.is({tagName: 'span'})) {
+                                parent = prev = p;
+                            } else {
+                                if(!parent) {
+                                    parent = p;
+                                }
+                                prev = prev && prev.prev();
+                                return true;
+                            }
+                        });
+                    }
+                    if(!prev) {
+                        return parent.moveUp();
+                    }
+                    else if(prev.nodeType === Node.TEXT_NODE && (txt = prev.getText())) {
+                        prev.setText(txt.substr(0, txt.length-1));
+                        return toret;
+                    } else if(prev.is({tagName: 'span'})) {
+                        if((txtNode = prev.getLastTextNode())) {
+                            txt = txtNode.getText();
+                            txtNode.setText(txt.substr(0, txt.length-1));
+                            return toret;
+                        }
+                    }
+
+                }
+            },
+            {
+                applies: function() {
+                    return toMerge.is({tagName: 'header'});
+                },
+                run: function() {
+                    if(prev && prev.is('p') || prev.is({tagName: 'header'})) {
+                        return merge(toMerge, prev);
+                    }
+                }
+            },
+            {
+                applies: function() {
+                    return toMerge.is('item');
+                },
+                run: function() {
+                    var list;
+                    if(prev && prev.is('item')) {
+                        return merge(toMerge, prev);
+                    } else if(!prev && (list = toMerge.parent()) && list.is('list')) {
+                        list.before(toMerge);
+                        toMerge.setClass('p');
+                        if(!list.contents().length) {
+                            list.detach();
+                        }
+                        return {node: toMerge.contents()[0], offset:0};
+                    }
+                }
+            }
+        ];
+
+        var toret;
+        strategies.some(function(strategy) {
+            if(strategy.applies()) {
+                toret = strategy.run();
+                return true;
+            }
+        });
+        return toret;
+    }
+};
+
 var undoRedoAction = function(dir) {
     return {
         name: dir,
index ed61328..00fc421 100644 (file)
@@ -1,20 +1,41 @@
 define(function(require) {
     
 'use strict';
-/* globals describe, it */
 
-var chai = require('libs/chai'),
+/* globals describe, it, afterEach */
+
+var $ = require('libs/jquery'),
+    _ = require('libs/underscore'),
+    chai = require('libs/chai'),
     sinon = require('libs/sinon'),
     wlxml = require('wlxml/wlxml'),
+    canvas = require('modules/documentCanvas/canvas/canvas'),
+    keyboard = require('modules/documentCanvas/canvas/keyboard'),
+    keyEvent = require('modules/documentCanvas/canvas/keyEvent'),
     corePlugin = require('./core.js'),
     expect = chai.expect;
 
+var K = keyboard.KEYS;
+
 var getDocumentFromXML = function(xml, options) {
     var doc = wlxml.WLXMLDocumentFromXML(xml, options || {});
     doc.registerExtension(corePlugin.documentExtension);
     return doc;
 };
 
+
+var getCanvasFromXML = function(xml, elements) {
+    var c = canvas.fromXMLDocument(getDocumentFromXML(xml), elements),
+        view = c.view();
+    view.attr('canvas-test', true);
+    /* globals document */
+    $(document.body).append(view);
+    return c;
+};
+var removeCanvas = function() {
+    $('[canvas-test]').remove();
+};
+
 var getTextNodes = function(text, doc) {
     /* globals Node */
     var toret = [];
@@ -49,6 +70,15 @@ var getTextNode = function(text, doc) {
     return nodes[0];
 };
 
+var getTextElement = function(text, c) {
+    var node = getTextNode(text, c.wlxmlDocument),
+        element =  node && node.getData('canvasElement');
+    if(!(element && element.getText() === text)) {
+        throw new Error();
+    }
+    return element;
+};
+
 
 describe('Document extensions', function() {
     describe('break content', function() {
@@ -133,5 +163,565 @@ describe('Document extensions', function() {
     });
 });
 
+describe('Keyboard interactions', function() {
+
+    var Keyboard = function(canvas) {
+        this.canvas = canvas;
+    };
+
+    _.extend(Keyboard.prototype, {
+        press: function(key) {
+            this.canvas.triggerKeyEvent(keyEvent.fromParams({key:key}), this.selection);
+            this.selection = this.canvas.getSelection();
+            return this;
+        },
+        withCaret: function(where) {
+            var offset = where.indexOf('|'),
+                text = where.split('|').join(''),
+                el = getTextElement(text, this.canvas),
+                selection = this.canvas.createSelection({type: 'caret', element: el, offset: offset});
+            if(offset === -1) {
+                throw new Error('Invalid caret');
+            }
+            this.selection = selection;
+            return this;
+        },
+        withSelection: function(start, end) {
+            var startOffset = start.indexOf('|'),
+                endOffset = end.indexOf('|'),
+                startText= start.split('|').join(''),
+                endText = end.split('|').join(''),
+                startElement = getTextElement(startText, this.canvas),
+                endElement = getTextElement(endText, this.canvas),
+                selection = this.canvas.createSelection({
+                    type: 'textSelection', 
+                    anchorElement: startElement,
+                    anchorOffset: startOffset,
+                    focusElement: endElement,
+                    focusOffset: endOffset
+                });
+            if(startOffset === -1 || endOffset === -1) {
+                throw new Error('Invalid text selection');
+            }
+            this.selection = selection;
+            return this;    
+        }
+    });
+
+    describe('deleting text with selection', function() {
+        afterEach(removeCanvas);
+
+        [K.BACKSPACE, K.DELETE].forEach(function(key) {
+            it('deletes text withing a single text element ' + key, function() {
+                var c = getCanvasFromXML('<section><div>Alice</div></section>'),
+                    k = new Keyboard(c);
+
+                k.withSelection('A|lice', 'Alic|e').press(key);
+                expect(c.wlxmlDocument.root.contents()[0].contents()[0].getText()).to.equal('Ae');
+
+
+                var selection = c.getSelection();
+                expect(selection.type).to.equal('caret');
+                expect(selection.element.getText()).to.equal('Ae');
+                expect(selection.offset).to.equal(1);
+            });
+            it('deletes text across two paragraphs ' + key, function() {
+                var c = getCanvasFromXML('<section><div class="p">Alice</div><div class="p">cat</div></section>'),
+                    k = new Keyboard(c);
+
+                k.withSelection('A|lice', 'c|at').press(key);
+                var rootContents = c.wlxmlDocument.root.contents();
+
+                expect(rootContents.length).to.equal(2);
+                expect(rootContents[0].contents()[0].getText()).to.equal('A');
+                expect(rootContents[1].contents()[0].getText()).to.equal('at');
+
+                var selection = c.getSelection();
+                expect(selection.type).to.equal('caret');
+                expect(selection.element.wlxmlNode.getText()).to.equal(key === K.BACKSPACE ? 'A' : 'at');
+            });
+
+            it('keeps an empty paragraph after deleting its whole text ' + key, function() {
+                var c = getCanvasFromXML('<section><div class="p">Alice</div></section>'),
+                    k = new Keyboard(c);
+
+                k.withSelection('|Alice', 'Alice|').press(key);
+                var rootContents = c.wlxmlDocument.root.contents();
+
+                expect(rootContents.length).to.equal(1);
+                expect(rootContents[0].contents()[0].getText()).to.equal('');
+                
+                var selection = c.getSelection();
+                expect(selection.type).to.equal('caret');
+                expect(selection.element.wlxmlNode.parent().sameNode(c.wlxmlDocument.root.contents()[0]));
+            });
+        });
+
+    });
+
+
+    describe('backspace at the beginning of a block', function() {
+        afterEach(removeCanvas);
+
+        it('merges two adjacent paragraphs', function() {
+            var c = getCanvasFromXML('<section><div class="p">A</div><div class="p">B</div></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('|B').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(1);
+            expect(rootContents[0].getClass()).to.equal('p');
+            expect(rootContents[0].contents()[0].getText()).to.equal('AB');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('AB', c))).to.equal(true);
+            expect(selection.offset).to.equal(1);
+        });
+        it('merges a paragraph with a header', function() {
+            var c = getCanvasFromXML('<section><header>A</header><div class="p">B</div></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('|B').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(1);
+            expect(rootContents[0].getTagName()).to.equal('header');
+            expect(rootContents[0].contents()[0].getText()).to.equal('AB');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('AB', c))).to.equal(true);
+            expect(selection.offset).to.equal(1);
+        });
+        it('merges two adjacent headers', function() {
+            var c = getCanvasFromXML('<section><header>A</header><header>B</header></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('|B').press(K.BACKSPACE);
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(1);
+            expect(rootContents[0].getTagName()).to.equal('header');
+            expect(rootContents[0].contents()[0].getText()).to.equal('AB');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('AB', c))).to.equal(true);
+            expect(selection.offset).to.equal(1);
+        });
+        it('merges a header with a paragraph', function() {
+            var c = getCanvasFromXML('<section><div class="p">A</div><header>B</header></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('|B').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(1);
+            expect(rootContents[0].is('p')).to.equal(true);
+            expect(rootContents[0].contents()[0].getText()).to.equal('AB');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('AB', c))).to.equal(true);
+            expect(selection.offset).to.equal(1);
+        });
+        it('merges a paragraph into a last list item', function() {
+            var c = getCanvasFromXML('<section><div class="list"><div class="item">item</div></div><div class="p">paragraph</div></section>'),
+                list = c.wlxmlDocument.root.contents()[0],
+                k = new Keyboard(c);
+
+            k.withCaret('|paragraph').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(1);
+            expect(rootContents[0].sameNode(list)).to.equal(true);
+
+            var items = list.contents();
+            expect(items.length).to.equal(1);
+            expect(items[0].contents()[0].getText()).to.equal('itemparagraph');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('itemparagraph', c))).to.equal(true);
+            expect(selection.offset).to.equal(4);
+        });
+        it('merges a list item with a list item', function() {
+            var c = getCanvasFromXML('<section><div class="list"><div class="item">item1</div><div class="item">item2</div></div></section>'),
+                list = c.wlxmlDocument.root.contents()[0],
+                k = new Keyboard(c);
+
+            k.withCaret('|item2').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(1);
+            
+            expect(rootContents[0].sameNode(list)).to.equal(true);
+
+            var items = list.contents();
+
+            expect(items.length).to.equal(1);
+            expect(items[0].contents()[0].getText()).to.equal('item1item2');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('item1item2', c))).to.equal(true);
+            expect(selection.offset).to.equal(5);
+        });
+        it('creates a new paragraph preceding the list from a first list item', function() {
+            var c = getCanvasFromXML('<section><div class="list"><div class="item">item1</div><div class="item">item2</div></div></section>'),
+                list = c.wlxmlDocument.root.contents()[0],
+                k = new Keyboard(c);
+
+            k.withCaret('|item1').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(2);
+            
+            expect(rootContents[0].getClass()).to.equal('p');
+            expect(rootContents[0].contents()[0].getText()).to.equal('item1');
+
+            expect(rootContents[1].sameNode(list)).to.equal(true);
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('item1', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+        it('removes list after moving up its only item', function() {
+            var c = getCanvasFromXML('<section><div class="list"><div class="item">item</div></div></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('|item').press(K.BACKSPACE);
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(1);
+            
+            expect(rootContents[0].getClass()).to.equal('p');
+            expect(rootContents[0].contents()[0].getText()).to.equal('item');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('item', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+    });
+
+    describe('backspace at the beginning of a span', function() {
+        afterEach(removeCanvas);
+
+        it('deletes span if it contains only one character', function() {
+            var c = getCanvasFromXML('<section>Alice<span class="emp">h</span>a cat</section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('h|').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(1);
+            expect(rootContents[0].getText()).to.equal('Alicea cat');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('Alicea cat', c))).to.equal(true);
+            expect(selection.offset).to.equal(5);
+        });
+
+        it('deletes from the end of the preceding text element', function() {
+            var c = getCanvasFromXML('<section>Alice<span>has a cat</span></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('|has a cat').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(2);
+            expect(rootContents[0].getText()).to.equal('Alic');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('has a cat', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+
+        it('deletes from the end of the preceding text element - multiple spans', function() {
+            var c = getCanvasFromXML('<section>Alice<span><span>has a cat</span></span></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('|has a cat').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(2);
+            expect(rootContents[0].getText()).to.equal('Alic');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('has a cat', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+
+        it('deletes from the end of the preceding span element content', function() {
+            var c = getCanvasFromXML('<section><span>Alice</span><span>has a cat</span></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('|has a cat').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(2);
+            expect(rootContents[0].is({tagName: 'span'})).to.equal(true);
+            expect(rootContents[0].contents()[0].getText()).to.equal('Alic');
+
+            expect(rootContents[1].contents()[0].getText()).to.equal('has a cat');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('has a cat', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+
+        it('deletes from the end of the preceding span element content - multiple spans', function() {
+            var c = getCanvasFromXML('<section><span>Alice</span><span><span>has a cat</span></span></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('|has a cat').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(2);
+            expect(rootContents[0].is({tagName: 'span'})).to.equal(true);
+            expect(rootContents[0].contents()[0].getText()).to.equal('Alic');
+
+            var outerSpan = rootContents[1];
+            expect(outerSpan.is({tagName: 'span'})).to.equal(true);
+
+            var innerSpan = outerSpan.contents()[0];
+            expect(innerSpan.contents()[0].getText()).to.equal('has a cat');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('has a cat', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+
+        it('merges two paragrahps if span is a first content of the second paragraph', function() {
+            var c = getCanvasFromXML('<section><div class="p">para</div><div class="p"><span>Alice</span> has a cat</div></section>'),
+                k = new Keyboard(c);
+            
+            k.withCaret('|Alice').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+
+            expect(rootContents.length).to.equal(1, 'single paragraph left');
+
+            var p = rootContents[0],
+                pContents = p.contents();
+
+            expect(p.is('p')).to.equal(true);
+
+            expect(pContents.length).to.equal(3);
+            expect(pContents[0].getText()).to.equal('para');
+            expect(pContents[1].contents().length).to.equal(1);
+            expect(pContents[1].contents()[0].getText()).to.equal('Alice');
+
+            expect(pContents[2].getText()).to.equal(' has a cat');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('Alice', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+    });
+
+    describe('backspace before a span', function() {
+        it('deletes from the end of a span', function() {
+            var c = getCanvasFromXML('<section><span>Alice</span>has a cat</section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('|has a cat').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(2);
+            expect(rootContents[0].is({tagName: 'span'})).to.equal(true);
+            expect(rootContents[0].contents()[0].getText()).to.equal('Alic');
+            expect(rootContents[1].getText()).to.equal('has a cat');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('has a cat', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+        it('deletes span if it contains only one character', function() {
+            var c = getCanvasFromXML('<section>Alice <span>h</span> a cat</section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('| a cat').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(1);
+            expect(rootContents[0].getText()).to.equal('Alice  a cat');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('Alice  a cat', c))).to.equal(true);
+            expect(selection.offset).to.equal(6);
+        });
+    });
+
+    describe('splitting with enter', function() {
+        afterEach(removeCanvas);
+
+        it('splits paragraph into two in the middle', function() {
+            var c = getCanvasFromXML('<section><div class="p">paragraph</div></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('para|graph').press(K.ENTER);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(2);
+            expect(rootContents[0].is({tagName: 'div', klass: 'p'})).to.equal(true);
+            expect(rootContents[0].contents()[0].getText()).to.equal('para');
+            expect(rootContents[1].is({tagName: 'div', klass: 'p'})).to.equal(true);
+            expect(rootContents[1].contents()[0].getText()).to.equal('graph');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('graph', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+        it('splits paragraph into two at the beginning', function() {
+            var c = getCanvasFromXML('<section><div class="p">paragraph</div></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('|paragraph').press(K.ENTER);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(2);
+            expect(rootContents[0].is({tagName: 'div', klass: 'p'})).to.equal(true);
+            expect(rootContents[0].contents()[0].getText()).to.equal('');
+            expect(rootContents[1].is({tagName: 'div', klass: 'p'})).to.equal(true);
+            expect(rootContents[1].contents()[0].getText()).to.equal('paragraph');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+        it('splits paragraph into two at the end', function() {
+            var c = getCanvasFromXML('<section><div class="p">paragraph</div></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('paragraph|').press(K.ENTER);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(2);
+            expect(rootContents[0].is({tagName: 'div', klass: 'p'})).to.equal(true);
+            expect(rootContents[0].contents()[0].getText()).to.equal('paragraph');
+            expect(rootContents[1].is({tagName: 'div', klass: 'p'})).to.equal(true);
+            expect(rootContents[1].contents()[0].getText()).to.equal('');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+
+        it('splits its parent box if inside a span', function() {
+            var c = getCanvasFromXML('<section><div class="p">this <span>is</span> a paragraph</div></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('i|s').press(K.ENTER);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+
+            expect(rootContents.length).to.equal(2);
+
+            var p1 = rootContents[0],
+                p2 = rootContents[1];
+
+            expect(p1.is({tagName: 'div', klass: 'p'})).to.equal(true);
+            expect(p2.is({tagName: 'div', klass: 'p'})).to.equal(true);
+
+            var p1Contents = p1.contents(),
+                p2Contents = p2.contents();
+
+            expect(p1Contents[0].getText()).to.equal('this ');
+            expect(p1Contents[1].is({tagName: 'span'})).to.equal(true);
+            expect(p1Contents[1].contents()[0].getText()).to.equal('i');
+
+            
+            expect(p2Contents[0].is({tagName: 'span'})).to.equal(true);
+            expect(p2Contents[0].contents()[0].getText()).to.equal('s');
+            expect(p2Contents[1].getText()).to.equal(' a paragraph');
+
+            var selection = c.getSelection();
+            expect(selection.element.sameNode(getTextElement('s', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+
+        it('splits its parent box if inside a double span', function() {
+            var c = getCanvasFromXML('<section><div class="p">this <span test="outer"><span test="inner">is</span></span> a paragraph</div></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('i|s').press(K.ENTER);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+
+            expect(rootContents.length).to.equal(2);
+
+            var p1 = rootContents[0],
+                p2 = rootContents[1];
+
+            expect(p1.is({tagName: 'div', klass: 'p'})).to.equal(true);
+            expect(p2.is({tagName: 'div', klass: 'p'})).to.equal(true);
+
+            var p1Contents = p1.contents(),
+                p2Contents = p2.contents();
+
+            /* first paragraph */
+            expect(p1Contents[0].getText()).to.equal('this ');
+            
+            var outer1 = p1Contents[1];
+            expect(outer1.getAttr('test')).to.equal('outer');
+            expect(outer1.contents().length).to.equal(1);
+            var inner1 = outer1.contents()[0];
+            expect(inner1.getAttr('test')).to.equal('inner');
+            expect(inner1.contents()[0].getText()).to.equal('i');
+
+            /* second paragraph */
+            var outer2 = p2Contents[0];
+            expect(outer2.getAttr('test')).to.equal('outer');
+            expect(outer2.contents().length).to.equal(1);
+            var inner2 = outer2.contents()[0];
+            expect(inner2.getAttr('test')).to.equal('inner');
+            expect(inner2.contents()[0].getText()).to.equal('s');
+
+            expect(p2Contents[1].getText()).to.equal(' a paragraph');
+
+            /* caret */
+            var selection = c.getSelection();
+            expect(selection.element.sameNode(getTextElement('s', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+    });
+
+    describe('Deleting text from a node', function() {
+        it('deletes last character with backspace', function() {
+            var c = getCanvasFromXML('<section><div class="p">a</div><div class="p">b</div></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('b|').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(2);
+            expect(rootContents[0].is({tagName: 'div', klass: 'p'})).to.equal(true);
+            expect(rootContents[0].contents()[0].getText()).to.equal('a');
+            expect(rootContents[1].is({tagName: 'div', klass: 'p'})).to.equal(true);
+            expect(rootContents[1].contents()[0].getText()).to.equal('');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+    });
+
+});
+
 
 });
\ No newline at end of file
index f907a20..6329491 100644 (file)
@@ -121,10 +121,13 @@ var documentNodeTransformations = {
 var elementNodeTransformations = {
 
     detach: function(params) {
-        var next;
-        params = _.extend({
-            normalizeStrategy: 'merge'
-        }, params);
+        var next, prev;
+
+        params = params || {};
+
+        if(!params.normalizeStrategy) {
+            params.normalizeStrategy = 'merge';
+        }
 
         if(this.parent() && this.isSurroundedByTextNodes()) {
             if(params.normalizeStrategy === 'detach-left') {
@@ -133,7 +136,12 @@ var elementNodeTransformations = {
                 this.next().detach();
             } else if(params.normalizeStrategy === 'merge') {
                 next = this.next();
-                this.prev().appendText(next.getText());
+                prev = this.prev();
+                params.ret = {
+                    mergedTo: prev,
+                    previousLen: prev.getText().length
+                };
+                prev.appendText(next.getText());
                 next.detach();
             } else {
                 throw new Error('unknown normalize strategy for detach');
index c0b9aa4..c158bac 100644 (file)
@@ -250,11 +250,21 @@ $.extend(ElementNode.prototype, {
         return node && (node.nativeNode === this.nativeNode || node._$.parents().index(this._$) !== -1);
     },
 
+    getFirstTextNode: function() {
+        return this._getTextNode('first');
+    },
+
     getLastTextNode: function() {
+        return this._getTextNode('last');
+    },
+
+    _getTextNode: function(which) {
         var contents = this.contents(),
             toret;
-
-        contents.reverse().some(function(node) {
+        if(which === 'last') {
+            contents = contents.reverse();
+        }
+        contents.some(function(node) {
             if(node.nodeType === Node.TEXT_NODE) {
                 toret = node;
                 return true;