editor: Improved use of contenteditable for a caret support
authorAleksander Łukasz <aleksander.lukasz@nowoczesnapolska.org.pl>
Thu, 24 Jul 2014 14:00:48 +0000 (16:00 +0200)
committerAleksander Łukasz <aleksander.lukasz@nowoczesnapolska.org.pl>
Thu, 14 Aug 2014 14:01:45 +0000 (16:01 +0200)
This change stops declaring the whole canvas as being contenteditable=true,
marks as such only current canvas text element instead.

This approach has many advantages:

1. We no longer need to handle unspecified caret behavior, where caret
could be put anywhere in the canvas DOM tree (outside of canvas text
elements in particular) which needed tricky corrections on the fly.

2. Canvas widgets and custom canvas elements no longer need to
use contenteditable=false nesting inside contenteditable=true,
effects of which are not fully specified and which was leading
to many unexpected behaviors, especially in Firefox.

3. It makes it possible the make a selection across any fragment of
a document (contenteditable=true elements nested in contentedtiable=false
are blocking selection crossing its borders from the inside).

The downside is the need to handle keyboard navigation on the borders
of canvas text elements manually.

src/editor/modules/documentCanvas/canvas/canvas.html
src/editor/modules/documentCanvas/canvas/canvas.js
src/editor/modules/documentCanvas/canvas/documentElement.js
src/editor/modules/documentCanvas/canvas/keyboard.js
src/editor/modules/documentCanvas/canvas/utils.js
src/editor/modules/documentCanvas/documentCanvas.less
src/editor/plugins/core/links/box.html

index e437d42..3eada65 100644 (file)
@@ -1,5 +1,5 @@
 <div class="view-table">
     <div class="view-row">
-        <div class="root-wrapper" contenteditable="true" spellcheck="false"></div>
+        <div class="root-wrapper" spellcheck="false"></div>
     </div>
 </div>
