Merging in actions branch
authorAleksander Łukasz <aleksander.lukasz@nowoczesnapolska.org.pl>
Wed, 23 Apr 2014 13:53:54 +0000 (15:53 +0200)
committerAleksander Łukasz <aleksander.lukasz@nowoczesnapolska.org.pl>
Wed, 23 Apr 2014 13:53:54 +0000 (15:53 +0200)
- actions api
- new rules for canvas & toolbar behavior

39 files changed:
src/editor/modules.js
src/editor/modules/documentCanvas/canvas/canvas.js
src/editor/modules/documentCanvas/canvas/keyboard.js
src/editor/modules/documentCanvas/canvas/wlxmlListener.js
src/editor/modules/documentCanvas/commands.js [deleted file]
src/editor/modules/documentCanvas/documentCanvas.js
src/editor/modules/documentToolbar/actionView.js [new file with mode: 0644]
src/editor/modules/documentToolbar/documentToolbar.js
src/editor/modules/documentToolbar/documentToolbar.less
src/editor/modules/documentToolbar/template.html
src/editor/modules/documentToolbar/templates/actionView.html [new file with mode: 0644]
src/editor/modules/documentToolbar/templates/actionViewButton.html [new file with mode: 0644]
src/editor/modules/documentToolbar/templates/actionViewSelection.html [new file with mode: 0644]
src/editor/modules/metadataEditor/metadataEditor.js
src/editor/modules/nodeBreadCrumbs/nodeBreadCrumbs.js
src/editor/modules/nodeBreadCrumbs/template.html
src/editor/modules/nodeFamilyTree/nodeFamilyTree.js
src/editor/modules/nodePane/nodePane.js
src/editor/modules/rng/mainLayout.html
src/editor/modules/rng/mainLayout.less
src/editor/modules/rng/rng.js
src/editor/modules/statusBar/statusBar.html [new file with mode: 0644]
src/editor/modules/statusBar/statusBar.js [new file with mode: 0644]
src/editor/modules/statusBar/statusBar.less [new file with mode: 0644]
src/editor/plugins/core/core.js
src/editor/plugins/core/footnote.js [new file with mode: 0644]
src/editor/plugins/core/lists.js [new file with mode: 0644]
src/editor/plugins/core/switch.js [new file with mode: 0644]
src/editor/plugins/core/templates.js [new file with mode: 0644]
src/editor/styles/main.less
src/fnpjs/actions.js [new file with mode: 0644]
src/fnpjs/runner.js
src/smartxml/fragments.js [new file with mode: 0644]
src/smartxml/fragments.test.js [new file with mode: 0644]
src/smartxml/smartxml.js
src/smartxml/smartxml.test.js
src/wlxml/extensions/list/list.js
src/wlxml/wlxml.js
tests/main.js