\ No newline at end of file
index d8090c8..08df11f 100644 (file)
@@ -159,16 +159,21 @@ $.extend(Canvas.prototype, Backbone.Events, {
         var mouseDown;
         this.rootWrapper.on('mousedown', '[document-node-element], [document-text-element]', function(e) {
             mouseDown = e.target;
+            canvas.rootWrapper.find('[contenteditable]').attr('contenteditable', null);
         });
 
         this.rootWrapper.on('click', '[document-node-element], [document-text-element]', function(e) {
+            var position;
             e.stopPropagation();
             if(e.originalEvent.detail === 3) {
                 e.preventDefault();
                 canvas._moveCaretToTextElement(canvas.getDocumentElement(e.currentTarget), 'whole');
             } else {
                 if(mouseDown === e.target) {
-                    canvas.setCurrentElement(canvas.getDocumentElement(e.currentTarget), {caretTo: false});
+                    if(window.getSelection().isCollapsed) {
+                        position = utils.caretPositionFromPoint(e.clientX, e.clientY);
+                        canvas.setCurrentElement(canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
+                    }
                 }
             }
         });
@@ -409,9 +414,10 @@ $.extend(Canvas.prototype, Backbone.Events, {
         }
         var selection = document.getSelection();
 
+        $(node).parent().attr('contenteditable', true);
         selection.removeAllRanges();
         selection.addRange(range);
-        this.rootWrapper.focus(); // FF requires this for caret to be put where range colllapses, Chrome doesn't.
+        $(node).parent().focus(); // FF requires this for caret to be put where range colllapses, Chrome doesn't.
     },
 
     setCursorPosition: function(position) {
index 50a188a..5e653b8 100644 (file)
@@ -139,8 +139,7 @@ $.extend(DocumentNodeElement.prototype, {
     createDOM: function() {
         var wrapper = $('<div>').attr('document-node-element', ''),
             widgetsContainer = $('<div>')
-                .addClass('canvas-widgets')
-                .attr('contenteditable', false),
+                .addClass('canvas-widgets'),
             contentContainer = $('<div>')
                 .attr('document-element-content', '');
         
index fd964e0..eafd091 100644 (file)
@@ -1,7 +1,8 @@
 define([
+'libs/jquery',
 'modules/documentCanvas/canvas/documentElement',
 'modules/documentCanvas/canvas/utils'
-], function(documentElement, utils) {
+], function($, documentElement, utils) {
     
 'use strict';
 /* globals gettext */
@@ -36,6 +37,55 @@ var handles = function(handler, event) {
     return false;
 };
 
+
+var scroll = function(place, textElement) {
+    var rect = textElement.getBoundingClientRect(),
+        scroll = $('#rng-module-documentCanvas-contentWrapper'),
+        border = rect.bottom - (place === 'top' ? rect.height : 0) - scroll.offset().top + scroll[0].scrollTop,
+        visible = scroll[0].scrollTop + {top: 0, bottom: scroll.height()}[place],
+        padding = 16,
+        toScroll = 0;
+    
+    if(place === 'top' && (border - padding < visible)) {
+        toScroll =  border - visible - padding;
+    } else if(place === 'bottom' && (border + padding > visible))  {
+        toScroll = border - visible + padding;
+    }
+    if(toScroll) {
+        scroll[0].scrollTop = scroll[0].scrollTop + toScroll;
+    }
+    return toScroll;
+};
+
+var getLastRectAbove = function(node, y) {
+    var rects = node.getClientRects(),
+        idx = 0,
+        rect, toret;
+    while((rect = rects[idx])) {
+        if(rect.bottom < y) {
+            toret = rect;
+        } else {
+            break;
+        }
+        idx++;
+    }
+    return toret;
+};
+
+var getFirstRectBelow = function(node, y) {
+    var rects = node.getClientRects(),
+        idx = 0,
+        rect, toret;
+    while((rect = rects[idx])) {
+        if(rect.top > y) {
+            toret = rect;
+            break;
+        }
+        idx++;
+    }
+    return toret;
+};
+
 var handlers = [];
 
 
@@ -121,51 +171,106 @@ handlers.push({key: KEYS.ENTER,
     }
 });
 
-handlers.push({keys: [KEYS.ARROW_UP, KEYS.ARROW_DOWN, KEYS.ARROW_LEFT, KEYS.ARROW_RIGHT],
+handlers.push({keys: [KEYS.ARROW_UP],
     keydown: function(event, canvas) {
-        var position = canvas.getCursor().getPosition(),
-            element = position.element;
-        if(element && (element instanceof documentElement.DocumentTextElement) && element.isEmpty()) {
-            var direction, caretTo;
-            if(event.which === KEYS.ARROW_LEFT  || event.which === KEYS.ARROW_UP) {
-                direction = 'above';
-                caretTo = 'end';
-            } else {
-                direction = 'below';
-                caretTo = 'start';
-            }
-            var el = canvas.getDocumentElement(utils.nearestInDocumentOrder('[document-text-element]', direction, element.dom[0]));
-            if(el) {
-                canvas.setCurrentElement(el, {caretTo: caretTo});
+        /* 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;
+                }
+            });
+            if(target) {
+                scrolled = scroll('top', target);
+                position = utils.caretPositionFromPoint(caretRect.left, rect.bottom - 1 - scrolled);
+                canvas.setCurrentElement(canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
             }
         }
-    },
-    keyup: function(event, canvas) {
+    }
+});
+
+handlers.push({keys: [KEYS.ARROW_DOWN],
+    keydown: function(event, canvas) {
+        /* globals window */
         var element = canvas.getCursor().getPosition().element,
-            caretTo = false;
-        if(!element) {
-            // Chrome hack
+            caretRect = window.getSelection().getRangeAt(0).getClientRects()[0],
+            frameRects = element.dom[0].getClientRects(),
+            lastRect = frameRects[frameRects.length-1],
+            position, target,rect, scrolled;
 
-            var moves = [{direction: 'above', caretTo: 'end'}, {direction: 'below', caretTo: 'start'}];
-            if(event.which === KEYS.ARROW_RIGHT  || event.which === KEYS.ARROW_DOWN) {
-                moves.reverse();
-            }
-            moves.some(function(move) {
-                /* globals window */
-                var targetNode = utils.nearestInDocumentOrder('[document-text-element]:visible', move.direction, window.getSelection().focusNode);
-                if(targetNode) {
-                    element = canvas.getDocumentElement(targetNode);
-                    caretTo = move.caretTo;
-                    return true; // break
+        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;
                 }
             });
+            if(target) {
+                scrolled = scroll('bottom', target);
+                position = utils.caretPositionFromPoint(caretRect.left, rect.top +1 - scrolled);
+                canvas.setCurrentElement(canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
+            }
         }
-        if(element) {
-            canvas.setCurrentElement(element, {caretTo: caretTo});
+    }
+});
+
+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'});
+            }
         }
     }
 });
 
+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});
+            }
+        } 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'});
+            }
+        }
+
+    }
+});
 
 var selectsWholeTextElement = function(cursor) {
     if(cursor.isSelecting() && cursor.getSelectionStart().offsetAtBeginning && cursor.getSelectionEnd().offsetAtEnd) {
index 058ede8..620e652 100644 (file)
@@ -42,6 +42,23 @@ var getElementForDetachedNode = function(node, originalParent) {
     return ptr.getData('canvasElement');
 };
 
+var caretPositionFromPoint = function(x, y) {
+    /* globals document */
+    var range, textNode, offset;
+    if(document.caretPositionFromPoint) {
+        range = document.caretPositionFromPoint(x, y);
+        textNode = range.offsetNode;
+        offset = range.offset;
+    } else if(document.caretRangeFromPoint) {
+        range = document.caretRangeFromPoint(x, y);
+        textNode = range.startContainer;
+        offset = range.startOffset;
+    }
+    return {
+        textNode: textNode,
+        offset: offset
+    };
+};
 
 
 return {
@@ -51,7 +68,8 @@ return {
     },
     getElementForNode: getElementForNode,
     getElementForDetachedNode: getElementForDetachedNode,
-    getElementForElementRootNode: getElementForElementRootNode
+    getElementForElementRootNode: getElementForElementRootNode,
+    caretPositionFromPoint: caretPositionFromPoint
 };
 
 });
index 879d78d..3d335dc 100644 (file)
         box-shadow: 0 0 20px rgba(0, 0, 0, 0.2), inset 0 0 10px rgba(0, 0, 0, 0.1);
     }
 
+    [document-text-element] {
+        outline: none;
+    }
+
     .current-text-element {
     }
 
index c836ccc..08bc484 100644 (file)
@@ -1,4 +1,4 @@
-<div contenteditable="false" link-box style="white-space: nowrap">
+<div link-box style="white-space: nowrap">
     <a link target="blank" href="<%= url %>"><%= text %></a> <br/>--
     <span>
         <a class="change" href="#"><%= gettext('change') %></a> |