index f7c5f6e..6b92a5e 100644 (file)
@@ -10,6 +10,7 @@ define(function(require) {
         data: require('modules/data/data'),
         rng: require('modules/rng/rng'),
         mainBar: require('modules/mainBar/mainBar'),
+        statusBar: require('modules/statusBar/statusBar'),
         indicator: require('modules/indicator/indicator'),
         
         sourceEditor: require('modules/sourceEditor/sourceEditor'),
index b217d18..d58ea7b 100644 (file)
@@ -10,7 +10,7 @@ define([
 ], function($, _, Backbone, logging, documentElement, keyboard, utils, wlxmlListener) {
     
 'use strict';
-/* global document:false, window:false, Node:false */
+/* global document:false, window:false, Node:false, gettext */
 
 var logger = logging.getLogger('canvas');
 
@@ -42,24 +42,29 @@ $.extend(TextHandler.prototype, {
     },
     setText: function(text, node) {
         //this.canvas.wlxmlDocument.transform('setText', {node:node, text: text});
-        node.setText(text);
+        node.document.transaction(function() {
+            node.setText(text);
+        }, {
+            metadata:{
+                description: gettext('Changing text')
+            }
+        });
 
     }
 
 });
 
 
-var Canvas = function(wlxmlDocument, publisher) {
+var Canvas = function(wlxmlDocument) {
     this.eventBus = _.extend({}, Backbone.Events);
     this.wrapper = $('<div>').addClass('canvas-wrapper').attr('contenteditable', true);
     this.wlxmlListener = wlxmlListener.create(this);
     this.loadWlxmlDocument(wlxmlDocument);
     this.setupEventHandling();
-    this.publisher = publisher ? publisher : function() {};
     this.textHandler = new TextHandler(this);
 };
 
-$.extend(Canvas.prototype, {
+$.extend(Canvas.prototype, Backbone.Events, {
 
     loadWlxmlDocument: function(wlxmlDocument) {
         if(!wlxmlDocument) {
@@ -104,9 +109,14 @@ $.extend(Canvas.prototype, {
 
     setupEventHandling: function() {
         var canvas = this;
+
         this.wrapper.on('keyup keydown keypress', function(e) {
-            keyboard.handleKey(e, this);
-        }.bind(this));
+            keyboard.handleKey(e, canvas);
+        });
+
+        this.wrapper.on('mouseup', function() {
+            canvas.triggerSelectionChanged();
+        });
 
         var mouseDown;
         this.wrapper.on('mousedown', '[document-node-element], [document-text-element]', function(e) {
@@ -244,6 +254,14 @@ $.extend(Canvas.prototype, {
         return element.dom().parents().index(this.wrapper) !== -1;
     },
 
+    triggerSelectionChanged: function() {
+        this.trigger('selectionChanged', this.getSelection());
+    },
+
+    getSelection: function() {
+        return new Selection(this);
+    },
+
     setCurrentElement: function(element, params) {
         if(!element) {
             logger.debug('Invalid element passed to setCurrentElement: ' + element);
@@ -299,18 +317,14 @@ $.extend(Canvas.prototype, {
             if(params.caretTo || !textElementToLand.sameNode(this.getCursor().getPosition().element)) {
                 this._moveCaretToTextElement(textElementToLand, params.caretTo); // as method on element?
             }
-            if(!(textElementToLand.sameNode(currentTextElement))) {
-                this.publisher('currentTextElementSet', textElementToLand.wlxmlNode);
-            }
         } else {
             document.getSelection().removeAllRanges();
         }
 
         if(!(currentNodeElement && currentNodeElement.sameNode(nodeElementToLand))) {
             _markAsCurrent(nodeElementToLand);
-
-            this.publisher('currentNodeElementSet', nodeElementToLand.wlxmlNode);
         }
+        this.triggerSelectionChanged();
     },
 
     _moveCaretToTextElement: function(element, where) {
@@ -320,7 +334,7 @@ $.extend(Canvas.prototype, {
         if(typeof where !== 'number') {
             range.selectNodeContents(node);
         } else {
-            range.setStart(node, where);
+            range.setStart(node, Math.min(node.data.length, where));
         }
         
         if(where !== 'whole') {
@@ -341,15 +355,103 @@ $.extend(Canvas.prototype, {
         if(position.element) {
             this._moveCaretToTextElement(position.element, position.offset);
         }
+    },
+
+    findCanvasElement: function(node) {
+        return utils.findCanvasElement(node);
+    },
+
+    toggleGrid: function() {
+        this.wrapper.toggleClass('grid-on');
+        this.trigger('changed');
+    },
+    isGridToggled: function() {
+        return this.wrapper.hasClass('grid-on');
     }
 });
 
 
+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.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();
 };
 
 $.extend(Cursor.prototype, {
+    sameAs: function(other) {
+        var same = true;
+        if(!other) {
+            return false;
+        }
+
+        ['focusNode', 'focusOffset', 'anchorNode', 'anchorOffset'].some(function(prop) {
+            same = same && this.selection[prop] === other.selection[prop];
+            if(!same) {
+                return true; // break
+            }
+        }.bind(this));
+
+        return same;
+    },
     isSelecting: function() {
         var selection = window.getSelection();
         return !selection.isCollapsed;
@@ -456,8 +558,8 @@ $.extend(Cursor.prototype, {
 });
 
 return {
-    fromXMLDocument: function(wlxmlDocument, publisher) {
-        return new Canvas(wlxmlDocument, publisher);
+    fromXMLDocument: function(wlxmlDocument) {
+        return new Canvas(wlxmlDocument);
     }
 };
 
index 383ca18..fc8be1e 100644 (file)
@@ -4,7 +4,7 @@ define([
 ], function(documentElement, utils) {
     
 'use strict';
-
+/* globals gettext */
 
 var KEYS = {
     ENTER: 13,
@@ -44,20 +44,27 @@ handlers.push({key: KEYS.ENTER,
         event.preventDefault();
         var cursor = canvas.getCursor(),
             position = cursor.getPosition(),
-            element = position.element,
-            added;
+            element = position.element;
 
         if(Object.keys(cursor.getPosition()).length === 0) {
             var currentElement = canvas.getCurrentNodeElement();
             if(currentElement) {
-                canvas.wlxmlDocument.startTransaction();
-                added = currentElement.wlxmlNode.after({
-                    tagName: currentElement.getWlxmlTag() || 'div',
-                    attrs: {'class': currentElement.getWlxmlClass() || 'p'}
+                canvas.wlxmlDocument.transaction(function() {
+                    var added = currentElement.wlxmlNode.after({
+                        tagName: currentElement.getWlxmlTag() || 'div',
+                        attrs: {'class': currentElement.getWlxmlClass() || 'p'}
+                    });
+                    added.append({text:''});
+                    return added;
+                }, {
+                    metadata: {
+                        description: gettext('Splitting text')
+                    },
+                    success: function(ret) {
+                        canvas.setCurrentElement(utils.findCanvasElement(ret), {caretTo: 'start'});
+                    }
                 });
-                added.append({text:''});
-                canvas.wlxmlDocument.endTransaction();
-                canvas.setCurrentElement(utils.findCanvasElement(added), {caretTo: 'start'});
+
             }
             return;
         }
@@ -68,13 +75,20 @@ handlers.push({key: KEYS.ENTER,
                     element = element.parent();
                 }
 
-                canvas.wlxmlDocument.startTransaction();
-                added = element.wlxmlNode.after(
-                    {tagName: element.getWlxmlTag() || 'div', attrs: {'class': element.getWlxmlClass() || 'p'}}
-                );
-                added.append({text: ''});
-                canvas.wlxmlDocument.endTransaction();
-                canvas.setCurrentElement(utils.findCanvasElement(added), {caretTo: 'start'});
+                canvas.wlxmlDocument.transaction(function() {
+                    var added = element.wlxmlNode.after(
+                        {tagName: element.getWlxmlTag() || 'div', attrs: {'class': element.getWlxmlClass() || 'p'}}
+                    );
+                    added.append({text: ''});
+                    return added;
+                }, {
+                    metadata: {
+                        description: gettext('Splitting text')
+                    },
+                    success: function(ret) {
+                        canvas.setCurrentElement(utils.findCanvasElement(ret), {caretTo: 'start'});
+                    }
+                });
 
             } else {
 
@@ -100,9 +114,17 @@ handlers.push({key: KEYS.ENTER,
                 //     goto = nodes.second;
                 //     gotoOptions = {caretTo: 'start'};
                 // }
+                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')
+                    }
+                });
 
-                var result = position.element.wlxmlNode.breakContent({offset: position.offset}),
-                    goto, gotoOptions;
                 if(result.emptyText) {
                     goto = result.emptyText;
                     gotoOptions = {};
@@ -280,6 +302,10 @@ handlers.push({keys: [KEYS.BACKSPACE, KEYS.DELETE],
                 }
                 event.preventDefault();
             }
+        }, {
+            metadata: {
+                description: gettext('Remove text')
+            }
         });
     }
 });
index f932ea0..7d19e94 100644 (file)
@@ -23,6 +23,10 @@ $.extend(Listener.prototype, {
             }
         }, this);
 
+        wlxmlDocument.on('operationEnd', function() {
+            this.canvas.triggerSelectionChanged();
+        }, this);
+
         wlxmlDocument.on('contentSet', function() {
             this.canvas.loadWlxmlDocument(wlxmlDocument);
         }, this);
diff --git a/src/editor/modules/documentCanvas/commands.js b/src/editor/modules/documentCanvas/commands.js
deleted file mode 100644 (file)
index 85831d8..0000000
+++ /dev/null
@@ -1,338 +0,0 @@
-define([
-'./canvas/utils',
-'views/dialog/dialog',
-'fnpjs/datetime'
-], function(utils, Dialog, datetime) {
-    
-'use strict';
-/* globals gettext */
-
-
-var gridToggled = false;
-
-var commands = {
-    _cmds: {},
-    register: function(name, command) {
-        this._cmds[name] = command;
-    },
-
-    run: function(name, params, canvas, user) {
-        return this._cmds[name](canvas, params, user);
-    }
-};
-
-commands.register('undo', function(canvas) {
-    var doc = canvas.wlxmlDocument;
-
-    doc.undo();
-});
-
-commands.register('redo', function(canvas) {
-    var doc = canvas.wlxmlDocument;
-
-    doc.redo();
-});
-
-commands.register('remove-node', function(canvas) {
-    canvas.getCurrentNodeElement().wlxmlNode.detach();
-});
-
-commands.register('unwrap-node', function(canvas) {
-    var cursor = canvas.getCursor(),
-        selectionStart = cursor.getSelectionStart(),
-        selectionEnd = cursor.getSelectionEnd(),
-        parent1 = selectionStart.element.parent() || undefined,
-        parent2 = selectionEnd.element.parent() || undefined;
-
-    var selectionAnchor = cursor.getSelectionAnchor(),
-        node1 = parent1.wlxmlNode,
-        node2 = parent2.wlxmlNode,
-        doc = node1.document;
-    if(doc.areItemsOfSameList({node1: node1, node2: node2})) {
-        doc.extractItems({item1: node1, item2: node2});
-        canvas.setCurrentElement(selectionAnchor.element, {caretTo: selectionAnchor.offset});
-    } else if(!cursor.isSelecting()) {
-        var nodeToUnwrap = cursor.getPosition().element.wlxmlNode,
-            parentNode = nodeToUnwrap.unwrap();
-        if(parentNode) {
-            canvas.setCurrentElement(utils.findCanvasElement(parentNode));
-        }
-    }
-});
-
-commands.register('wrap-node', function(canvas) {
-    var cursor = canvas.getCursor(),
-        selectionStart = cursor.getSelectionStart(),
-        selectionEnd = cursor.getSelectionEnd(),
-        parent1 = selectionStart.element.parent() || undefined,
-        parent2 = selectionEnd.element.parent() || undefined;
-
-    var node1 = parent1.wlxmlNode,
-        node2 = parent2.wlxmlNode,
-        doc = node1.document;
-
-    if(doc.areItemsOfSameList({node1: node1, node2: node2})) {
-        doc.createList({node1: node1, node2: node2});
-    }
-});
-
-commands.register('list', function(canvas, params) {
-    void(params);
-    var cursor = canvas.getCursor(),
-        selectionStart = cursor.getSelectionStart(),
-        selectionEnd = cursor.getSelectionEnd(),
-        parent1 = selectionStart.element.parent() || undefined,
-        parent2 = selectionEnd.element.parent() || undefined,
-        selectionFocus = cursor.getSelectionFocus(),
-        node1 = parent1.wlxmlNode,
-        node2 = parent2.wlxmlNode,
-        doc = node1.document;
-    
-    if(cursor.isSelecting()) {
-        doc.transaction(function() {
-            doc.createList({node1: node1, node2: node2, klass: params.meta === 'num' ? 'list.enum' : 'list'});
-        }, {
-            success: function() {
-                canvas.setCurrentElement(selectionFocus.element, {caretTo: selectionFocus.offset});
-            }
-        });
-    } else {
-        var list;
-        if(node1.isInside('list')) {
-            list = node1.getParent('list');
-            if((params.meta === 'num' && list.getClass() === 'list.enum') || params.meta !== 'num' && list.getClass() === 'list') {
-                list.object.extractAllItems();
-            } else {
-                list.setClass(params.meta === 'num' ? 'list.enum' : 'list');
-            }
-        }
-    }
-});
-
-commands.register('toggle-grid', function(canvas, params) {
-    canvas.doc().dom().parent().toggleClass('grid-on', params.toggle);
-    gridToggled = params.toggle;
-});
-
-commands.register('newNodeRequested', function(canvas, params, user) {
-    var cursor = canvas.getCursor(),
-        selectionStart = cursor.getSelectionStart(),
-        selectionEnd = cursor.getSelectionEnd(),
-        wlxmlNode, caretTo, wrapperCanvasElement;
-
-    var insertNode = function(insertion, callback) {
-        var doc = canvas.wlxmlDocument,
-            metadata, creator, dialog;
-
-        var execCallback = function(node) {
-            if(callback) {
-                callback(node);
-            }
-        };
-
-        if(params.wlxmlTag === 'aside' && params.wlxmlClass === 'comment') {
-            doc.transaction(function() {
-                var node = insertion();
-                if(user) {
-                    creator = user.name;
-                    if(user.email) {
-                        creator += ' (' + user.email + ')';
-                    }
-                } else {
-                    creator = 'anonymous';
-                }
-
-                metadata = node.getMetadata();
-                metadata.add({key: 'creator', value: creator});
-                metadata.add({key: 'date', value: datetime.currentStrfmt()});
-                return node;
-            }, {
-                success: execCallback
-            });
-        } else if(params.wlxmlClass === 'link') {
-            dialog = Dialog.create({
-                title: gettext('Create link'),
-                executeButtonText: gettext('Apply'),
-                cancelButtonText: gettext('Cancel'),
-                fields: [
-                    {label: gettext('Link'), name: 'href', type: 'input'}
-                ]
-            });
-            dialog.on('execute', function(event) {
-                doc.transaction(function() {
-                    var node = insertion();
-                    node.setAttr('href', event.formData.href);
-                    event.success();
-                    return node;
-                }, {
-                    success: execCallback
-                });
-            });
-            dialog.show();
-        } else {
-            doc.transaction(function() {
-                return insertion();
-            }, {success: execCallback});
-        }
-    };
-
-    if(cursor.isSelecting()) {
-        if(cursor.isSelectingSiblings()) {
-            if(cursor.isSelectingWithinElement()) {
-                wlxmlNode = selectionStart.element.wlxmlNode;
-                caretTo = selectionStart.offset < selectionEnd.offset ? 'start' : 'end';
-
-                insertNode(
-                    function() {
-                        return wlxmlNode.wrapWith({tagName: params.wlxmlTag, attrs: {'class': params.wlxmlClass}, start: selectionStart.offset, end: selectionEnd.offset});
-                    },
-                    function(wrapper) {
-                        wrapperCanvasElement = utils.findCanvasElement(wrapper);
-                        canvas.setCurrentElement(wrapperCanvasElement.children()[0], {caretTo: caretTo});
-                    }
-                );
-            }
-            else {
-                wlxmlNode = selectionStart.element.wlxmlNode.parent();
-                caretTo = selectionStart.element.sameNode(cursor.getSelectionAnchor().element) ? 'end' : 'start';
-
-                insertNode(
-                    function() {
-                        return wlxmlNode.wrapText({
-                            _with: {tagName: params.wlxmlTag, attrs: {'class': params.wlxmlClass}},
-                            offsetStart: selectionStart.offset,
-                            offsetEnd: selectionEnd.offset,
-                            textNodeIdx: [wlxmlNode.indexOf(selectionStart.element.wlxmlNode), wlxmlNode.indexOf(selectionEnd.element.wlxmlNode)] //parent.childIndex(selectionEnd.element)]
-                        });
-                    },
-                    function(wrapper) {
-                        wrapperCanvasElement = utils.findCanvasElement(wrapper);
-                        canvas.setCurrentElement(wrapperCanvasElement.children()[caretTo === 0 ? 0 : wrapperCanvasElement.children().length - 1], {caretTo: caretTo});
-                    }
-                );
-            }
-        } else {
-            var node1 = selectionStart.element.wlxmlNode,
-                node2 = selectionEnd.element.wlxmlNode,
-                siblingParents = canvas.wlxmlDocument.getSiblingParents({node1: node1, node2: node2});
-
-            if(siblingParents) {
-                insertNode(
-                    function() {
-                        return canvas.wlxmlDocument.wrapNodes({
-                            node1: siblingParents.node1,
-                            node2: siblingParents.node2,
-                            _with: {tagName: params.wlxmlTag, attrs: {'class': params.wlxmlClass}}
-                        });
-                    }
-                );
-            }
-        }
-    } else if(canvas.getCurrentNodeElement()) {
-        wlxmlNode = canvas.getCurrentNodeElement().wlxmlNode;
-
-        var linkFound = [wlxmlNode].concat(wlxmlNode.parents()).some(function(node) {
-            if(node.getClass() === 'link') {
-                var dialog = Dialog.create({
-                    title: gettext('Edit link'),
-                    executeButtonText: gettext('Apply'),
-                    cancelButtonText: gettext('Cancel'),
-                    fields: [
-                        {label: gettext('Link'), name: 'href', type: 'input', initialValue: node.getAttr('href')},
-                    ]
-                });
-                dialog.on('execute', function(event) {
-                    canvas.wlxmlDocument.transaction(function() {
-                        node.setAttr('href', event.formData.href);
-                        event.success();
-                    });
-                });
-                dialog.show();
-                return true;
-            }
-        });
-        if(linkFound) {
-            return;
-        }
-
-        if(params.ctrlKey) {
-            insertNode(
-                function() {
-                    return wlxmlNode.wrapWith({tagName: params.wlxmlTag, attrs: {'class': params.wlxmlClass}});
-                },
-                function(wrapper) {
-                    canvas.setCurrentElement(utils.findCanvasElement(wrapper));
-                }
-            );
-        } else {
-            insertNode(
-                function() {
-                    var node = wlxmlNode.after({tagName: params.wlxmlTag, attrs: {'class': params.wlxmlClass}});
-                    node.append({text:''});
-                    return node;
-                }, function(wrapper) {
-                    canvas.setCurrentElement(utils.findCanvasElement(wrapper));
-                }
-            );
-        }
-    }
-});
-
-commands.register('footnote', function(canvas, params) {
-    void(params);
-    var cursor = canvas.getCursor(),
-        position = cursor.getPosition(),
-        asideNode, asideElement, node;
-        
-
-    if(cursor.isSelectingWithinElement()) {
-        asideNode = position.element.wlxmlNode.wrapWith({tagName: 'aside', attrs:{'class': 'footnote'}, start: cursor.getSelectionStart().offset, end: cursor.getSelectionEnd().offset});
-    } else {
-        node = position.element.wlxmlNode;
-        node.document.transaction(function() {
-            asideNode = node.divideWithElementNode({tagName: 'aside', attrs:{'class': 'footnote'}}, {offset: position.offset});
-            asideNode.append({text: ''});
-        });
-    }
-
-    asideElement = utils.findCanvasElement(asideNode);
-    asideElement.toggle(true);
-    canvas.setCurrentElement(asideElement);
-});
-
-commands.register('take-away-node', function(canvas) {
-    var position = canvas.getCursor().getPosition(),
-        element = position.element,
-        nodeElement = element ? element.parent() : canvas.getCurrentNodeElement();
-
-    if(!nodeElement || !(nodeElement.parent())) {
-        return;
-    }
-
-    var range = nodeElement.wlxmlNode.unwrapContent();
-
-    if(element) {
-        var elementIsFirstChild = nodeElement.childIndex(element);
-        if(element.bound()) {
-            canvas.setCurrentElement(element, {caretTo: position.offset});
-        } else {
-            if(elementIsFirstChild) {
-                canvas.setCurrentElement(utils.findCanvasElement(range.element1), {caretTo: 'end'});
-            } else {
-                canvas.setCurrentElement(utils.findCanvasElement(range.element2), {caretTo: 'end'});
-            }
-        }
-    } else {
-        canvas.setCurrentElement(utils.findCanvasElement(range.element1), {caretTo: 'start'});
-    }
-
-});
-
-
-return {
-    run: function(name, params, canvas, user) {
-        return commands.run(name, params, canvas, user);
-    }
-};
-
-});
\ No newline at end of file
index 6015089..9a88b75 100644 (file)
@@ -2,20 +2,30 @@
 
 define([
 'libs/jquery',
+'libs/underscore',
+'fnpjs/logging/logging',
 './canvas/canvas',
-'./commands',
-'libs/text!./template.html'], function($, canvas3, commands, template) {
+'libs/text!./template.html'], function($, _, logging, canvas3, template) {
 
 'use strict';
 
+
+var logger = logging.getLogger('documentCanvas');
+
 return function(sandbox) {
 
-    var canvas = canvas3.fromXMLDocument(null, sandbox.publish);
+    var canvas = canvas3.fromXMLDocument(null);
     var canvasWrapper = $(template);
     var shownAlready = false;
     var scrollbarPosition = 0,
+        actionHandlers = {},
         cursorPosition;
+        
     
+    canvas.on('selectionChanged', function(selection) {
+        sandbox.publish('selectionChanged', selection);
+    });
+
     canvasWrapper.onShow = function() {
         if(!shownAlready) {
             shownAlready = true;
@@ -33,10 +43,25 @@ return function(sandbox) {
 
     /* public api */
     return {
-        start: function() { sandbox.publish('ready'); },
+        start: function() {
+            sandbox.getPlugins().forEach(function(plugin) {
+                var handlers;
+                if(plugin.canvas) {
+                    handlers = plugin.canvas.actionHandlers;
+                    if(handlers && !_.isArray(handlers)) {
+                        handlers = [handlers];
+                    }
+                    actionHandlers[plugin.name] = handlers;
+                }
+            });
+            sandbox.publish('ready');
+        },
         getView: function() {
             return canvasWrapper;
         },
+        getCanvas: function() {
+            return canvas;
+        },
         setDocument: function(wlxmlDocument) {
             canvas.loadWlxmlDocument(wlxmlDocument);
             canvasWrapper.find('#rng-module-documentCanvas-content').empty().append(canvas.view());
@@ -50,8 +75,17 @@ return function(sandbox) {
         jumpToElement: function(node) {
             canvas.setCurrentElement(node);
         },
-        command: function(command, params) {
-            commands.run(command, params, canvas, sandbox.getConfig().user);
+        onAfterActionExecuted: function(action, ret) {
+            if(ret && ret instanceof canvas.wlxmlDocument.CaretFragment && ret.isValid()) {
+                logger.debug('The action returned a valid fragment');
+                canvas.setCurrentElement(ret.node, {caretTo: ret.offset});
+                return;
+            }
+            logger.debug('No valid fragment returned from the action');
+
+            (actionHandlers[action.getPluginName()] || []).forEach(function(handler) {
+                handler(canvas, action, ret);
+            });
         }
     };
     
diff --git a/src/editor/modules/documentToolbar/actionView.js b/src/editor/modules/documentToolbar/actionView.js
new file mode 100644 (file)
index 0000000..74db332
--- /dev/null
@@ -0,0 +1,147 @@
+define(function(require) {
+    
+'use strict';
+/* globals gettext */
+
+var $ = require('libs/jquery'),
+    Backbone = require('libs/backbone'),
+    _ = require('libs/underscore'),
+    viewTemplate = require('libs/text!modules/documentToolbar/templates/actionView.html'),
+    buttonTemplate = require('libs/text!modules/documentToolbar/templates/actionViewButton.html'),
+    selectionTemplate = require('libs/text!modules/documentToolbar/templates/actionViewSelection.html');
+
+
+viewTemplate = _.template(viewTemplate);
+buttonTemplate = _.template(buttonTemplate);
+selectionTemplate = _.template(selectionTemplate);
+
+var iconExists = function(iconName) {
+    /* globals window */
+    var el = $('<i>').addClass('icon-' + iconName);
+    $('body').append(el);
+    var style = window.getComputedStyle(el[0]);
+    var toret = /glyphicons/.test(style.backgroundImage) && !/14px 14px/.test(style.backgroundPosition);
+    el.remove();
+    return toret;
+};
+
+var ActionView = Backbone.View.extend({
+    events: {
+        'mousedown .btn': 'onMouseDown',
+        'click .btn': 'onExecute',
+        'change select': 'onSelectionChange',
+        'mouseenter': 'onMouseEnter',
+        'mouseleave': 'onMouseLeave'
+    },
+    initialize: function() {
+        this.action = this.options.action;
+        this.action.on('paramsChanged', function() {
+            this.render();
+        }, this);
+        this.setElement(viewTemplate());
+    },
+    render: function() {
+        /* globals document */
+
+        var actionState = this.action.getState();
+
+        if(!actionState) {
+            this.$el.html(buttonTemplate({label: gettext('error :('), iconName:''}));
+            this._button().attr('disabled', true);
+            return;
+        }
+
+        var templateContext = {
+            label: actionState.label || '?',
+            iconName: (iconExists(actionState.icon)) ? actionState.icon : null,
+            iconStyle: actionState.iconStyle
+        },
+            hovered = document.querySelectorAll(':hover'),
+            hovers = false,
+            button = this._button();
+           
+        if(hovered.length && _.last(hovered) === button[0]) {
+            hovers = true;
+        }
+
+        this.$el.empty();
+        _.pairs(this.action.definition.params).forEach(function(pair) {
+            var paramName = pair[0],
+                paramDesc = pair[1],
+                widget;
+            if(paramDesc.type === 'select') {
+                widget = $(selectionTemplate({
+                    paramName: paramName,
+                    options: paramDesc.options
+                }));
+                if(this.action.params[paramName]) {
+                    widget.find('option[value=' + this.action.params[paramName].id + ']').attr('selected', true);
+                }
+                this.$el.append(widget);
+            }
+        }.bind(this));
+
+        this.$el.append(buttonTemplate(templateContext));
+        button = this._button();
+        
+        if(!actionState.allowed) {
+            button.attr('disabled', true);
+            button.wrap('<div style="position: relative;">');
+            button.after('<div style="position: absolute; top:0; bottom:0; left:0; right: 0"></div>');
+        }
+        
+        if(actionState.toggled !== undefined) {
+            button.toggleClass('active', actionState.toggled);
+        }
+
+        if(hovers) {
+            this.trigger('hover');
+        }
+    },
+    onMouseEnter: function() {
+        this.trigger('hover');
+    },
+    onMouseLeave: function() {
+        this.trigger('leave');
+    },
+    onMouseDown: function() {
+        this.trigger('mousedown');
+    },
+    onExecute: function() {
+        this.action.execute(function(ret) {
+            this.trigger('actionExecuted', this.action, ret);
+        }.bind(this));
+    },
+    onSelectionChange: function(e) {
+        var select = $(e.target),
+            paramName = select.attr('param');
+
+        this.action.definition.params[paramName].options.some(function(option) {
+            if(option.id.toString() === select.val()) {
+                this.action.updateWidgetParam(paramName, option);
+                return true; // break
+            }
+        }.bind(this));
+    },
+    _button: function() {
+        return this.$el.find('button');
+    }
+});
+
+var create = function(action) {
+    var view = new ActionView({action:action});
+    view.render();
+
+    return {
+        on: function() {
+            view.on.apply(view, Array.prototype.slice.call(arguments, 0));
+        },
+        dom: view.$el,
+    };
+};
+
+return {
+    create: create
+};
+
+});
\ No newline at end of file
index 33f95a6..b66734f 100644 (file)
-define(['libs/jquery', 'libs/underscore', 'utils/wlxml', 'libs/text!./template.html'], function($, _, wlxmlUtils, template) {
+define(['libs/jquery', 'libs/underscore', 'modules/documentToolbar/actionView', 'libs/text!./template.html'], function($, _, actionView, template) {
 
 'use strict';
-/* globals Node */
+
 
 return function(sandbox) {
     
-    var documentTemplates = sandbox.getBootstrappedData(),
-        currentNode;
+    var addedActions = [],
+        contextParams = {},
+        document, canvas;
 
     var view = {
-        node: $(_.template(template)({wlxmlUtils: wlxmlUtils, templates: documentTemplates})),
-        setup: function() {
-            var view = this;
+        node: $(_.template(template)()),
+        getOption: function(option) {
+            return this.node.find('.rng-module-documentToolbar-toolbarOption[data-option=' + option +']').val();
+        },
+        addAction: function(group, actionDescription) {
+            var action = sandbox.createAction(actionDescription.actionName, actionDescription.actionConfig),
+                view;
+            addedActions.push(action);
+            view = actionView.create(action);
             
-            this.node.find('button').click(function(e) {
-                e.stopPropagation();
-
-                var btn = $(e.currentTarget),
-                    btnName = btn.attr('data-name'),
-                    meta = btn.attr('data-meta'),
-                    params = {},
-                    command = btnName;
-
-                if(myHandlers[btnName]) {
-                    myHandlers[btnName](btn);
-                } else {
-                    if(btn.attr('data-btn-type') === 'toggle') {
-                        command = 'toggle-' + command;
-                        btn.toggleClass('active');
-                        params.toggle = btn.hasClass('active');
-                    }
-
-                    if(btnName === 'new-node') {
-                        command = 'newNodeRequested';
-                        params.wlxmlTag = view.getOption('newTag-tag');
-                        params.wlxmlClass = view.getOption('newTag-class');
-                        if(meta) {
-                            var split = meta.split('/');
-                            params.wlxmlTag = split[0];
-                            params.wlxmlClass = split[1];
-                        }
-                    } else {
-                        params.meta = meta;
-                    }
-
-                    if(command === 'undo' || command === 'redo') {
-                        params.callback = function(disable) {
-                            btn.attr('disabled', !disable);
-                        };
-                    }
+            _.pairs(contextParams).forEach(function(pair) {
+                var name = pair[0],
+                    value = pair[1];
+                action.updateContextParam(name, value);
+            });
 
-                    _.extend(params, {ctrlKey: e.ctrlKey});
+            group.append(view.dom);
+            view.on('actionExecuted', function(action, ret) {
+                sandbox.publish('actionExecuted', action, ret);
+            });
 
-                    sandbox.publish('command', command, params);
-                }
+            view.on('hover', function() {
+                sandbox.publish('actionHovered', action);
+            });
+            view.on('leave', function() {
+                sandbox.publish('actionOff', action);
             });
         },
-        getOption: function(option) {
-            return this.node.find('.rng-module-documentToolbar-toolbarOption[data-option=' + option +']').val();
+        addActionsGroup: function() {
+            var div = $('<div>');
+            div.addClass('rng-module-documentToolbar-toolbarGroup');
+            this.node.append(div);
+            return div;
         }
     };
     
-    var myHandlers = {
-        templatesBtn: function() {
-            if(currentNode && currentNode.nodeType === Node.ELEMENT_NODE) {
-                var templateId = parseInt(view.node.find('[data-name=templates-select]').val(), 10);
-                documentTemplates.forEach(function(template) {
-                    if(template.id === templateId) {
-                        var toAdd = currentNode.document.createDocumentNode(template.content);
-                        currentNode.after(toAdd);
-                    }
-                });
-            }
-        }
+    var setContextParam = function(what, ctx) {
+        contextParams[what] = ctx;
+        addedActions.forEach(function(action) {
+            action.updateContextParam(what, contextParams[what]);
+        });
     };
 
-    view.setup();
+    sandbox.registerKeyHandler('keydown', function(e) {
+        if(e.keyCode === 17) {
+            addedActions.forEach(function(action) {
+                action.updateKeyParam('ctrl', true);
+            });
+        }
+    });
+    sandbox.registerKeyHandler('keyup', function(e) {
+        if(e.keyCode === 17) {
+            addedActions.forEach(function(action) {
+                action.updateKeyParam('ctrl', false);
+            });
+        }
+    });
 
     return {
-        start: function() { sandbox.publish('ready'); },
+        start: function() {
+            var config = sandbox.getConfig().toolbar || {};
+            config.forEach(function(actionsGroup) {
+                var group = view.addActionsGroup();
+                actionsGroup.forEach(function(actionDescription) {
+                    if(typeof actionDescription === 'string') {
+                        actionDescription = {actionName: actionDescription, actionConfig: {}};
+                    }
+                    view.addAction(group, actionDescription);
+                });
+            });
+            sandbox.publish('ready');
+        },
         getView: function() { return view.node; },
-        setNodeElement: function(node) {
-            currentNode = node;
+        setDocumentFragment: function(fragment) {
+            if(!document) {
+                document = fragment.document;
+                document.on('operationEnd', function() {
+                    setContextParam('document', document);
+                });
+            }
+            setContextParam('fragment', fragment);
+
+        },
+        setCanvas: function(_canvas) {
+            setContextParam('canvas', _canvas);
+            if(!canvas) {
+                canvas = _canvas;
+                canvas.on('changed', function() {
+                    setContextParam('canvas', _canvas);
+                });
+            }
         },
         getOption: function(option) { return view.getOption(option); }
     };
index 559afde..4b35b12 100644 (file)
         border-color: #ddd;
         padding: 0 8px 0 0;
         margin: 0 8px 0 0;
-        float:left;
+        display: inline-block;
+    }
+
+    .toolbar-widget {
+        display: inline-block;
+        margin: 0 5px;
+        div {
+            display: inline-block;
+        }
     }
     
 }
index 2e14a89..1c013da 100644 (file)
@@ -1,35 +1 @@
-<div class="rng-module-documentToolbar">
-    <div class="rng-module-documentToolbar-toolbarGroup">
-        <button data-name="undo" data-btn-type="cmd" data-meta="bullets" class="btn btn-mini"><i class="icon-share-alt" style="-webkit-transform: scale(-1,1); transform: scale(-1, 1);"></i></button>
-        <button data-name="redo" data-btn-type="cmd" class="btn btn-mini"><i class="icon-share-alt"></i></button>
-    </div>
-
-    <div class="rng-module-documentToolbar-toolbarGroup">
-        <button data-name="new-node" data-meta="header/" data-btn-type="cmd" class="btn btn-mini">nagłówek</button>
-        <button data-name="list" data-btn-type="cmd" data-meta="bullets" class="btn btn-mini">lista pkt</button>
-        <button data-name="list" data-btn-type="cmd" data-meta="num" class="btn btn-mini">lista num</button>
-    </div>
-
-    <div class="rng-module-documentToolbar-toolbarGroup">
-        <button data-name="new-node" data-meta="span/emp" data-btn-type="cmd" class="btn btn-mini">wyróżnienie</button>
-        <button data-name="new-node" data-meta="span/cite" data-btn-type="cmd" class="btn btn-mini">cytat</i></button>
-        <button data-name="new-node" data-meta="span/link" data-btn-type="cmd" class="btn btn-mini">link</i></button>
-    </div>
-
-    <% if(templates) { %>
-    <div class="rng-module-documentToolbar-toolbarGroup">
-        <select data-name="templates-select">
-        <% templates.forEach(function(template) { %>
-            <option value="<%= template.id %>"><%= template.name %></option>
-        <% }); %>
-        </select>
-        <button class="btn btn-mini" data-name="templatesBtn">+</button>
-    </div>
-    <% } %>
-
-    <div class="rng-module-documentToolbar-toolbarGroup">
-        <button data-name="new-node" data-meta="aside/comment" data-btn-type="cmd" class="btn btn-mini"><i class="icon-comment"></i></button>
-    </div>
-
-    <div style="clear: both;"></div>
-</div>
\ No newline at end of file
+<div class="rng-module-documentToolbar"></div>
diff --git a/src/editor/modules/documentToolbar/templates/actionView.html b/src/editor/modules/documentToolbar/templates/actionView.html
new file mode 100644 (file)
index 0000000..f5ca649
--- /dev/null
@@ -0,0 +1 @@
+<div class="toolbar-widget"></div>
\ No newline at end of file
diff --git a/src/editor/modules/documentToolbar/templates/actionViewButton.html b/src/editor/modules/documentToolbar/templates/actionViewButton.html
new file mode 100644 (file)
index 0000000..506a291
--- /dev/null
@@ -0,0 +1,7 @@
+<button class="btn btn-mini">
+<% if(iconName) { %>
+    <i class="icon-<%= iconName %>" style="<%= iconStyle || '' %>"></i>
+<% } else { %>
+    <%= label %>
+<% } %>
+</button>
\ No newline at end of file
diff --git a/src/editor/modules/documentToolbar/templates/actionViewSelection.html b/src/editor/modules/documentToolbar/templates/actionViewSelection.html
new file mode 100644 (file)
index 0000000..c09ed2d
--- /dev/null
@@ -0,0 +1,6 @@
+<select param="<%= paramName %>">
+    <option value=""></option>
+    <% options.forEach(function(option) { %>
+        <option value="<%= option.id %>"><%= option.name %></option>
+    <% }); %>
+</select>
\ No newline at end of file
index 118571e..70df62e 100644 (file)
@@ -7,6 +7,7 @@ define([
 ], function($, _, mainTemplate, itemTemplate, OpenSelectView) {
 
 'use strict';
+/* globals gettext */
 
 return function(sandbox) {
 
@@ -41,11 +42,15 @@ return function(sandbox) {
             
             this.node.find('.rng-module-metadataEditor-addBtn').click(function() {
                 adding = true;
-                currentNode.getMetadata().add('','');
+                currentNode.document.transaction(function() {
+                    currentNode.getMetadata().add('','');
+                }, this, gettext('Add metadata row'));
             });
             
             this.metaTable.on('click', '.rng-visualEditor-metaRemoveBtn', function(e) {
-                $(e.target).closest('tr').data('row').remove();
+                currentNode.document.transaction(function() {
+                    $(e.target).closest('tr').data('row').remove();
+                }, this, gettext('Remove metadata row'));
             });
             
             this.metaTable.on('keydown', '[contenteditable]', function(e) {
@@ -70,13 +75,16 @@ return function(sandbox) {
                         row = editable.parents('tr').data('row'),
                         isKey = _.last(editable.attr('class').split('-')) === 'metaItemKey',
                         method = isKey ? 'setKey' : 'setValue';
-                    row[method](toSet);
+                    row.metadata.node.document.transaction(function() {
+                        row[method](toSet);
+                    }, this, gettext('Metadata edit'));
                 }
             }, 500));
         },
         clear: function() {
         },
         setMetadata: function(node) {
+            this.node.find('.rng-module-metadataEditor-addBtn').attr('disabled', !node);
             if(!node) {
                 this.metaTable.html('');
                 return;
@@ -183,7 +191,7 @@ return function(sandbox) {
                 if(event.type === 'metadataRemoved' && event.meta.node.sameNode(currentNode)) {
                     view.removeMetadataRow(event.meta.row);
                 }
-                if(event.type === 'nodeDetached' && event.meta.node.sameNode(currentNode)) {
+                if(event.type === 'nodeDetached' && event.meta.node.containsNode(currentNode)) {
                     view.setMetadata(null);
                 }
             });
index 55f4fab..0540542 100644 (file)
@@ -8,7 +8,8 @@ define([
 
 return function(sandbox) {
     
-    var template = _.template(templateSrc);
+    var template = _.template(templateSrc),
+        listens = false;
     
     var view = {
         dom: $('<div>' + template({node:null, parents: null}) + '</div>'),
@@ -31,7 +32,11 @@ return function(sandbox) {
         setNodeElement: function(nodeElement) {
             this.dom.empty();
             this.currentNodeElement = nodeElement;
-            var parents = nodeElement.parents();
+            var parents;
+            if(nodeElement) {
+                parents = nodeElement.parents();
+            }
+
             this.dom.html(template({node: nodeElement, parents: parents, utils: wlxmlUtils}));
 
             this.dom.find('li > a[href="#"]').each(function(idx, a) {
@@ -54,6 +59,14 @@ return function(sandbox) {
         start: function() { sandbox.publish('ready'); },
         getView: function() { return view.dom; },
         setNodeElement: function(nodeElement) {
+            if(!listens && nodeElement) {
+                nodeElement.document.on('change', function() {
+                    if(view.currentNodeElement && !view.currentNodeElement.isInDocument()) {
+                        view.setNodeElement(null);
+                    }
+                });
+                listens = true;
+            }
             view.setNodeElement(nodeElement);
         },
         highlightNode: function(id) { view.highlightNode(id); },
index 2f96b2d..062fb1e 100644 (file)
@@ -5,6 +5,6 @@
             <li><a href="#"> <%= utils.getTagLabel(parents[i].getTagName()) %><% if(parents[i].getClass()) { %>.<%= utils.getClassLabel(parents[i].getClass()) %> <% } %></a><span class="divider">/</span></li>
         <% } %>
             <li class="active"><%= utils.getTagLabel(node.getTagName()) %><% if(node.getClass()) { %>.<%= utils.getClassLabel(node.getClass()) %> <% } %></span></li>
-    <% } %>
+    <% } else { %> &nbsp; <% } %>
     </ul>
 </div>
\ No newline at end of file
index e2a4eb8..bda562f 100644 (file)
@@ -29,7 +29,7 @@ return function(sandbox) {
         listens = true;
         document.on('change', function(event) {
             if(event.type === 'nodeTextChange' && event.meta.node.parent().sameNode(view.currentNodeElement)) {
-                view.setElement();
+                view.setElement(view.currentNodeElement);
             }
         }, this);
     };
@@ -52,50 +52,54 @@ return function(sandbox) {
             });
         },
         setElement: function(element) {
-            element = element || this.currentNodeElement;
-            var textElement = element.getText ? element : null,
-                nodeElement = element.getText ? element.parent() : element, // TODO: better type detection
-                nodeElementParent = nodeElement.parent(),
-                parent;
-            
-            this.currentNodeElement = nodeElement;
-            items = [];
+            var contents = [],
+                parent, nodeElementParent;
 
-            if(nodeElementParent) {
-                items.push(nodeElementParent);
-                parent = {
-                    id: items.length - 1,
-                    repr: wlxmlUtils.getTagLabel(nodeElementParent.getTagName()) + (nodeElementParent.getClass() ? ' / ' + wlxmlUtils.getClassLabel(nodeElementParent.getClass()) : '')
-                };
+            if(element) {
+                element = element || this.currentNodeElement;
+                var textElement = element.getText ? element : null,
+                    nodeElement = element.getText ? element.parent() : element, // TODO: better type detection
+                    items;
                 
-            }
-        
-            var nodeContents = nodeElement.contents(),
-                contents = [];
-            nodeContents.forEach(function(child) {
-                if(child.getText) {
-                    var text = child.getText();
-                    if(!text) {
-                        text = '<pusty tekst>';
-                    }
-                    else {
-                        if(text.length > 13) {
-                            text = text.substr(0,13) + '...';
+                this.currentNodeElement = nodeElement;
+                items = [];
+                nodeElementParent = nodeElement.parent();
+
+                if(nodeElementParent) {
+                    items.push(nodeElementParent);
+                    parent = {
+                        id: items.length - 1,
+                        repr: wlxmlUtils.getTagLabel(nodeElementParent.getTagName()) + (nodeElementParent.getClass() ? ' / ' + wlxmlUtils.getClassLabel(nodeElementParent.getClass()) : '')
+                    };
+                    
+                }
+            
+                var nodeContents = nodeElement.contents();
+                nodeContents.forEach(function(child) {
+                    if(child.getText) {
+                        var text = child.getText();
+                        if(!text) {
+                            text = '<pusty tekst>';
+                        }
+                        else {
+                            if(text.length > 13) {
+                                text = text.substr(0,13) + '...';
+                            }
+                            text = '"' + text + '"';
                         }
-                        text = '"' + text + '"';
+                        contents.push({
+                            id: items.length,
+                            repr: _.escape(text), bold: child.sameNode(textElement)
+                        });
+                    } else {
+                        contents.push({
+                            id: items.length,
+                            repr: wlxmlUtils.getTagLabel(child.getTagName()) + (child.getClass() ? ' / ' + wlxmlUtils.getClassLabel(child.getClass()) : '')
+                        });
                     }
-                    contents.push({
-                        id: items.length,
-                        repr: _.escape(text), bold: child.sameNode(textElement)
-                    });
-                } else {
-                    contents.push({
-                        id: items.length,
-                        repr: wlxmlUtils.getTagLabel(child.getTagName()) + (child.getClass() ? ' / ' + wlxmlUtils.getClassLabel(child.getClass()) : '')
-                    });
-                }
-                items.push(child);
-            });
+                    items.push(child);
+                });
+            }
             this.dom.empty();
             this.dom.append($(template({parent: parent, contents: contents})));
 
@@ -123,10 +127,10 @@ return function(sandbox) {
             sandbox.publish('ready');
         },
         setElement: function(element) {
-            if(!listens) {
+            if(!listens && element) {
                 startListening(element.document);
             }
-            if(!(element.sameNode(view.currentNodeElement))) {
+            if(!element || !(element.sameNode(view.currentNodeElement))) {
                 view.setElement(element);
             }
         },
index 02ae2cc..25e5ddb 100644 (file)
@@ -11,14 +11,24 @@ define([
 return function(sandbox) {
     
     var view = $(_.template(templateSrc)({utils: wlxmlUtils})),
-        currentNode;
+        listens = false,
+        currentNode,
+        msgs = {
+            Tag: gettext('Tag editing'),
+            Class: gettext('Class editing')
+        };
     
     view.on('change', 'select', function(e) {
         var target = $(e.target);
         var attr = target.attr('class').split('-')[3] === 'tagSelect' ? 'Tag' : 'Class',
-            value = target.val().replace(/-/g, '.');
-        currentNode['set' + attr](value);
+            value = target.val().replace(/-/g, '.'),
+            oldValue = attr === 'Tag' ? currentNode.getTagName() : currentNode.getClass();
+        currentNode.document.transaction(function() {
+            currentNode['set' + attr](value);
+        }, this, msgs[attr] + ': ' + oldValue + ' -> ' + value);
     });
+
+
    
     return {
         start: function() {
@@ -28,28 +38,37 @@ return function(sandbox) {
             return view;
         },
         setNodeElement: function(wlxmlNodeElement) {
-            var module = this;
-            if(!currentNode) {
-                wlxmlNodeElement.document.on('change', function(event) {
-                    if(event.type === 'nodeAttrChange' && event.meta.node.sameNode(currentNode)) {
-                        module.setNodeElement(currentNode);
-                    }
-                });
-            }
+            if(wlxmlNodeElement) {
+                var module = this;
+                if(!listens) {
+                    wlxmlNodeElement.document.on('change', function(event) {
+                        if(currentNode && !currentNode.isInDocument()) {
+                            module.setNodeElement(null);
+                        }
+                        if(event.type === 'nodeAttrChange' && event.meta.node.sameNode(currentNode)) {
+                            module.setNodeElement(currentNode);
+                        }
+                    });
+                    listens = true;
+                }
 
-            view.find('.rng-module-nodePane-tagSelect').val(wlxmlNodeElement.getTagName());
+                view.find('.rng-module-nodePane-tagSelect').attr('disabled', false).val(wlxmlNodeElement.getTagName());
 
-            var escapedClassName = (wlxmlNodeElement.getClass() || '').replace(/\./g, '-');
-            view.find('.rng-module-nodePane-classSelect').val(escapedClassName);
-
-            var attrs = _.extend(wlxmlNodeElement.getMetaAttributes(), wlxmlNodeElement.getOtherAttributes());
-            var widget = metaWidget.create({attrs:attrs});
-            widget.on('valueChanged', function(key, value) {
-                wlxmlNodeElement.setMetaAttribute(key, value);
-                //wlxmlNodeElement.setMetaAttribute(key, value);
-            });
-            view.find('.metaFields').empty().append(widget.el);
+                var escapedClassName = (wlxmlNodeElement.getClass() || '').replace(/\./g, '-');
+                view.find('.rng-module-nodePane-classSelect').attr('disabled', false).val(escapedClassName);
 
+                var attrs = _.extend(wlxmlNodeElement.getMetaAttributes(), wlxmlNodeElement.getOtherAttributes());
+                var widget = metaWidget.create({attrs:attrs});
+                widget.on('valueChanged', function(key, value) {
+                    wlxmlNodeElement.setMetaAttribute(key, value);
+                    //wlxmlNodeElement.setMetaAttribute(key, value);
+                });
+                view.find('.metaFields').empty().append(widget.el);
+            } else {
+                view.find('.rng-module-nodePane-tagSelect').attr('disabled', true).val('');
+                view.find('.rng-module-nodePane-classSelect').attr('disabled', true).val('');
+                view.find('.metaFields').empty();
+            }
             currentNode = wlxmlNodeElement;
         }
     };
index 0cccb9c..825ae38 100644 (file)
@@ -5,4 +5,5 @@
     <div style="clear:both"></div>
     <div fnpjs-place="mainView">
     </div>
+    <div fnpjs-place="bottomPanel"></div>
 </div>
\ No newline at end of file
index f957f0a..c1a7e8e 100644 (file)
@@ -23,7 +23,7 @@
         top: 15px;
         left:0;
         right:0;
-        bottom:0;
+        bottom:20px;
         z-index: 1;
         
         > .rng-view-tabs {
         }
         
     }
+    [fnpjs-place="bottomPanel"] {
+        position: absolute;
+        bottom:0;
+        height: 20px;
+        width:100%
+    }
 }
\ No newline at end of file
index b12c19f..6a3271b 100644 (file)
@@ -42,16 +42,25 @@ return function(sandbox) {
         jumpToDocumentElement: function(element) {
             sandbox.getModule('documentCanvas').jumpToElement(element);
         },
-        updateCurrentNodeElement: function(nodeElement) {
-            sandbox.getModule('nodePane').setNodeElement(nodeElement);
-            sandbox.getModule('nodeFamilyTree').setElement(nodeElement);
-            sandbox.getModule('nodeBreadCrumbs').setNodeElement(nodeElement);
-            sandbox.getModule('documentToolbar').setNodeElement(nodeElement);
-            sandbox.getModule('metadataEditor').setNodeElement(nodeElement);
+        refreshCanvasSelection: function(selection) {
+            var fragment = selection.toDocumentFragment(),
+                elementParent;
+            
+            sandbox.getModule('documentToolbar').setDocumentFragment(fragment);
+            
+            if(fragment && fragment.node) {
+                elementParent = fragment.node.getNearestElementNode();
+                sandbox.getModule('nodePane').setNodeElement(elementParent);
+                sandbox.getModule('nodeFamilyTree').setElement(fragment.node);
+                sandbox.getModule('nodeBreadCrumbs').setNodeElement(elementParent);
+                sandbox.getModule('metadataEditor').setNodeElement(elementParent);
+            } else {
+                sandbox.getModule('nodePane').setNodeElement(null);
+                sandbox.getModule('nodeFamilyTree').setElement(null);
+                sandbox.getModule('nodeBreadCrumbs').setNodeElement(null);
+                sandbox.getModule('metadataEditor').setNodeElement(null);
+            }
         },
-        updateCurrentTextElement: function(textElement) {
-            sandbox.getModule('nodeFamilyTree').setElement(textElement);
-        }
     };
     
 
@@ -98,7 +107,7 @@ return function(sandbox) {
             sandbox.getModule('mainBar').setCommandEnabled('drop-draft', usingDraft);
             sandbox.getModule('mainBar').setCommandEnabled('save', usingDraft);
 
-            _.each(['sourceEditor', 'documentCanvas', 'documentToolbar', 'metadataEditor', 'nodeBreadCrumbs', 'mainBar', 'indicator', 'documentHistory', 'diffViewer'], function(moduleName) {
+            _.each(['sourceEditor', 'documentCanvas', 'documentToolbar', 'metadataEditor', 'nodeBreadCrumbs', 'mainBar', 'indicator', 'documentHistory', 'diffViewer', 'statusBar'], function(moduleName) {
                 sandbox.getModule(moduleName).start();
             });
             
@@ -196,24 +205,16 @@ return function(sandbox) {
             views.visualEditing.setView('leftColumn', sandbox.getModule('documentCanvas').getView());
         },
         
-        currentTextElementSet: function(textElement) {
-            commands.updateCurrentTextElement(textElement);
-        },
-
-        currentNodeElementSet: function(nodeElement) {
-            commands.updateCurrentNodeElement(nodeElement);
-        },
-        
-        currentNodeElementChanged: function(nodeElement) {
-            commands.updateCurrentNodeElement(nodeElement);
-        },
-
         nodeHovered: function(canvasNode) {
             commands.highlightDocumentNode(canvasNode);
         },
         
         nodeBlured: function(canvasNode) {
             commands.dimDocumentNode(canvasNode);
+        },
+
+        selectionChanged: function(selection) {
+            commands.refreshCanvasSelection(selection);
         }
     };
 
@@ -248,9 +249,10 @@ return function(sandbox) {
     eventHandlers.documentToolbar = {
         ready: function() {
             views.visualEditing.setView('toolbar', sandbox.getModule('documentToolbar').getView());
+            sandbox.getModule('documentToolbar').setCanvas(sandbox.getModule('documentCanvas').getCanvas());
         },
-        command: function(cmd, params) {
-            sandbox.getModule('documentCanvas').command(cmd, params);
+        actionExecuted: function(action, ret) {
+            sandbox.getModule('documentCanvas').onAfterActionExecuted(action, ret);
         }
     };
     
@@ -292,6 +294,21 @@ return function(sandbox) {
         }
     };
 
+    eventHandlers.statusBar = {
+        ready: function() {
+            views.mainLayout.setView('bottomPanel', sandbox.getModule('statusBar').getView());
+        }
+    };
+
+    eventHandlers.__all__ = {
+        actionHovered: function(action) {
+            sandbox.getModule('statusBar').showAction(action);
+        },
+        actionOff: function() {
+            sandbox.getModule('statusBar').clearAction();
+        }
+    };
+
     window.addEventListener('beforeunload', function(event) {
         var txt = gettext('Do you really want to exit?');
         if(documentIsDirty) {
@@ -305,6 +322,11 @@ return function(sandbox) {
     
     return {
         start: function() {
+            sandbox.registerActionsAppObject({
+                getUser: function() {
+                    return sandbox.getConfig().user;
+                }
+            });
             sandbox.getModule('data').start();
         },
         handleEvent: function(moduleName, eventName, args) {
@@ -312,10 +334,16 @@ return function(sandbox) {
             if(eventHandlers[moduleName] && eventHandlers[moduleName][eventName]) {
                 logger.debug('Handling event ' + eventRepr);
                 eventHandlers[moduleName][eventName].apply(eventHandlers, args);
-            } else {
-                logger.warning('No event handler for ' + eventRepr);
+                return;
+            }
+
+            if(eventHandlers.__all__[eventName]) {
+                logger.debug('Handling event ' + eventRepr);
+                eventHandlers.__all__[eventName].apply(eventHandlers.__all__, args);
+                return;
             }
 
+            logger.warning('No event handler for ' + eventRepr);
         }
     };
 };
diff --git a/src/editor/modules/statusBar/statusBar.html b/src/editor/modules/statusBar/statusBar.html
new file mode 100644 (file)
index 0000000..0473d59
--- /dev/null
@@ -0,0 +1 @@
+<div id="rng-module-statusBar"></div>
\ No newline at end of file
diff --git a/src/editor/modules/statusBar/statusBar.js b/src/editor/modules/statusBar/statusBar.js
new file mode 100644 (file)
index 0000000..0f08c59
--- /dev/null
@@ -0,0 +1,50 @@
+define(function(require) {
+    
+'use strict';
+/* globals gettext */
+
+var $ = require('libs/jquery'),
+    template = require('libs/text!modules/statusBar/statusBar.html'),
+    logging = require('fnpjs/logging/logging');
+
+var logger = logging.getLogger('statusBar');
+
+return function(sandbox){
+
+    var view = $(template);
+
+    return {
+        start: function() {
+            return sandbox.publish('ready');
+        },
+        getView: function() {
+            return view;
+        },
+        showAction: function(action) {
+            var state = action.getState(),
+                description;
+            
+            if(!state) {
+                description = gettext('error :(');
+                logger.error('Got undefined action state: ' + action.name);
+            } else {
+                description = state.description;
+                if(!description) {
+                    description = state.allowed ? gettext('Undescribed action') : gettext('Action not allowed');
+                    logger.info('Undescribed action: ' + action.name);
+                }
+            }
+
+            view.text(description);
+            if(!state.allowed) {
+                view.prepend('<span class="badge badge-warning" style="margin-right: 5px">!</span>');
+            }
+        },
+        clearAction: function() {
+            view.text('');
+        }
+    };
+
+};
+
+});
\ No newline at end of file
diff --git a/src/editor/modules/statusBar/statusBar.less b/src/editor/modules/statusBar/statusBar.less
new file mode 100644 (file)
index 0000000..cbaa2f5
--- /dev/null
@@ -0,0 +1,8 @@
+#rng-module-statusBar {
+    border-width: 1px 0 0 0;
+    border-style: solid;
+    border-color: #ddd;
+    font-size: 0.8em;
+    height: 100%;
+    padding: 5px;
+}
\ No newline at end of file
index d11bf73..a780227 100644 (file)
@@ -1,9 +1,15 @@
 define(function(require) {
     
 'use strict';
+/* globals gettext */
 
 var _ = require('libs/underscore'),
-    plugin = {documentExtension: {textNode: {}}};
+    templates = require('plugins/core/templates'),
+    footnote = require('plugins/core/footnote'),
+    switchTo = require('plugins/core/switch'),
+    lists = require('plugins/core/lists'),
+    plugin = {name: 'core', actions: [], canvas: {}, documentExtension: {textNode: {}}},
+    Dialog = require('views/dialog/dialog');
 
 
 plugin.documentExtension.textNode.transformations = {
@@ -38,6 +44,274 @@ plugin.documentExtension.textNode.transformations = {
     }
 };
 
+var undoRedoAction = function(dir) {
+    return {
+        name: dir,
+        params: {
+            document: {type: 'context', name: 'document'},
+        },
+        stateDefaults: {
+            label: dir === 'undo' ? '<-' : '->',
+            icon: 'share-alt',
+            iconStyle: dir === 'undo' ? '-webkit-transform: scale(-1,1); transform: scale(-1, 1)' : '',
+            execute: function(callback, params) {
+                params.document[dir]();
+                callback();
+            },
+        },
+        getState: function(params) {
+            var allowed = params.document && !!(params.document[dir+'Stack'].length),
+                desc = dir === 'undo' ? gettext('Undo') : gettext('Redo'),
+                descEmpty = dir === 'undo' ? gettext('There is nothing to undo') : gettext('There is nothing to redo');
+            if(allowed) {
+                var metadata = _.last(params.document[dir+'Stack']).metadata;
+                if(metadata) {
+                    desc += ': ' + (metadata.description || gettext('unknown operation'));
+                }
+            }
+            return {
+                allowed: allowed,
+                description: allowed ? desc : descEmpty
+            };
+        }
+    };
+};
+
+var pad = function(number) {
+    if(number < 10) {
+        number = '0' + number;
+    }
+    return number;
+};
+
+var commentAction = {
+    name: 'comment',
+    params: {
+        fragment: {type: 'context', name: 'fragment'}
+    },
+    stateDefaults: {
+        icon: 'comment',
+        execute: function(callback, params, editor) {
+            /* globals Node */
+            var node = params.fragment.node,
+                action = this;
+            if(node.nodeType === Node.TEXT_NODE) {
+                node = node.parent();
+            }
+            node.document.transaction(function() {
+                var comment =  node.after({tagName: 'aside', attrs: {'class': 'comment'}});
+                comment.append({text:''});
+                var user = editor.getUser(), creator;
+                if(user) {
+                    creator = user.name;
+                    if(user.email) {
+                        creator += ' (' + user.email + ')';
+                    }
+                } else {
+                    creator = 'anonymous';
+                }
+
+                var currentDate = new Date(),
+                    dt = pad(currentDate.getDate()) + '-' +
+                                pad((currentDate.getMonth() + 1))  + '-' +
+                                pad(currentDate.getFullYear()) + ' ' +
+                                pad(currentDate.getHours()) + ':' +
+                                pad(currentDate.getMinutes()) + ':' +
+                                pad(currentDate.getSeconds());
+
+                var metadata = comment.getMetadata();
+                metadata.add({key: 'creator', value: creator});
+                metadata.add({key: 'date', value: dt});
+            }, {
+                metadata: {
+                    description: action.getState().description
+                },
+                success: callback
+            });
+        },
+    },
+    getState: function(params) {
+        var state = {
+            allowed: params.fragment && params.fragment.isValid() &&
+                        params.fragment instanceof params.fragment.NodeFragment && !params.fragment.node.isRoot()
+        };
+        if(state.allowed) {
+            state.description = gettext('Insert comment after current node');
+        }
+        return state;
+    }
+};
+
+
+var createWrapTextAction = function(createParams) {
+    return {
+        name: createParams.name,
+        params: {
+            fragment: {type: 'context', name: 'fragment'},
+        },
+        getState: function(params) {
+            var state = {
+                    label: this.config.label
+                },
+                parent;
+            
+            if(
+                !params.fragment || !params.fragment.isValid() ||
+                !(params.fragment instanceof params.fragment.TextRangeFragment) ||
+                !params.fragment.hasSiblingBoundries()) {
+                    return _.extend(state, {allowed: false});
+            }
+            
+            parent = params.fragment.startNode.parent();
+            if(parent && parent.is(createParams.klass) || parent.isInside(createParams.klass)) {
+                return _.extend(state, {allowed: false});
+            }
+
+            return _.extend(state, {
+                allowed: true,
+                description: createParams.description,
+                execute: function(callback, params) {
+                    params.fragment.document.transaction(function() {
+                        var parent = params.fragment.startNode.parent(),
+                            doc = params.fragment.document,
+                            wrapper, lastTextNode;
+                        
+                        wrapper = parent.wrapText({
+                            _with: {tagName: 'span', attrs: {'class': createParams.klass}},
+                            offsetStart: params.fragment.startOffset,
+                            offsetEnd: params.fragment.endOffset,
+                            textNodeIdx: [params.fragment.startNode.getIndex(), params.fragment.endNode.getIndex()]
+                        });
+                            
+                        lastTextNode = wrapper.getLastTextNode();
+                        if(lastTextNode) {
+                            return doc.createFragment(doc.CaretFragment, {node: lastTextNode, offset: lastTextNode.getText().length});
+                        }
+                    }, {
+                        metadata: {
+                            description: createParams.description
+                        },
+                        success: callback
+                    });
+                }
+            });
+        }
+    };
+};
+
+
+var createLinkFromSelection = function(callback, params) {
+    var doc = params.fragment.document,
+        dialog = Dialog.create({
+            title: gettext('Create link'),
+            executeButtonText: gettext('Apply'),
+            cancelButtonText: gettext('Cancel'),
+            fields: [
+                {label: gettext('Link'), name: 'href', type: 'input'}
+            ]
+        }),
+        action = this;
+    
+    dialog.on('execute', function(event) {
+        doc.transaction(function() {
+            var span =  action.params.fragment.startNode.parent().wrapText({
+                _with: {tagName: 'span', attrs: {'class': 'link'}},
+                offsetStart: params.fragment.startOffset,
+                offsetEnd: params.fragment.endOffset,
+                textNodeIdx: [params.fragment.startNode.getIndex(), params.fragment.endNode.getIndex()]
+            }),
+                doc = params.fragment.document;
+            span.setAttr('href', event.formData.href);
+            event.success();
+            return doc.createFragment(doc.CaretFragment, {node: span.contents()[0], offset:0});
+        }, {
+            metadata: {
+                description: action.getState().description
+            },
+            success: callback
+        });
+    });
+    dialog.show();
+};
+
+var editLink = function(callback, params) {
+    var doc = params.fragment.document,
+        link = params.fragment.node.getParent('link'),
+        dialog = Dialog.create({
+            title: gettext('Edit link'),
+            executeButtonText: gettext('Apply'),
+            cancelButtonText: gettext('Cancel'),
+            fields: [
+                {label: gettext('Link'), name: 'href', type: 'input', initialValue: link.getAttr('href')}
+            ]
+        }),
+        action = this;
+    
+    dialog.on('execute', function(event) {
+        doc.transaction(function() {
+            link.setAttr('href', event.formData.href);
+            event.success();
+            return params.fragment;
+        }, {
+            metadata: {
+                description: action.getState().description
+            },
+            success: callback
+        });
+    });
+    dialog.show();
+};
+
+var linkAction = {
+    name: 'link',
+    params: {
+        fragment: {type: 'context', name: 'fragment'}
+    },
+    stateDefaults: {
+        label: gettext('link')
+    },
+    getState: function(params) {
+        if(!params.fragment || !params.fragment.isValid()) {
+            return {allowed: false};
+        }
+
+        if(params.fragment instanceof params.fragment.TextRangeFragment) {
+            if(!params.fragment.hasSiblingBoundries() || params.fragment.startNode.parent().is('link')) {
+                return {allowed: false};
+            }
+            return {
+                allowed: true,
+                description: gettext('Create link from selection'),
+                execute: createLinkFromSelection
+            };
+        }
+
+        if(params.fragment instanceof params.fragment.CaretFragment) {
+            if(params.fragment.node.isInside('link')) {
+                return {allowed: true, toggled: true, execute: editLink};
+            }
+        }
+        return {allowed: false};
+    }
+};
+
+
+plugin.actions = [
+    undoRedoAction('undo'),
+    undoRedoAction('redo'),
+    commentAction,
+    createWrapTextAction({name: 'emphasis', klass: 'emp', description: gettext('Mark as emphasized')}),
+    createWrapTextAction({name: 'cite', klass: 'cite', description: gettext('Mark as citation')}),
+    linkAction
+].concat(plugin.actions, templates.actions, footnote.actions, switchTo.actions, lists.actions);
+
+
+
+plugin.config = function(config) {
+    // templates.actions[0].config(config.templates);
+    templates.actions[0].params.template.options = config.templates;
+};
+
 return plugin;
 
 });
\ No newline at end of file
diff --git a/src/editor/plugins/core/footnote.js b/src/editor/plugins/core/footnote.js
new file mode 100644 (file)
index 0000000..2fa314a
--- /dev/null
@@ -0,0 +1,84 @@
+define(function() {
+    
+'use strict';
+/* globals gettext */
+
+var footnoteExecute = {
+    selecting: function(params) {
+        var parent = params.fragment.startNode.parent();
+        return parent.wrapText({
+            _with: {tagName: 'aside', attrs: {'class': 'footnote'}},
+            offsetStart: params.fragment.startOffset,
+            offsetEnd: params.fragment.endOffset,
+            textNodeIdx: [params.fragment.startNode.getIndex(), params.fragment.endNode.getIndex()]
+        });
+    },
+    afterCursor: function(params) {
+        var node = params.fragment.node,
+            asideNode;
+        node.document.transaction(function() {
+            asideNode = node.divideWithElementNode({tagName: 'aside', attrs:{'class': 'footnote'}}, {offset: params.fragment.offset});
+            asideNode.append({text: ''});
+        });
+        return asideNode;
+    },
+    afterNode: function(params) {
+        var node = params.fragment.node,
+            asideNode;
+        node.document.transaction(function() {
+            asideNode = node.after({tagName: 'aside', attrs:{'class': 'footnote'}}, {offset: params.fragment.offset});
+            asideNode.append({text: ''});
+        });
+        return asideNode;
+    }
+};
+
+var footnoteAction =  {
+    name: 'footnote',
+    params: {
+        fragment: {type: 'context', name: 'fragment'}
+    },
+    stateDefaults: {
+        icon: 'asterisk'
+    },
+    getState: function(params) {
+        if(!params.fragment || !params.fragment.isValid()) {
+            return {allowed: false};
+        }
+        if(params.fragment instanceof params.fragment.TextRangeFragment && params.fragment.hasSiblingBoundries()) {
+            return {
+                allowed: true,
+                description: gettext('Create footnote from selection'),
+                execute: footnoteExecute.selecting
+            };
+        }
+        if(params.fragment instanceof params.fragment.CaretFragment) {
+            return {
+                allowed: true,
+                description: gettext('Insert footnote after cursor'),
+                execute: footnoteExecute.afterCursor
+            };
+        }
+        if(params.fragment instanceof params.fragment.NodeFragment) {
+            if(params.fragment.node.isRoot()) {
+                return {
+                    allowed: false,
+                    description: gettext('Cannot insert footnote after root node')
+                };
+            }
+            return {
+                allowed: true,
+                description: gettext('Insert footnote after node'),
+                execute: footnoteExecute.afterNode
+            };
+        }
+        return false;
+    }
+};
+
+
+return {
+    actions: [footnoteAction],
+};
+
+});
\ No newline at end of file
diff --git a/src/editor/plugins/core/lists.js b/src/editor/plugins/core/lists.js
new file mode 100644 (file)
index 0000000..427673e
--- /dev/null
@@ -0,0 +1,153 @@
+define(function() {
+    
+'use strict';
+/* globals gettext, interpolate */
+
+
+var getBoundriesForAList = function(fragment) {
+    var node;
+
+    if(fragment instanceof fragment.RangeFragment && fragment.hasSiblingBoundries()) {
+        return fragment.boundriesSiblingParents();
+    }
+    if(fragment instanceof fragment.NodeFragment) {
+        node = fragment.node.getNearestElementNode();
+        return {
+            node1: node,
+            node2: node
+        };
+    }
+};
+
+var countItems = function(boundries) {
+    var ptr = boundries.node1,
+        c = 1;
+    while(ptr && !ptr.sameNode(boundries.node2)) {
+        c++;
+        ptr = ptr.next();
+    }
+    return c;
+};
+
+var toggleListAction = function(type) {
+    
+    var execute = {
+        add: function(callback, params) {
+            var boundries = getBoundriesForAList(params.fragment),
+                listParams = {klass: type === 'Bullet' ? 'list' : 'list.enum'},
+                action = this;
+
+            if(boundries && boundries.node1) {
+                listParams.node1 = boundries.node1;
+                listParams.node2 = boundries.node2;
+                boundries.node1.document.transaction(function() {
+                    var list = boundries.node1.document.createList(listParams),
+                        item1 = list.object.getItem(0),
+                        text = item1 ? item1.contents()[0] : undefined, //
+                        doc = boundries.node1.document;
+
+                   return doc.createFragment(doc.CaretFragment, {node: text, offset:0});
+                }, {
+                    metadata: {
+                        description: action.getState().description
+                    },
+                    success: callback
+                });
+            } else {
+                throw new Error('Invalid boundries');
+            }
+        },
+        remove: function(callback, params) {
+            /* globals Node */
+            var current = params.fragment.node,
+                action = this;
+
+            var toSearch = current.nodeType === Node.ELEMENT_NODE ? [current] : [];
+            toSearch = toSearch.concat(current.parents());
+            toSearch.some(function(node) {
+                if(node.is('list')) {
+                    node.document.transaction(function() {
+                        node.object.extractListItems();
+                    }, {
+                        metadata: {
+                            description: action.getState().description
+                        },
+                        success: callback
+                    });
+                    
+                    return true; // break
+                }
+            }.bind(this));
+        },
+        changeType: function(callback, params) {
+            var node = params.fragment.node,
+                action = this;
+            node.document.transaction(function() {
+                node.getParent('list').setClass(type === 'Bullet' ? 'list' : 'list.enum');
+            }, {
+                metadata: {
+                    description: action.getState().description
+                },
+                success: callback
+            });
+        }
+    };
+
+    var isToggled = function(params) {
+        if(params.fragment && params.fragment.node && params.fragment.node.isInside('list')) {
+            var list = params.fragment.node.getParent('list');
+            return list.getClass() === (type === 'Bullet' ? 'list' : 'list.enum');
+        }
+        return false;
+    };
+
+
+    return {
+        name: 'toggle' + type + 'List',
+        context: ['fragment'],
+        params: {
+            fragment: {type: 'context', name: 'fragment'}
+        },
+        stateDefaults: {
+            label: type === 'Bullet' ? gettext('bull. list') : gettext('num. list')
+        },
+        getState: function(params) {
+            if(!params.fragment || !params.fragment.isValid()) {
+                return false;
+            }
+
+            if(params.fragment instanceof params.fragment.CaretFragment && params.fragment.node.isInside('list')) {
+                var list = params.fragment.node.getParent('list');
+                if((list.getClass() === 'list' && type === 'Enum') || (list.getClass() === 'list.enum' && type === 'Bullet')) {
+                    return {
+                        allowed: true,
+                        description: interpolate(gettext('Change list type to %s'), [type]),
+                        execute: execute.changeType
+                    };
+                }
+                return {
+                    allowed: true,
+                    toggled: isToggled(params),
+                    description: gettext('Remove list'),
+                    execute: execute.remove
+                };
+
+            }
+            var boundries = getBoundriesForAList(params.fragment);
+            if(boundries) {
+                return {
+                    allowed: true,
+                    description: interpolate(gettext('Make %s fragment(s) into list'), [countItems(getBoundriesForAList(params.fragment))]),
+                    execute: execute.add
+                };
+            }
+        }
+    };
+};
+
+
+return {
+    actions: [toggleListAction('Bullet'), toggleListAction('Enum')]
+};
+
+});
\ No newline at end of file
diff --git a/src/editor/plugins/core/switch.js b/src/editor/plugins/core/switch.js
new file mode 100644 (file)
index 0000000..9b34721
--- /dev/null
@@ -0,0 +1,89 @@
+define(function(require) {
+    
+'use strict';
+/* globals gettext */
+
+var _ = require('libs/underscore');
+
+
+var createSwitchAction = function(createParams) {
+    return  {
+        name: createParams.name,
+        params: {
+            fragment: {type: 'context', name: 'fragment'},
+        },
+        getState: function(params) {
+            var state = {
+                    label: this.config.label
+                },
+                f = params.fragment,
+                description;
+
+
+            if(
+                !(f && f.isValid()) ||
+                !((f instanceof f.CaretFragment) || (f instanceof f.TextRangeFragment && f.getCommonParent()))
+            ) {
+                return _.extend(state, {
+                    allowed: false,
+                    description: 'wrong or no selection'
+                });
+            }
+
+            var node = f instanceof f.CaretFragment ? f.node.parent() : f.getCommonParent(),
+                alreadyInTarget = node.isInside(createParams.to),
+                toSwitch = node,
+                textNodePath = (f.node || f.startNode).getPath();
+
+            if(!toSwitch.is(createParams.from)) {
+                toSwitch = toSwitch.getParent(createParams.from);
+            }
+
+            description = 'Switch to ' + createParams.to.name;
+            return _.extend(state, {
+                allowed: !!toSwitch,
+                toggled: alreadyInTarget,
+                description: description,
+                execute: alreadyInTarget ? function() {} : function(callback) {
+                    f.document.transaction(function() {
+                        if(createParams.to.tagName) {
+                            toSwitch = toSwitch.setTag(createParams.to.tagName);
+                        }
+                        if(!_.isUndefined(createParams.to.klass)) {
+                            toSwitch.setClass(createParams.to.klass);
+                        }
+                        return f.document.createFragment(f.CaretFragment, {node: f.document.getNodeByPath(textNodePath), offset: f.offset});
+                    }, {
+                        metadata: {
+                            description: description
+                        },
+                        success: callback
+                    });
+                }
+            });
+        }
+    };
+};
+
+var headerAction = createSwitchAction({name: 'switchToHeader', from: {tagName: 'div', klass: 'p'}, to: {tagName: 'header', klass: '', name: gettext('header')}}),
+    paragraphAction = createSwitchAction({name: 'switchToParagraph', from: {tagName: 'header'}, to: {tagName: 'div', klass: 'p', name: gettext('paragraf')}});
+
+return {
+    actions: [headerAction, paragraphAction],
+    canvasActionHandler: {
+        handles: [headerAction, paragraphAction],
+        // handle: function(canvas, action, ret) {
+        //     var params = {},
+        //         f;
+        //     if(ret && ret.node2) {
+        //         f = ret.oldFragment;
+        //         if(f && f instanceof f.CaretFragment) {
+        //             params.caretTo = f.offset;
+        //         }
+        //         canvas.setCurrentElement(ret.node2, params);
+        //     }
+        // }
+    }
+};
+
+});
\ No newline at end of file
diff --git a/src/editor/plugins/core/templates.js b/src/editor/plugins/core/templates.js
new file mode 100644 (file)
index 0000000..7d6d74f
--- /dev/null
@@ -0,0 +1,62 @@
+define(function() {
+    
+'use strict';
+/* globals gettext, interpolate */
+
+
+var insertTemplateAction = {
+    name: 'template',
+    params: {
+        fragment: {type: 'context', name: 'fragment'},
+        template: {type: 'select', options: []},
+        ctrl: {type: 'key', key: 'ctrl'}
+    },
+    stateDefaults: {
+        label: '+',
+        icon: 'core.plus'
+    },
+    getState: function(params) {
+        var description;
+
+        if(!(params.template && params.template.id)) {
+            return {
+                allowed: false,
+                description: gettext('No template selected')
+            };
+        } else if(
+            !params.fragment || !params.fragment.isValid() ||
+            !(params.fragment instanceof params.fragment.NodeFragment) ||
+            params.fragment.node.getNearestElementNode().isRoot()
+            ) {
+                return {
+                    allowed: false,
+                    description: gettext('Wrong node selected')
+            };
+        }
+
+        description = interpolate(gettext('Insert template %s after %s'), [params.template.name, params.fragment.node.getNearestElementNode().getTagName()]);
+        return {
+            allowed: true,
+            description: description,
+            execute: function(callback, params) {
+                var node = params.fragment.node.getNearestElementNode();
+                node.document.transaction(function() {
+                    var toAdd = node.document.createDocumentNode(params.template.content);
+                    node.after(toAdd);
+                }, {
+                    metadata: {
+                        description: description
+                    },
+                    success: callback
+                });
+            }
+        };
+    }
+};
+
+
+return {
+    actions: [insertTemplateAction]
+};
+
+});
\ No newline at end of file
index 82bf8d5..411a2aa 100644 (file)
@@ -15,3 +15,4 @@
 @import '../modules/nodeFamilyTree/nodeFamilyTree.less';
 @import '../modules/metadataEditor/metadataEditor.less';
 @import '../modules/diffViewer/diffViewer.less';
+@import '../modules/statusBar/statusBar.less';
diff --git a/src/fnpjs/actions.js b/src/fnpjs/actions.js
new file mode 100644 (file)
index 0000000..f380ed6
--- /dev/null
@@ -0,0 +1,90 @@
+define(function(require) {
+    
+'use strict';
+
+var _ = require('libs/underscore'),
+    Backbone = require('libs/backbone'),
+    logging = require('fnpjs/logging/logging');
+
+var logger = logging.getLogger('fnpjs.actions');
+
+
+var Action = function(fqName, definition, config, appObject) {
+    this.fqName = fqName;
+    this.definition = definition;
+    this.config = config;
+    this._cache = null;
+    this.appObject = appObject;
+
+    this.params = {};
+};
+
+_.extend(Action.prototype, Backbone.Events, {
+    getPluginName: function() {
+        return this.fqName.split('.')[0];
+    },
+    updateParam: function(filter, value) {
+        var changed = false;
+        _.pairs(this.definition.params).forEach(function(pair) {
+            var paramName = pair[0],
+                paramDesc = pair[1];
+            if(filter(paramDesc, paramName)) {
+                this.params[paramName] = value;
+                changed = true;
+            }
+        }.bind(this));
+        if(changed) {
+            this._cache = null;
+            this.trigger('paramsChanged');
+        }
+    },
+    updateContextParam: function(contextName, value) {
+        this.updateParam(function(paramDesc) {
+            return paramDesc.type === 'context' && paramDesc.name === contextName;
+        }, value);
+    },
+    updateKeyParam: function(keyName, toggled) {
+        this.updateParam(function(paramDesc) {
+            return paramDesc.type === 'key' && paramDesc.key === keyName;
+        }, toggled);
+    },
+    updateWidgetParam: function(name, value) {
+        this.updateParam(function(paramDesc, paramName) {
+            return !_.contains(['context', 'key'], paramDesc.type) && paramName === name;
+        }, value);
+    },
+    getState: function() {
+        var gotState;
+        if(!this._cache) {
+            try {
+                gotState = this.definition.getState.call(this, this.params);
+            } catch(e) {
+                logger.exception(e);
+                return;
+            }
+            if(typeof gotState === 'boolean') {
+                gotState = {allowed: gotState};
+            }
+            this._cache = _.extend({}, this.definition.stateDefaults || {}, gotState);
+        }
+        if(this._cache === false) {
+            this._cache = {allowed: false};
+        }
+        return this._cache;
+    },
+    execute: function(callback) {
+        var state = this.getState();
+        callback = callback || function() {};
+        if(state.allowed) {
+            return state.execute.call(this, callback, this.params, this.appObject);
+        }
+        throw new Error('Execution not allowed');
+    }
+});
+
+
+return {
+    Action: Action
+};
+
+});
index 2f00170..6c28a61 100644 (file)
@@ -1,4 +1,4 @@
-define(['libs/jquery', 'libs/underscore', 'fnpjs/logging/logging'], function($, _, logging) {
+define(['libs/jquery', 'libs/underscore', 'fnpjs/logging/logging', 'fnpjs/actions'], function($, _, logging, actions) {
 
 'use strict';
 
@@ -13,7 +13,9 @@ var Runner = function(app, modules) {
         moduleInstances = {},
         eventListeners = [],
         plugins = [],
-        config;
+        actionDefinitions = {},
+        config,
+        actionsAppObject;
         
     _.each(_.keys(modules || {}), function(moduleName) {
         if(_.contains(app.permissions[moduleName] || [], 'handleEvents')) {
@@ -62,6 +64,24 @@ var Runner = function(app, modules) {
         this.getConfig = function() {
             return config;
         };
+
+        this.createAction = function(fqName, config) {
+            var definition = actionDefinitions[fqName];
+            if(!definition) {
+                throw new Error('Invalid action: ' + fqName);
+            }
+            return new actions.Action(fqName, definition, config, actionsAppObject);
+        };
+
+        this.registerKeyHandler = function(eventName, handler) {
+            $('body').on(eventName, function(e) {
+                handler(e);
+            });
+        };
+
+        this.registerActionsAppObject = function(_actionsAppObject) {
+            actionsAppObject = _actionsAppObject;
+        };
     };
     
     
@@ -71,6 +91,10 @@ var Runner = function(app, modules) {
 
     this.registerPlugin = function(plugin) {
         plugins.push(plugin);
+        (plugin.actions || []).forEach(function(definition) {
+            var actionFqName = plugin.name + '.' + definition.name;
+            actionDefinitions[actionFqName] = definition;
+        });
     };
     
     this.start = function(_config) {
@@ -83,6 +107,20 @@ var Runner = function(app, modules) {
             logging.setConfig(config.logging);
         }
 
+        _.pairs(config.plugins || {}).forEach(function(pair) {
+            var pluginName = pair[0],
+                pluginConfig = pair[1];
+
+            plugins.some(function(plugin) {
+                if(plugin.name === pluginName) {
+                    if(_.isFunction(plugin.config)) {
+                        plugin.config(pluginConfig);
+                    }
+                    return true; //break
+                }
+            });
+        });
+
         app.initModules.forEach(function(moduleName) {
             getModuleInstance(moduleName).start();
         });
diff --git a/src/smartxml/fragments.js b/src/smartxml/fragments.js
new file mode 100644 (file)
index 0000000..9bb92cc
--- /dev/null
@@ -0,0 +1,120 @@
+define(function(require) {
+    
+'use strict';
+
+var $ = require('libs/jquery'),
+    _ = require('libs/underscore');
+
+
+var Fragment = function(document) {
+    this.document = document;
+};
+$.extend(Fragment.prototype, {
+    isValid: function() {
+        return false;
+    }
+});
+
+
+var NodeFragment = function(document, params) {
+    Fragment.call(this, document);
+    this.node = params.node;
+};
+NodeFragment.prototype = Object.create(Fragment.prototype);
+$.extend(NodeFragment.prototype, {
+    isValid: function() {
+        return this.document.containsNode(this.node);
+    }
+});
+
+
+var CaretFragment = function(document, params) {
+    NodeFragment.call(this, document, params);
+    this.offset = params.offset;
+
+};
+CaretFragment.prototype = Object.create(NodeFragment.prototype);
+$.extend(CaretFragment.prototype, {
+    isValid: function() {
+        /* globals Node */
+        return NodeFragment.prototype.isValid.call(this) &&
+                this.node.nodeType === Node.TEXT_NODE &&
+                _.isNumber(this.offset);
+    }
+});
+
+
+
+var RangeFragment = function(document, params) {
+    Fragment.call(this, document);
+
+    if(params.node1.sameNode(params.node2)) {
+        this.startNode = this.endNode = params.node1;
+    } else {
+        /*jshint bitwise: false*/
+        /* globals Node */
+        var node1First = params.node1.nativeNode.compareDocumentPosition(params.node2.nativeNode) & Node.DOCUMENT_POSITION_FOLLOWING;
+        (node1First ? ['start', 'end'] : ['end','start']).forEach(function(prefix, idx) {
+            this[prefix + 'Node'] = params['node'+(idx+1)];
+        }.bind(this));
+    }
+};
+RangeFragment.prototype = Object.create(Fragment.prototype);
+$.extend(RangeFragment.prototype, {
+    isValid: function() {
+        return this.document.containsNode(this.startNode) && this.document.containsNode(this.endNode);
+    },
+    hasSiblingBoundries: function() {
+        return this.isValid() && this.startNode.isSiblingOf(this.endNode);
+    },
+    boundriesSiblingParents: function() {
+        return this.startNode.document.getSiblingParents({
+            node1: this.startNode,
+            node2: this.endNode
+        });
+    },
+    getCommonParent: function() {
+        var siblingParents = this.boundriesSiblingParents();
+        if(siblingParents) {
+            return siblingParents.node1.parent();
+        }
+    },
+});
+
+var TextRangeFragment = function(document, params) {
+    var orderChanged;
+
+    RangeFragment.call(this, document, params);
+
+    if(this.startNode.sameNode(this.endNode)) {
+        this.startOffset = Math.min(params.offset1, params.offset2);
+        this.endOffset = Math.max(params.offset1, params.offset2);
+    } else {
+        orderChanged =  !params.node1.sameNode(this.startNode);
+        this.startOffset = orderChanged ? params.offset2 : params.offset1;
+        this.endOffset = orderChanged ? params.offset1 : params.offset2;
+    }
+};
+TextRangeFragment.prototype = Object.create(RangeFragment.prototype);
+$.extend(TextRangeFragment.prototype, {
+    isValid: function() {
+        return RangeFragment.prototype.isValid.call(this) &&
+            _.isNumber(this.startOffset) &&
+            _.isNumber(this.endOffset);
+    }
+});
+
+var FragmentTypes = {
+    Fragment: Fragment,
+    NodeFragment: NodeFragment,
+    CaretFragment: CaretFragment,
+    RangeFragment: RangeFragment,
+    TextRangeFragment: TextRangeFragment
+};
+_.values(FragmentTypes).forEach(function(Type) {
+    $.extend(Type.prototype, FragmentTypes);
+});
+
+return FragmentTypes;
+
+});
\ No newline at end of file
diff --git a/src/smartxml/fragments.test.js b/src/smartxml/fragments.test.js
new file mode 100644 (file)
index 0000000..8cd6a58
--- /dev/null
@@ -0,0 +1,82 @@
+define(function(require) {
+    
+'use strict';
+/* global describe, it */
+/* jshint expr:true */
+
+
+var chai = require('libs/chai'),
+    smartxml = require('./smartxml.js');
+
+
+var expect = chai.expect;
+
+var getDocumentFromXML = function(xml) {
+    return smartxml.documentFromXML(xml);
+};
+
+describe('Fragments API', function() {
+    describe('node fragment', function() {
+        it('describes a single node', function() {
+            var doc = getDocumentFromXML('<section></section');
+
+            var fragment = doc.createFragment(doc.NodeFragment, {node:doc.root});
+            expect(fragment instanceof fragment.NodeFragment).to.be.true;
+            expect(fragment.node.sameNode(doc.root)).to.be.true;
+        });
+    });
+
+    describe('caret fragment', function() {
+        it('describes place in a text', function() {
+            var doc = getDocumentFromXML('<section>Alice</section>');
+
+            var fragment = doc.createFragment(doc.CaretFragment, {node: doc.root.contents()[0], offset: 1});
+
+            expect(fragment instanceof fragment.CaretFragment).to.be.true;
+            expect(fragment instanceof fragment.NodeFragment).to.be.true;
+            expect(fragment.node.getText()).to.equal('Alice');
+            expect(fragment.offset).to.equal(1);
+        });
+    });
+
+    describe('text range fragment', function() {
+        it('describes fragment of a text node', function() {
+            var doc = getDocumentFromXML('<section>Alice</section>'),
+                textNode = doc.root.contents()[0];
+
+            var fragment = doc.createFragment(doc.TextRangeFragment, {
+                node1: textNode,
+                offset1: 4,
+                node2: textNode,
+                offset2: 1
+            });
+
+            expect(fragment instanceof fragment.TextRangeFragment).to.be.true;
+            expect(fragment instanceof fragment.RangeFragment).to.be.true;
+            expect(fragment.startNode.getText()).to.equal('Alice');
+            expect(fragment.startOffset).to.equal(1);
+            expect(fragment.endNode.getText()).to.equal('Alice');
+            expect(fragment.endOffset).to.equal(4);
+        });
+        it('describes text spanning multiple nodes', function() {
+            var doc = getDocumentFromXML('<section>Alice <span>has</span> a cat!</section>'),
+                textNode1 = doc.root.contents()[0],
+                textNode2 = doc.root.contents()[2];
+
+            var fragment = doc.createFragment(doc.TextRangeFragment, {
+                node1: textNode2,
+                offset1: 4,
+                node2: textNode1,
+                offset2: 1
+            });
+
+            expect(fragment instanceof fragment.TextRangeFragment).to.be.true;
+            expect(fragment.startNode.getText()).to.equal('Alice ');
+            expect(fragment.startOffset).to.equal(1);
+            expect(fragment.endNode.getText()).to.equal(' a cat!');
+            expect(fragment.endOffset).to.equal(4);
+        });
+    });
+});
+
+});
\ No newline at end of file
index 628163a..3ee49b7 100644 (file)
@@ -4,8 +4,9 @@ define([
     'libs/backbone',
     'smartxml/events',
     'smartxml/transformations',
-    'smartxml/core'
-], function($, _, Backbone, events, transformations, coreTransformations) {
+    'smartxml/core',
+    'smartxml/fragments'
+], function($, _, Backbone, events, transformations, coreTransformations, fragments) {
     
 'use strict';
 /* globals Node */
@@ -78,6 +79,14 @@ $.extend(DocumentNode.prototype, {
         return this.document.root.sameNode(this);
     },
 
+    isInDocument: function() {
+        return this.document.containsNode(this);
+    },
+
+    isSiblingOf: function(node) {
+        return node && this.parent().sameNode(node.parent());
+    },
+
     sameNode: function(otherNode) {
         return !!(otherNode) && this.nativeNode === otherNode.nativeNode;
     },
@@ -143,6 +152,10 @@ $.extend(DocumentNode.prototype, {
             return 0;
         }
         return this.parent().indexOf(this);
+    },
+
+    getNearestElementNode: function() {
+        return this.nodeType === Node.ELEMENT_NODE ? this : this.parent();
     }
 });
 
@@ -210,6 +223,22 @@ $.extend(ElementNode.prototype, {
         return node && (node.nativeNode === this.nativeNode || node._$.parents().index(this._$) !== -1);
     },
 
+    getLastTextNode: function() {
+        var contents = this.contents(),
+            toret;
+
+        contents.reverse().some(function(node) {
+            if(node.nodeType === Node.TEXT_NODE) {
+                toret = node;
+                return true;
+            }
+            toret = node.getLastTextNode();
+            return !!toret;
+        });
+
+        return toret;
+    },
+
     toXML: function() {
         var wrapper = $('<div>');
         wrapper.append(this._getXMLDOMToDump());
@@ -234,6 +263,11 @@ $.extend(TextNode.prototype, {
         return this.nativeNode.data;
     },
 
+
+    containsNode: function() {
+        return false;
+    },
+
     triggerTextChangeEvent: function() {
         var event = new events.ChangeEvent('nodeTextChange', {node: this});
         this.document.trigger('change', event);
@@ -291,7 +325,7 @@ var Document = function(xml, extensions) {
     this.loadXML(xml);
 };
 
-$.extend(Document.prototype, Backbone.Events, {
+$.extend(Document.prototype, Backbone.Events, fragments, {
     ElementNodeFactory: ElementNode,
     TextNodeFactory: TextNode,
 
@@ -542,6 +576,7 @@ $.extend(Document.prototype, Backbone.Events, {
 
             this._undoInProgress = false;
             this.redoStack.push(transaction);
+            this.trigger('operationEnd');
         }
     },
     redo: function() {
@@ -553,6 +588,8 @@ $.extend(Document.prototype, Backbone.Events, {
             });
             this._transformationLevel--;
             this.undoStack.push(transaction);
+            this.trigger('operationEnd');
+
         }
     },
 
@@ -570,6 +607,7 @@ $.extend(Document.prototype, Backbone.Events, {
         }
         if(this._currentTransaction.hasTransformations()) {
             this.undoStack.push(this._currentTransaction);
+            this.trigger('operationEnd');
         }
         this._currentTransaction = null;
     },
@@ -626,6 +664,13 @@ $.extend(Document.prototype, Backbone.Events, {
             }
             return $document[0];
         }, configurable: true});
+    },
+
+    createFragment: function(Type, params) {
+        if(!Type.prototype instanceof fragments.Fragment) {
+            throw new Error('Can\'t create a fragment: `Type` is not a valid Fragment');
+        }
+        return new Type(this, params);
     }
 });
 
index 5d1ca3e..a994f05 100644 (file)
@@ -219,6 +219,21 @@ describe('smartxml', function() {
                 expect(event.meta.oldVal).to.equal('value1');
             });
         });
+
+        describe('Searching for the last child text node', function() {
+            [
+                '<div>xxx<div></div>last</div>',
+                '<div><div>last</div></div>',
+                '<div>xxx<div>last</div><div></div></div>'
+            ].forEach(function(xml, i) {
+                var example = 'example ' + i;
+                it('returns last child text node ' + example + ')', function() {
+                    var doc = getDocumentFromXML(xml),
+                        lastTextNode = doc.root.getLastTextNode();
+                    expect(lastTextNode.getText()).to.equal('last', example);
+                });
+            });
+        });
     });
 
     describe('Basic TextNode properties', function() {
index 85ab751..29834ae 100644 (file)
@@ -29,14 +29,29 @@ extension.wlxmlClass.list.transformations = {
     }
 };
 
+extension.wlxmlClass.list.transformations = {
+    extractListItems: function() {
+        var contents = this.contents(),
+            first = contents[0],
+            last;
+        if(contents.length) {
+            last = contents[contents.length-1];
+            this.document.extractItems({
+                item1: first,
+                item2: last
+            });
+        } else {
+            this.detach();
+        }
+    }
+};
+
 extension.document.methods = {
     areItemsOfSameList: function(params) {
         return params.node1.parent().sameNode(params.node2.parent()) && params.node2.parent().is('list');
     }
 };
 
-
-
 extension.document.transformations.createList = {
     impl: function(params) {
         /* globals Node */
index 4e1ddc6..5194c19 100644 (file)
@@ -12,16 +12,16 @@ define([
 
 
 var WLXMLDocumentNodeMethods =  {
-    isInside: function(klass) {
-        var parent = this.getParent(klass);
+    isInside: function(query) {
+        var parent = this.getParent(query);
         return !!parent;
     },
-    getParent: function(klass) {
+    getParent: function(query) {
         /* globals Node */
         var me = this.nodeType === Node.ELEMENT_NODE ? [this] : [],
             toret;
         me.concat(this.parents()).some(function(node) {
-            if(node.is(klass)) {
+            if(node.is(query)) {
                 toret = node;
                 return true;
             }
@@ -78,8 +78,12 @@ $.extend(WLXMLElementNode.prototype, WLXMLDocumentNodeMethods, smartxml.ElementN
             return this.setAttr('class', klass);
         }
     },
-    is: function(klass) {
-        return this.getClass().substr(0, klass.length) === klass;
+    is: function(query) {
+        if(typeof query === 'string') {
+            query = {klass: query};
+        }
+        return (_.isUndefined(query.klass) || this.getClass().substr(0, query.klass.length) === query.klass) &&
+               (_.isUndefined(query.tagName) || this.getTagName() === query.tagName);
     },
     getMetaAttributes: function() {
         var toret = new AttributesList(),
index c3da0df..e2028fa 100644 (file)
@@ -6,6 +6,12 @@
       return (/\.test\.js$/).test(file);
     });
 
+    /* globals window */
+    // This installs noop i18n functions so that tests can work with i18nized code
+    window.gettext = window.interpolate = function() {
+        return Array.prototype.slice.call(arguments, 0);
+    };
+
     require({
       baseUrl: '/base/src/editor',
       deps: tests,