Merge in first approach to edumed exercises
authorAleksander Łukasz <aleksander.lukasz@nowoczesnapolska.org.pl>
Thu, 14 Aug 2014 14:26:42 +0000 (16:26 +0200)
committerAleksander Łukasz <aleksander.lukasz@nowoczesnapolska.org.pl>
Thu, 14 Aug 2014 14:26:42 +0000 (16:26 +0200)
70 files changed:
karma.conf.js
src/editor/modules/documentCanvas/canvas/canvas.js
src/editor/modules/documentCanvas/canvas/container.js
src/editor/modules/documentCanvas/canvas/container.less [new file with mode: 0644]
src/editor/modules/documentCanvas/canvas/documentElement.js
src/editor/modules/documentCanvas/canvas/elementsRegister.js
src/editor/modules/documentCanvas/canvas/genericElement.js
src/editor/modules/documentCanvas/canvas/keyboard.js
src/editor/modules/documentCanvas/canvas/selection.js
src/editor/modules/documentCanvas/canvas/wlxmlListener.js
src/editor/modules/documentCanvas/documentCanvas.js
src/editor/modules/documentCanvas/documentCanvas.less
src/editor/modules/documentCanvas/nodes.less
src/editor/modules/documentToolbar/documentToolbar.less
src/editor/plugins/core/canvasElements.less
src/editor/plugins/core/core.js
src/editor/plugins/core/core.test.js
src/editor/plugins/core/edumed/actions.js [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/choice.less [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/choiceBase.js [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/choiceMulti.js [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/choiceMulti.less [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/choiceSingle.js [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/choiceSingle.less [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/choiceTrueOrFalse.js [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/choiceTrueOrFalse.less [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/element.html [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/element.js [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/list.html [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/list.js [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/listItem.html [new file with mode: 0644]
src/editor/plugins/core/edumed/choice/trueOrFalseSwitch.html [new file with mode: 0644]
src/editor/plugins/core/edumed/edumed.js [new file with mode: 0644]
src/editor/plugins/core/edumed/edumed.less [new file with mode: 0644]
src/editor/plugins/core/edumed/elementBase.js [new file with mode: 0644]
src/editor/plugins/core/edumed/gaps/actions.js [new file with mode: 0644]
src/editor/plugins/core/edumed/gaps/element.js [new file with mode: 0644]
src/editor/plugins/core/edumed/gaps/gaps.less [new file with mode: 0644]
src/editor/plugins/core/edumed/gaps/view.html [new file with mode: 0644]
src/editor/plugins/core/edumed/order/element.js [new file with mode: 0644]
src/editor/plugins/core/edumed/order/order.less [new file with mode: 0644]
src/editor/plugins/core/edumed/order/view.html [new file with mode: 0644]
src/editor/plugins/core/edumed/order/view.js [new file with mode: 0644]
src/editor/plugins/core/edumed/order/viewItem.html [new file with mode: 0644]
src/editor/plugins/core/edumed/replace/actions.js [new file with mode: 0644]
src/editor/plugins/core/edumed/replace/element.js [new file with mode: 0644]
src/editor/plugins/core/edumed/replace/replace.less [new file with mode: 0644]
src/editor/plugins/core/edumed/replace/tip.html [new file with mode: 0644]
src/editor/plugins/core/edumed/replace/view.html [new file with mode: 0644]
src/editor/plugins/core/lists.js
src/editor/styles/main.less
src/editor/views/menu/menu.html [new file with mode: 0644]
src/editor/views/menu/menu.js [new file with mode: 0644]
src/editor/views/menu/menu.less [new file with mode: 0644]
src/editor/views/menu/menuitem.html [new file with mode: 0644]
src/fnpjs/runner.js
src/smartxml/core.js
src/smartxml/fragments.js
src/wlxml/extensions/edumed/choice.xml [new file with mode: 0644]
src/wlxml/extensions/edumed/choiceSingle.xml [new file with mode: 0644]
src/wlxml/extensions/edumed/choiceTrueOrFalse.xml [new file with mode: 0644]
src/wlxml/extensions/edumed/edumed.js [new file with mode: 0644]
src/wlxml/extensions/edumed/edumed.test.js [new file with mode: 0644]
src/wlxml/extensions/edumed/gaps.xml [new file with mode: 0644]
src/wlxml/extensions/edumed/order.xml [new file with mode: 0644]
src/wlxml/extensions/edumed/replace.xml [new file with mode: 0644]
src/wlxml/extensions/list/list.js
src/wlxml/extensions/list/list.test.js
src/wlxml/wlxml.js
src/wlxml/wlxml.test.js

index 9f84f89..913c295 100644 (file)
@@ -9,6 +9,7 @@ files = [
   {pattern: 'libs/*.js', included: false},
   {pattern: 'src/**/*.js', included: false},
   {pattern: 'src/**/*.html', included: false},
+  {pattern: 'src/**/*.xml', included: false},
   'tests/main.js',
 ];
 
index 0259452..ebede40 100644 (file)
@@ -3,6 +3,7 @@ define([
 'libs/underscore',
 'libs/backbone',
 'fnpjs/logging/logging',
+'views/menu/menu',
 'modules/documentCanvas/canvas/documentElement',
 'modules/documentCanvas/canvas/keyboard',
 'modules/documentCanvas/canvas/utils',
@@ -14,8 +15,8 @@ define([
 'modules/documentCanvas/canvas/selection',
 'modules/documentCanvas/canvas/keyEvent',
 'libs/text!./canvas.html'
-], function($, _, Backbone, logging, documentElement, keyboard, utils, wlxmlListener, ElementsRegister, genericElement, nullElement, gutter, selection, keyEvent, canvasTemplate) {
-    
+], function($, _, Backbone, logging, Menu, documentElement, keyboard, utils, wlxmlListener, ElementsRegister, genericElement, nullElement, gutter, selection, keyEvent, canvasTemplate) {
+
 'use strict';
 /* global document:false, window:false, Node:false, gettext */
 
@@ -62,9 +63,10 @@ $.extend(TextHandler.prototype, {
 });
 
 
-var Canvas = function(wlxmlDocument, elements, metadata) {
+var Canvas = function(wlxmlDocument, elements, metadata, sandbox) {
     this.metadata = metadata || {};
-    this.elementsRegister = new ElementsRegister(documentElement.DocumentNodeElement, nullElement);
+    this.sandbox = sandbox;
+    this.elementsRegister = this.createElementsRegister();
 
     elements = [
         {tag: 'section', klass: null, prototype: genericElement},
@@ -95,6 +97,15 @@ var Canvas = function(wlxmlDocument, elements, metadata) {
 
 $.extend(Canvas.prototype, Backbone.Events, {
 
+    createElementType: function(elementPrototype) {
+        /* TODO: reconcile this with ElementsRegister behavior */
+        var Constructor = function() {
+            documentElement.DocumentNodeElement.apply(this, Array.prototype.slice.call(arguments, 0));
+        };
+        Constructor.prototype = elementPrototype;
+        return Constructor;
+    },
+
     getElementOffset: function(element) {
         return element.dom.offset().top - this.dom.offset().top;
     },
@@ -109,14 +120,28 @@ $.extend(Canvas.prototype, Backbone.Events, {
         this.reloadRoot();
     },
 
-    createElement: function(wlxmlNode) {
+    createElement: function(wlxmlNode, register, useRoot) {
         var Factory;
+        register = register || this.elementsRegister;
         if(wlxmlNode.nodeType === Node.TEXT_NODE) {
             Factory = documentElement.DocumentTextElement;
         } else {
+            Factory = register.getElement({tag: wlxmlNode.getTagName(), klass: wlxmlNode.getClass()});
+        }
+        if(!Factory && useRoot) {
             Factory = this.elementsRegister.getElement({tag: wlxmlNode.getTagName(), klass: wlxmlNode.getClass()});
+            if(!Factory) {
+                Factory = documentElement.DocumentNodeElement;
+            }
         }
-        return new Factory(wlxmlNode, this);
+
+        if(Factory) {
+            return new Factory(wlxmlNode, this);
+        }
+    },
+
+    createElementsRegister: function() {
+        return new ElementsRegister(documentElement.DocumentNodeElement, nullElement);
     },
 
     getDocumentElement: function(htmlElement) {
@@ -147,7 +172,6 @@ $.extend(Canvas.prototype, Backbone.Events, {
         this.rootWrapper.append(this.rootElement.dom);
     },
 
-
     triggerKeyEvent: function(keyEvent, selection) {
         selection = selection || this.getSelection();
         if(selection && (selection.type === 'caret' || selection.type === 'textSelection') && selection.toDocumentFragment().isValid()) {
@@ -155,6 +179,10 @@ $.extend(Canvas.prototype, Backbone.Events, {
         }
     },
 
+    createAction: function(fqName, config) {
+        return this.sandbox.createAction(fqName, config);
+    },
+
     setupEventHandling: function() {
         var canvas = this;
 
@@ -196,6 +224,17 @@ $.extend(Canvas.prototype, Backbone.Events, {
             }
         });
 
+        this.rootWrapper.on('contextmenu', function(e) {
+            var el = canvas.getDocumentElement(e.target);
+            
+            if(!el) {
+                return;
+            }
+
+            e.preventDefault();
+            this.showContextMenu(el, {x: e.clientX, y: e.clientY});
+        }.bind(this));
+
         this.rootWrapper.on('paste', function(e) {
             e.preventDefault();
 
@@ -454,6 +493,18 @@ $.extend(Canvas.prototype, Backbone.Events, {
         if(position.element) {
             this._moveCaretToTextElement(position.element, position.offset);
         }
+    },
+    showContextMenu: function(element, coors) {
+        var menu = new Menu();
+
+        while(element) {
+            (element.contextMenuActions || []).forEach(menu.addAction.bind(menu));
+            element = element.parent();
+        }
+        if(menu.actions.length) {
+            menu.updateContextParam('fragment', this.getSelection().toDocumentFragment());
+            this.sandbox.showContextMenu(menu, {x: coors.x, y: coors.y});
+        }
     }
 });
 
@@ -585,8 +636,8 @@ $.extend(Cursor.prototype, {
 });
 
 return {
-    fromXMLDocument: function(wlxmlDocument, elements, metadata) {
-        return new Canvas(wlxmlDocument, elements, metadata);
+    fromXMLDocument: function(wlxmlDocument, elements, metadata, sandbox) {
+        return new Canvas(wlxmlDocument, elements, metadata, sandbox);
     }
 };
 
index 6436526..663a159 100644 (file)
@@ -8,13 +8,15 @@ var $ = require('libs/jquery'),
 
 
 var Container = function(nodes, params, element) {
+    params = params || {};
     _.extend(this, params);
     this.dom = this.dom || $('<div>');
     this.dom.addClass('canvas-container');
+    this.dom.toggleClass('reset-background', !!params.resetBackground);
     this.element = element;
 
     nodes.forEach(function(node) {
-        var el = this.element.canvas.createElement(node);
+        var el = this.element.createElement(node);
         if(el.dom) {
             this.dom.append(el.dom);
         }
@@ -102,7 +104,7 @@ _.extend(Container.prototype, {
         if(param instanceof documentElement.DocumentElement) {
             element = param;
         } else {
-            element = this.element.canvas.createElement(param);//
+            element = this.element.createElement(param);//
         }
         if(element.dom) {
             this.dom.prepend(element.dom);
diff --git a/src/editor/modules/documentCanvas/canvas/container.less b/src/editor/modules/documentCanvas/canvas/container.less
new file mode 100644 (file)
index 0000000..dc4a514
--- /dev/null
@@ -0,0 +1,5 @@
+.canvas-container {
+    .reset-background {
+        background-color: @white;
+    }
+}
index 859a80b..a1965cd 100644 (file)
@@ -95,6 +95,8 @@ $.extend(DocumentElement.prototype, {
 var DocumentNodeElement = function(wlxmlNode, canvas) {
     DocumentElement.call(this, wlxmlNode, canvas);
     this.containers = [];
+    this.elementsRegister = canvas.createElementsRegister();
+    this.contextMenuActions = [];
     this.init(this.dom);
 };
 
@@ -104,7 +106,7 @@ var manipulate = function(e, params, action) {
     if(params instanceof DocumentElement) {
         element = params;
     } else {
-        element = e.canvas.createElement(params);
+        element = e.createElement(params);
     }
     if(element.dom) {
         e.dom[action](element.dom);
@@ -146,6 +148,13 @@ $.extend(DocumentNodeElement.prototype, {
             this.containers.splice(idx, 1);
         }
     },
+    createElement: function(wlxmlNode) {
+        var parent = this.wlxmlNode.parent() ? utils.getElementForNode(this.wlxmlNode.parent()) : null;
+        return this.canvas.createElement(wlxmlNode, this.elementsRegister, !parent) || parent.createElement(wlxmlNode);
+    },
+    addToContextMenu: function(actionFqName) {
+        this.contextMenuActions.push(this.canvas.createAction(actionFqName));
+    },
     handle: function(event) {
         var method = 'on' + event.type[0].toUpperCase() + event.type.substr(1),
             target;
@@ -232,6 +241,9 @@ $.extend(DocumentNodeElement.prototype, {
         // })
         this.dom.css('display', what);
         this._container().css('display', what);
+    },
+    children: function() {
+        return [];
     }
 });
 
@@ -293,7 +305,7 @@ $.extend(DocumentTextElement.prototype, {
         if(params instanceof DocumentNodeElement) {
             element = params;
         } else {
-            element = this.canvas.createElement(params);
+            element = this.parent().createElement(params);
         }
         if(element.dom) {
             this.dom.wrap('<div>');
@@ -311,7 +323,7 @@ $.extend(DocumentTextElement.prototype, {
         if(params instanceof DocumentNodeElement) {
             element = params;
         } else {
-            element = this.canvas.createElement(params);
+            element = this.createElement(params);
         }
         if(element.dom) {
             this.dom.wrap('<div>');
index 9a44514..7b2cf99 100644 (file)
@@ -38,9 +38,6 @@ _.extend(ElementsRegister.prototype, {
                 }
             }.bind(this));
         }
-        if(!Factory) {
-            Factory = this.BaseType;
-        }
         return Factory;
     }
 });
index 8ed08bf..fd0dfc4 100644 (file)
@@ -37,7 +37,7 @@ $.extend(generic, {
     },
     
     refresh: function() {
-        if(this.wlxmlNode.getTagName() === 'span') {
+        if(this.wlxmlNode.getTagName() === 'span' || this.wlxmlNode.getTagName() === 'aside') {
             if(this.containsBlock()) {
                 this.displayAsBlock();
             } else {
index 6b32da0..e43ea89 100644 (file)
@@ -257,6 +257,14 @@ var keyEventHandlers = [
                 return;
             }
 
+
+            var parent = element.wlxmlNode.parent();
+            if(element.wlxmlNode.getIndex() === 0 && parent.isContextRoot() && (!parent.is('item') || parent.getIndex() === 0)) {
+                // Don't even try to do anything at the edge of a context root, except for non-first items
+                // - this is a temporary solution until key events handling get refactored into something more sane.
+                return;
+            }
+
             e.preventDefault();
 
             s.canvas.wlxmlDocument.transaction(function() {
@@ -348,14 +356,44 @@ var keyEventHandlers = [
 
         }
     },
+    {
+        applies: function(e, s) {
+            var parent = s.element && s.element.wlxmlNode.parent(),
+                parentIsItem = parent && parent.is('item'),
+                itemIsOnList = parent && parent.parent() && parent.parent().is('list');
+            return s.type === 'caret' && e.key === KEYS.ENTER && s.element.isEmpty() && parentIsItem && itemIsOnList;
+        },
+        run: function(e, s) {
+            var item = s.element.wlxmlNode.parent(),
+                list = item.parent();
+            e.preventDefault();
+            s.canvas.wlxmlDocument.transaction(function() {
+                var p = list.after({tagName: 'div', attrs: {'class': 'p'}});
+                p.append({text: ''});
+                item.detach();
+                return p;
+            }, {
+                success: function(p) {
+                    s.canvas.setCurrentElement(p);
+                }
+            });
+        }
+    },
     {
         applies: function(e, s) {
             return s.type === 'caret' && e.key === KEYS.ENTER && !s.element.parent().isRootElement();
         },
         run: function(e, s) {
-            var result, goto, gotoOptions;
+            var parent = s.element.parent(),
+                children = parent.children(),
+                result, goto, gotoOptions;
             void(e);
             e.preventDefault();
+
+            if(children.length === 1 && s.element.isEmpty()) {
+                return;
+            }
+
             s.canvas.wlxmlDocument.transaction(function() {
                 result = s.element.wlxmlNode.breakContent({offset: s.offset});
             }, {
index 4de392b..0d8c4ce 100644 (file)
@@ -60,7 +60,8 @@ $.extend(TextSelection.prototype, {
     toDocumentFragment: function() {
         var doc = this.canvas.wlxmlDocument,
             anchorNode = this.anchorElement ? this.anchorElement.wlxmlNode : null,
-            focusNode = this.focusElement ? this.focusElement.wlxmlNode : null;
+            focusNode = this.focusElement ? this.focusElement.wlxmlNode : null,
+            node1, node2;
         
         if(!anchorNode || !focusNode) {
             return;
@@ -75,10 +76,17 @@ $.extend(TextSelection.prototype, {
             });
         }
         else {
-            var siblingParents = doc.getSiblingParents({node1: anchorNode, node2: focusNode});
+            if(anchorNode.hasSameContextRoot(focusNode)) {
+                var siblingParents = doc.getSiblingParents({node1: anchorNode, node2: focusNode});
+                node1 = siblingParents.node1;
+                node2 = siblingParents.node2;
+            } else {
+                node1 = focusNode;
+                node2 = anchorNode;
+            }
             return doc.createFragment(doc.RangeFragment, {
-                node1: siblingParents.node1,
-                node2: siblingParents.node2
+                node1: node1,
+                node2: node2
             });
         }
     },
@@ -129,7 +137,7 @@ return {
                 params = {
                     type: 'caret',
                     element: element,
-                    offset: element.isEmpty() ? 0 : nativeSelection.focusOffset
+                    offset: element.isEmpty() ? 0 : nativeSelection.getRangeAt(0).startOffset
                 };
             } else if(isText(nativeSelection.focusNode) && isText(nativeSelection.anchorNode)) {
                 anchorElement = canvas.getDocumentElement(nativeSelection.anchorNode);
index d107612..b1a9582 100644 (file)
@@ -49,7 +49,7 @@ var handlers = {
                 if(event.meta.node.isRoot()) {
                     this.canvas.reloadRoot();
                 } else {
-                    newElement = this.canvas.createElement(event.meta.node);
+                    newElement = element.parent().createElement(event.meta.node);
                     element.dom.replaceWith(newElement.dom);
                 }
             }
index a98fdb4..a440bb9 100644 (file)
@@ -22,7 +22,7 @@ return function(sandbox) {
 
     var canvas = canvas3.fromXMLDocument(null, canvasElements, {
         user: sandbox.getConfig().user
-    });
+    }, sandbox);
     var canvasWrapper = $(template);
     var shownAlready = false;
     var scrollbarPosition = 0,
index 3d335dc..9677691 100644 (file)
@@ -1,5 +1,6 @@
 @import 'nodes.less';
 @import 'canvas/canvas.less';
+@import 'canvas/container.less';
 @import 'canvas/documentElement.less';
 @import 'canvas/genericElement.less';
 @import 'canvas/gutter.less';
index 7938911..35ac7a8 100644 (file)
 [wlxml-tag="aside"] {
     margin-top: 10px;
     margin-bottom: 10px;
-
-    [wlxml-class='gap'] {
-        display: inline;
-    }
 }
-
-[wlxml-class="gap"] {
-    &:before, &:after {
-        color: darken(@blue, 10%);
-        font-weight: bold;
-        //vertical-align: super;
-    }
-    &:before {
-        content: "(";
-    }
-    &:after {
-        content: ")";
-    }
-}
\ No newline at end of file
index 4b35b12..976fee4 100644 (file)
 
     .toolbar-widget {
         display: inline-block;
-        margin: 0 5px;
+        margin: 0;
         div {
             display: inline-block;
         }
+        .btn {
+            border-radius: 0;
+        }
     }
     
 }
index 6a663b1..b2e3151 100644 (file)
@@ -1,3 +1,5 @@
+@import 'edumed/edumed.less';
+
 .canvas-widget-footnote-handle {
     display: inline;
     outline: 0px solid transparent;
index e300232..31ac09d 100644 (file)
@@ -11,9 +11,30 @@ var _ = require('libs/underscore'),
     plugin = {name: 'core', actions: [], canvas: {}, documentExtension: {textNode: {}, documentNode: {}}},
     Dialog = require('views/dialog/dialog'),
     canvasElements = require('plugins/core/canvasElements'),
-    metadataEditor = require('plugins/core/metadataEditor/metadataEditor');
-
-
+    metadataEditor = require('plugins/core/metadataEditor/metadataEditor'),
+    edumed = require('plugins/core/edumed/edumed');
+
+
+var exerciseFix = function(newNodes) {
+    var list, exercise, max, addedItem, answerValues;
+    if(newNodes.created.is('item')) {
+        list = newNodes.created.parent();
+        exercise = list.parent();
+        if(exercise && exercise.is('exercise')) {
+            if(exercise.is('exercise.order')) {
+                answerValues = exercise.object.getItems()
+                            .map(function(item) {
+                                if(!addedItem && item.node.sameNode(newNodes.created)) {
+                                    addedItem = item;
+                                }
+                                return item.getAnswer();
+                            });
+                max = Math.max.apply(Math.max, answerValues);
+                addedItem.setAnswer(max + 1);
+            }
+        }
+    }
+};
 
 plugin.documentExtension.textNode.transformations = {
     breakContent: {
@@ -38,6 +59,21 @@ plugin.documentExtension.textNode.transformations = {
                     return true; // break
                 }
             });
+
+            /* <hack>
+            /*
+                This makes sure that adding a new item to the list in some of the edumed exercises
+                sets an answer attribute that makes sense (and not just copies it which would create
+                a duplicate value).
+
+                This won't be neccessary when/if we introduce canvas element own key event handlers.
+
+                Alternatively, WLXML elements could implement their own item split methods that we
+                would delegate to.
+            */
+                exerciseFix(newNodes);
+            /* </hack> */
+
             parentDescribingNodes.forEach(function(node) {
                 newNodes.first.append(node);
             });
@@ -185,7 +221,7 @@ plugin.documentExtension.documentNode.transformations = {
                     return toMerge.is({tagName: 'div', 'klass': 'p'}) || (toMerge.is({tagName: 'div'}) && toMerge.getClass() === '');
                 },
                 run: function() {
-                    if(prev && prev.is('p') || prev.is({tagName: 'header'})) {
+                    if(prev && (prev.is('p') || prev.is({tagName: 'header'}))) {
                         return merge(toMerge, prev);
                     }
                     if(prev && prev.is('list')) {
@@ -224,7 +260,16 @@ plugin.documentExtension.documentNode.transformations = {
                     } else if(prev.is({tagName: 'span'})) {
                         if((txtNode = prev.getLastTextNode())) {
                             txt = txtNode.getText();
-                            txtNode.setText(txt.substr(0, txt.length-1));
+                            if(txt.length > 1) {
+                                txtNode.setText(txt.substr(0, txt.length-1));
+                            } else {
+                                if(txtNode.parent().contents().length === 1) {
+                                    txtNode.parent().detach();
+                                } else {
+                                    txtNode.detach();
+                                }
+
+                            }
                             return toret;
                         }
                     }
@@ -590,8 +635,7 @@ plugin.actions = [
     createWrapTextAction({name: 'cite', klass: 'cite', wrapDescription: gettext('Mark as citation'), unwrapDescription: gettext('Remove citation')}),
     linkAction,
     metadataEditor.action(metadataParams)
-].concat(plugin.actions, templates.actions, footnote.actions, switchTo.actions, lists.actions);
-
+].concat(plugin.actions, templates.actions, footnote.actions, switchTo.actions, lists.actions, edumed.actions);
 
 
 plugin.config = function(config) {
@@ -608,7 +652,7 @@ plugin.config = function(config) {
     });
 };
 
-plugin.canvasElements = canvasElements;
+plugin.canvasElements = canvasElements.concat(edumed.canvasElements);
 
 return plugin;
 
index 00fc421..27a7254 100644 (file)
@@ -546,7 +546,7 @@ describe('Keyboard interactions', function() {
             expect(selection.element.sameNode(getTextElement('has a cat', c))).to.equal(true);
             expect(selection.offset).to.equal(0);
         });
-        it('deletes span if it contains only one character', function() {
+        it('deletes span if it contains only one character (1)', function() {
             var c = getCanvasFromXML('<section>Alice <span>h</span> a cat</section>'),
                 k = new Keyboard(c);
 
@@ -561,6 +561,21 @@ describe('Keyboard interactions', function() {
             expect(selection.element.sameNode(getTextElement('Alice  a cat', c))).to.equal(true);
             expect(selection.offset).to.equal(6);
         });
+        it('deletes span if it contains only one character (2)', function() {
+            var c = getCanvasFromXML('<section><span>a</span><span>b</span></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('|b').press(K.BACKSPACE);
+
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(1);
+            expect(rootContents[0].contents()[0].getText()).to.equal('b');
+
+            var selection = c.getSelection();
+            expect(selection.type).to.equal('caret');
+            expect(selection.element.sameNode(getTextElement('b', c))).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
     });
 
     describe('splitting with enter', function() {
@@ -621,6 +636,17 @@ describe('Keyboard interactions', function() {
             expect(selection.offset).to.equal(0);
         });
 
+        it('does nothing on an empty paragraph', function() {
+            var c = getCanvasFromXML('<section><div class="p">a</div></section>'),
+                k = new Keyboard(c),
+                spy = sinon.spy();
+
+            k.withCaret('a|').press(K.BACKSPACE);
+            c.wlxmlDocument.on('change', spy);
+            k.press(K.ENTER);
+            expect(spy.callCount).to.equal(0);
+        });
+
         it('splits its parent box if inside a span', function() {
             var c = getCanvasFromXML('<section><div class="p">this <span>is</span> a paragraph</div></section>'),
                 k = new Keyboard(c);
@@ -700,6 +726,29 @@ describe('Keyboard interactions', function() {
         });
     });
 
+    describe('Enter on a list items', function() {
+        afterEach(removeCanvas);
+
+        it('creates a paragraph after a list if hitting enter on the last and empty list item', function() {
+            var c = getCanvasFromXML('<section><div class="list"><div class="item">item</div></div></section>'),
+                k = new Keyboard(c);
+
+            k.withCaret('item|').press(K.ENTER).press(K.ENTER);
+            
+            var rootContents = c.wlxmlDocument.root.contents();
+            expect(rootContents.length).to.equal(2);
+            expect(rootContents[0].is('list')).to.equal(true);
+            expect(rootContents[1].is('p')).to.equal(true);
+
+            var list = rootContents[0];
+            expect(list.contents().length).to.equal(1);
+            
+            var selection = c.getSelection();
+            expect(selection.element.wlxmlNode.sameNode(rootContents[1].contents()[0])).to.equal(true);
+            expect(selection.offset).to.equal(0);
+        });
+    });
+
     describe('Deleting text from a node', function() {
         it('deletes last character with backspace', function() {
             var c = getCanvasFromXML('<section><div class="p">a</div><div class="p">b</div></section>'),
diff --git a/src/editor/plugins/core/edumed/actions.js b/src/editor/plugins/core/edumed/actions.js
new file mode 100644 (file)
index 0000000..b79d109
--- /dev/null
@@ -0,0 +1,80 @@
+define(function(require) {
+    
+'use strict';
+
+/* globals gettext */
+
+var _ = require('libs/underscore');
+
+var descriptionText = gettext('Description goes here') + '...',
+    firstItemText = gettext('First item') + '...';
+
+var createAction = function(actionConfig) {
+
+    return {
+        name: actionConfig.name,
+        params: {
+            fragment: {type: 'context', name: 'fragment'}
+        },
+        stateDefaults: {
+            icon: actionConfig.icon || null,
+            execute: function(callback, params) {
+                /* globals Node */
+                void(callback);
+
+                var node = params.fragment.node;
+                if(node.nodeType === Node.TEXT_NODE) {
+                    node = node.parent();
+                }
+                if(node.isInside('exercise')) {
+                    node = node.getParent('exercise');
+                }
+
+                node.document.transaction(function() {
+                    var exerciseNode = node.after(node.document.edumedCreateExerciseNode(actionConfig.exercise)),
+                        doc = params.fragment.document,
+                        cursorTarget;
+
+                    //@@
+                    if(actionConfig.exercise === 'order') {
+                        exerciseNode.object.addItem(firstItemText);
+                        cursorTarget = exerciseNode.contents('.p')[0].append({text: descriptionText});
+                    } else if(_.contains(['gap', 'replace'], actionConfig.exercise)) {
+                        cursorTarget = _.first(exerciseNode.contents('.p'));
+                        if(cursorTarget) {
+                            cursorTarget = cursorTarget.append({text: ''});
+                        }
+                    } else if(actionConfig.exercise.split('.')[0] === 'choice') {
+                        _.first(exerciseNode.find('p')).append({text: descriptionText});
+                        cursorTarget = _.first(exerciseNode.find('item.answer'));
+                        if(cursorTarget) {
+                            cursorTarget = cursorTarget.append({text: ''});
+                        }
+                    }
+                    if(cursorTarget) {
+                        callback(doc.createFragment(doc.CaretFragment, {node: cursorTarget, offset: cursorTarget.getText().length}));
+                    }
+                });
+                
+            }
+        },
+        getState: function(params) {
+            return {
+                allowed: params.fragment && params.fragment.isValid() && params.fragment instanceof params.fragment.NodeFragment,
+                description: gettext('Insert exercise') + ': ' + actionConfig.exerciseTitle
+            };
+        }
+    };
+
+};
+
+return [
+    createAction({name: 'insertOrderExercise', icon: 'random', exercise: 'order', exerciseTitle: gettext('Order')}),
+    createAction({name: 'insertChoiceSingleExercise', icon: 'ok-circle', exercise: 'choice.single', exerciseTitle: gettext('Single Choice')}),
+    createAction({name: 'insertChoiceMultiExercise', icon: 'check', exercise: 'choice', exerciseTitle: gettext('Multiple Choice')}),
+    createAction({name: 'insertChoiceTrueOrFalseExercise', icon: 'adjust', exercise: 'choice.true-or-false', exerciseTitle: gettext('True or False')}),
+    createAction({name: 'insertGapsExercise', icon: 'question-sign', exercise: 'gap', exerciseTitle: gettext('Gaps')}),
+    createAction({name: 'insertReplaceExercise', icon: 'thumbs-down', exercise: 'replace', exerciseTitle: gettext('Replace')})
+];
+
+});
diff --git a/src/editor/plugins/core/edumed/choice/choice.less b/src/editor/plugins/core/edumed/choice/choice.less
new file mode 100644 (file)
index 0000000..6a80f87
--- /dev/null
@@ -0,0 +1,3 @@
+@import 'choiceTrueOrFalse.less';
+@import 'choiceSingle.less';
+@import 'choiceMulti.less';
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/choice/choiceBase.js b/src/editor/plugins/core/edumed/choice/choiceBase.js
new file mode 100644 (file)
index 0000000..aeafed4
--- /dev/null
@@ -0,0 +1,62 @@
+define(function(require) {
+    
+'use strict';
+
+var $ = require('libs/jquery'),
+    _ = require('libs/underscore'),
+    elementBase = require('plugins/core/edumed/elementBase'),
+    template = require('libs/text!./element.html');
+
+
+var choiceBase = Object.create(elementBase);
+_.extend(choiceBase, {
+    init: function() {
+        elementBase.init.call(this);
+        this.view = $(_.template(template)({type: this.type, name: this.name}));
+        this._container().append(this.view);
+
+        this.createContainer(this.wlxmlNode.contents().filter(function(n) {return !n.is('list');}), {
+            resetBackground: true,
+            manages: function(node, removedFrom) {
+                if(node.is('list.orderable')) {
+                    return false;
+                }
+                return this.wlxmlNode.sameNode(node.parent() || removedFrom);
+            }.bind(this),
+            dom: this.view.find('.description')
+        });
+
+        this.wlxmlNode.contents()
+            .filter(function(node) { return node.is('list'); })
+            .some(function(node) {
+                this.listView = this.createListView(node);
+                this.view.find('.list').append(this.listView.dom);
+            }.bind(this));
+    },
+    getVerticallyFirstTextElement: function() {
+        var toret;
+        this.containers.some(function(container) {
+            toret = container.getVerticallyFirstTextElement();
+            return !!toret;
+        });
+        return toret;
+    },
+    onNodeAdded: function(event) {
+        var node = event.meta.node;
+        if(this.listView.listNode.sameNode(node.parent()) && node.is('item.answer')) {
+            this.listView.addItem(node);
+        }
+    },
+    onNodeDetached: function(event) {
+        var node = event.meta.node;
+        if(this.listView.listNode.sameNode(event.meta.parent) && node.is('item.answer')) {
+            this.listView.removeItem(node);
+        }
+    }
+});
+
+return choiceBase;
+
+});
+
+
diff --git a/src/editor/plugins/core/edumed/choice/choiceMulti.js b/src/editor/plugins/core/edumed/choice/choiceMulti.js
new file mode 100644 (file)
index 0000000..0983930
--- /dev/null
@@ -0,0 +1,50 @@
+define(function(require) {
+    
+'use strict';
+
+/* globals gettext */
+
+var $ = require('libs/jquery'),
+    _ = require('libs/underscore'),
+    choiceBase = require('./choiceBase'),
+    ListView = require('./list');
+
+
+var choiceMulti = Object.create(choiceBase);
+_.extend(choiceMulti, {
+    type: 'multi',
+    name: gettext('Multiple Choice'),
+    createListView: function(listNode) {
+        return new ListView(this, listNode, {
+            onItemViewAdded: function(itemView) {
+                var checkbox = new CheckboxView(itemView.node.getAttr('answer') === 'true', function(checked) {
+                    itemView.node.document.transaction(function() {
+                        itemView.node.getParent('exercise.choice').object.setAnswer(itemView.node, checked);
+                    }, {
+                        metadata: {
+                            description: gettext('Change answer')
+                        }
+                    });
+                });
+                itemView.addPrefixView(checkbox);
+            }
+        });
+    },
+    onNodeAttrChange: function(event) {
+        if(this.listView.listNode.sameNode(event.meta.node.parent())) {
+            this.listView.getItemView(event.meta.node).prefixView.dom.attr('checked', event.meta.newVal === 'true');
+        }
+    }
+});
+
+var CheckboxView = function(checked, onValueChange) {
+    this.dom = $('<input type="checkbox">')
+            .attr('checked', checked);
+    this.dom.on('click', function() {
+        onValueChange(this.checked);
+    });
+};
+
+return choiceMulti;
+
+});
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/choice/choiceMulti.less b/src/editor/plugins/core/edumed/choice/choiceMulti.less
new file mode 100644 (file)
index 0000000..92877b2
--- /dev/null
@@ -0,0 +1,11 @@
+.exercise-multi {
+    input {
+        margin-right: 15px;
+    }
+    ul {
+        margin: 0;
+    }
+    li {
+        list-style-type: none;
+    }
+}
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/choice/choiceSingle.js b/src/editor/plugins/core/edumed/choice/choiceSingle.js
new file mode 100644 (file)
index 0000000..68ff576
--- /dev/null
@@ -0,0 +1,57 @@
+define(function(require) {
+    
+'use strict';
+
+/* globals gettext */
+
+var $ = require('libs/jquery'),
+    _ = require('libs/underscore'),
+    choiceBase = require('./choiceBase'),
+    ListView = require('./list');
+
+
+var choiceSingle = Object.create(choiceBase);
+_.extend(choiceSingle, {
+    type: 'single',
+    name: gettext('Single Choice'),
+    init: function() {
+        this._comboName = _.uniqueId('edumed_exercise_hash_');
+        choiceBase.init.call(this);
+    },
+    createListView: function(listNode) {
+        var el = this;
+        return new ListView(this, listNode, {
+            onItemViewAdded: function(itemView) {
+                var radiobox = new RadioView(itemView.node.getAttr('answer') === 'true', el._comboName, function() {
+                    itemView.node.document.transaction(function() {
+                        itemView.node.getParent('exercise.choice').object.markAsAnswer(itemView.node);
+                    }, {
+                        metadata: {
+                            description: gettext('Change answer')
+                        }
+                    });
+                    
+                });
+                itemView.addPrefixView(radiobox);
+            }
+        });
+    },
+    onNodeAttrChange: function(event) {
+        if(this.listView.listNode.sameNode(event.meta.node.parent())) {
+            this.listView.getItemView(event.meta.node).prefixView.dom.attr('checked', event.meta.newVal === 'true');
+        }
+    }
+});
+
+var RadioView = function(checked, name, onValueChange) {
+    this.dom = $('<input type="radio">')
+            .attr('checked', checked)
+            .attr('name', name);
+    this.dom.on('change', function() {
+        onValueChange(this.checked);
+    });
+};
+
+return choiceSingle;
+
+});
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/choice/choiceSingle.less b/src/editor/plugins/core/edumed/choice/choiceSingle.less
new file mode 100644 (file)
index 0000000..ddb9c96
--- /dev/null
@@ -0,0 +1,11 @@
+.exercise-single {
+    input {
+        margin-right: 15px;
+    }
+    ul {
+        margin: 0;
+    }
+    li {
+        list-style-type: none;
+    }
+}
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/choice/choiceTrueOrFalse.js b/src/editor/plugins/core/edumed/choice/choiceTrueOrFalse.js
new file mode 100644 (file)
index 0000000..bf1a9ea
--- /dev/null
@@ -0,0 +1,57 @@
+define(function(require) {
+    
+'use strict';
+
+/* globals gettext */
+
+var $ = require('libs/jquery'),
+    _ = require('libs/underscore'),
+    choiceBase = require('./choiceBase'),
+    switchTemplate = require('libs/text!./trueOrFalseSwitch.html'),
+    ListView = require('./list');
+
+
+var trueOrFalse  = Object.create(choiceBase);
+_.extend(trueOrFalse, {
+    type: 'true-or-false',
+    name: gettext('True or False'),
+    createListView: function(listNode) {
+        return new ListView(this, listNode, {
+            onItemViewAdded: function(itemView) {
+                var switchView = new Switch(itemView.node.getAttr('answer') === 'true', function(choice) {
+                    itemView.node.document.transaction(function() {
+                        itemView.node.getParent('exercise.choice').object.setAnswer(itemView.node, choice);
+                    }, {
+                        metadata: {
+                            description: gettext('Change answer')
+                        }
+                    });
+                });
+                itemView.addPrefixView(switchView);
+            }
+        });
+    }
+});
+
+var Switch = function(checked, onValueChange) {
+    this.dom = $(_.template(switchTemplate)());
+    var trueBtn = this.dom.find('.true'),
+        falseBtn = this.dom.find('.false');
+
+    trueBtn.on('click', function() {
+        trueBtn.addClass('selected');
+        falseBtn.removeClass('selected');
+        onValueChange(true);
+    });
+    this.dom.find('.false').on('click', function() {
+        falseBtn.addClass('selected');
+        trueBtn.removeClass('selected');
+        onValueChange(false);
+    });
+    trueBtn.toggleClass('selected', checked);
+    falseBtn.toggleClass('selected', !checked);
+};
+
+return trueOrFalse;
+
+});
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/choice/choiceTrueOrFalse.less b/src/editor/plugins/core/edumed/choice/choiceTrueOrFalse.less
new file mode 100644 (file)
index 0000000..40d0318
--- /dev/null
@@ -0,0 +1,19 @@
+.exercise-true-or-false {
+    ul {
+        list-style: none;
+    }
+    .switch {
+        font-variant: small-caps;
+        float: left;
+        margin-right: 15px;
+        background-color: #d4d6d8;
+        cursor: pointer;
+        border-radius: 5px;
+        padding: 0px 5px;
+        font-weight: bold;
+        .selected {
+            color: white;
+            background-color: #ed7831;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/choice/element.html b/src/editor/plugins/core/edumed/choice/element.html
new file mode 100644 (file)
index 0000000..665d957
--- /dev/null
@@ -0,0 +1,5 @@
+<div class="edumed-exercise exercise-<%= type %>">
+    <div class="header"><%= gettext('Exercise') %>: <%= name %></div>
+    <div class="description"></div>
+    <div class="list"></div>
+</div>
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/choice/element.js b/src/editor/plugins/core/edumed/choice/element.js
new file mode 100644 (file)
index 0000000..0514dae
--- /dev/null
@@ -0,0 +1,15 @@
+define(function(require) {
+    
+'use strict';
+
+var choiceSingle = require('./choiceSingle'),
+    choiceMulti = require('./choiceMulti'),
+    choiceTrueOrFalse = require('./choiceTrueOrFalse');
+
+return [
+    {tag: 'div', klass: 'exercise.choice', prototype: choiceMulti},
+    {tag: 'div', klass: 'exercise.choice.single', prototype: choiceSingle},
+    {tag: 'div', klass: 'exercise.choice.true-or-false', prototype: choiceTrueOrFalse},
+];
+
+});
diff --git a/src/editor/plugins/core/edumed/choice/list.html b/src/editor/plugins/core/edumed/choice/list.html
new file mode 100644 (file)
index 0000000..11c4e94
--- /dev/null
@@ -0,0 +1,3 @@
+<div class="exercise-list">
+    <ul></ul>
+</div>
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/choice/list.js b/src/editor/plugins/core/edumed/choice/list.js
new file mode 100644 (file)
index 0000000..9174710
--- /dev/null
@@ -0,0 +1,98 @@
+define(function(require) {
+    
+'use strict';
+var $ = require('libs/jquery');
+
+var _ = require('libs/underscore'),
+    Backbone = require('libs/backbone'),
+    viewTemplate = require('libs/text!./list.html'),
+    viewItemTemplate = require('libs/text!./listItem.html');
+
+
+var ListView = function(element, listNode, params) {
+    this.element = element;
+    this.listNode = listNode;
+    this.params = params;
+    this.dom = $(_.template(viewTemplate)());
+    this.list = this.dom.find('ul');
+    this.addButton = this.dom.find('button.add');
+    this.itemViews = [];
+
+    this.listNode.contents()
+        .filter(function(node) {
+            return node.is('item.answer');
+        })
+        .forEach(function(node) {
+            this.addItem(node);
+        }.bind(this));
+};
+_.extend(ListView.prototype, Backbone.Events, {
+    addItem: function(node) {
+        var view = new ItemView(node, this);
+        var idx = this.listNode.contents()
+            .filter(function(n) { return n.is('item'); })
+            .indexOf(node);
+
+        if(idx <= this.itemViews.length - 1) {
+                this.itemViews.splice(idx, 0, view);
+        } else {
+            this.itemViews.push(view);
+        }
+        if(idx > 0) {
+            this.itemViews[idx-1].dom.after(view.dom);
+        } else {
+            this.list.prepend(view.dom);
+        }
+        if(this.params.onItemViewAdded) {
+            this.params.onItemViewAdded(view);
+        }
+    },
+    removeItem: function(node) {
+        this.itemViews.some(function(view, idx) {
+            if(view.node.sameNode(node)) {
+                view.remove();
+                this.itemViews.splice(idx, 1);
+                return true;
+            }
+        }.bind(this));
+    },
+    getItemView: function(node) {
+        var toret;
+        this.itemViews.some(function(view) {
+            if(view.node.sameNode(node)) {
+                toret = view;
+                return true;
+            }
+        }.bind(this));
+        return toret;
+    }
+});
+
+var ItemView = function(node, exerciseView) {
+    this.node = node;
+    this.exerciseView = exerciseView;
+    this.dom = $(_.template(viewItemTemplate)());
+
+    this.container = exerciseView.element.createContainer(node.contents(), {
+        resetBackground: true,
+        manages: function(node, originaParent) {
+            return this.node.sameNode(node.parent() || originaParent);
+        }.bind(this),
+        dom: this.dom.find('.content')
+    });
+};
+
+_.extend(ItemView.prototype, Backbone.Events, {
+    remove: function() {
+        this.container.remove();
+        this.dom.remove();
+    },
+    addPrefixView: function(view) {
+        this.dom.find('.prefix').append(view.dom);
+        this.prefixView = view;
+    }
+});
+
+return ListView;
+
+});
diff --git a/src/editor/plugins/core/edumed/choice/listItem.html b/src/editor/plugins/core/edumed/choice/listItem.html
new file mode 100644 (file)
index 0000000..4735c9b
--- /dev/null
@@ -0,0 +1 @@
+<li><span class="prefix"></span><span class="content"></span></li>
diff --git a/src/editor/plugins/core/edumed/choice/trueOrFalseSwitch.html b/src/editor/plugins/core/edumed/choice/trueOrFalseSwitch.html
new file mode 100644 (file)
index 0000000..ee89828
--- /dev/null
@@ -0,0 +1,3 @@
+<div class="switch">
+    <span class="true"><%= gettext('true') %></span><span class="false"><%= gettext('false') %></span>
+</div>
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/edumed.js b/src/editor/plugins/core/edumed/edumed.js
new file mode 100644 (file)
index 0000000..6ba5996
--- /dev/null
@@ -0,0 +1,18 @@
+define(function(require) {
+    
+'use strict';
+
+var actions = require('./actions'),
+    gapsActions = require('./gaps/actions'),
+    replaceActions = require('./replace/actions'),
+    orderExerciseElement = require('./order/element'),
+    gapsExerciseElement = require('./gaps/element'),
+    replaceExerciseElement = require('./replace/element'),
+    choiceExerciseElements = require('./choice/element');
+
+return {
+    actions: actions.concat(gapsActions).concat(replaceActions),
+    canvasElements: [orderExerciseElement, gapsExerciseElement, replaceExerciseElement].concat(choiceExerciseElements)
+};
+
+});
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/edumed.less b/src/editor/plugins/core/edumed/edumed.less
new file mode 100644 (file)
index 0000000..fc79e39
--- /dev/null
@@ -0,0 +1,30 @@
+@import 'order/order.less';
+@import 'gaps/gaps.less';
+@import 'replace/replace.less';
+@import 'choice/choice.less';
+
+.edumed-exercise {
+    background-color: @grayLighter;
+    border: 1px lighten(#000, 35%) dashed;
+    margin: 15px 0;
+    padding: 10px;
+
+    .header {
+        font-weight: bold;
+        font-variant: small-caps;
+        margin-bottom: 10px;
+        background-color: @grayLighter;;
+    }
+
+    .btn {
+        line-height: 12px;
+    }
+}
+
+.edumed-exercise-remove {
+    position: absolute;
+    top: 20px;
+    right: 5px;
+    padding: 0 3px;
+    line-height: 15px;
+}
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/elementBase.js b/src/editor/plugins/core/edumed/elementBase.js
new file mode 100644 (file)
index 0000000..9f87dd1
--- /dev/null
@@ -0,0 +1,48 @@
+define(function(require) {
+    
+'use strict';
+
+/* globals gettext */
+
+var $ = require('libs/jquery'),
+    _ = require('libs/underscore'),
+    documentElement = require('modules/documentCanvas/canvas/documentElement'),
+    Dialog = require('views/dialog/dialog');
+
+    
+var choiceBase = Object.create(documentElement.DocumentNodeElement.prototype);
+_.extend(choiceBase, {
+    init: function() {
+        var el = this;
+        documentElement.DocumentNodeElement.prototype.init.call(this);
+        this.x = $('<div class="edumed-exercise-remove btn btn-mini btn-danger">x</div>');
+        this.x.on('click', function() {
+            var dialog = Dialog.create({
+                title: gettext('Removing exercise'),
+                text: gettext('Do you really want to remove this exercise?'),
+                executeButtonText: gettext('Yes'),
+                cancelButtonText: gettext('No, don\'t do anything!')
+            });
+            dialog.on('execute', function(event) {
+                el.canvas.wlxmlDocument.transaction(function() {
+                    el.wlxmlNode.detach();
+                }, {
+                    metadata: {
+                        description: gettext('Removing exercise')
+                    },
+                    success: function() {
+                        event.success();
+                    }
+                });
+            });
+            dialog.show();
+
+        });
+
+        this.addWidget(this.x);
+    },
+});
+
+return choiceBase;
+
+});
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/gaps/actions.js b/src/editor/plugins/core/edumed/gaps/actions.js
new file mode 100644 (file)
index 0000000..e91faa4
--- /dev/null
@@ -0,0 +1,92 @@
+define(function(require) {
+    
+'use strict';
+
+/* globals gettext */
+
+var _ = require('libs/underscore');
+
+
+var createGap = {
+    name: 'createGap',
+    params: {
+        fragment: {type: 'context', name: 'fragment'}
+    },
+    stateDefaults: {
+        icon: null,
+        label: gettext('Create a gap'),
+        execute: function(callback, params) {
+            var doc = params.fragment.document;
+
+            doc.transaction(function() {
+                var wrapper = params.fragment.startNode.parent().wrapText({
+                        _with: {tagName: 'aside', attrs: {'class': 'gap'}},
+                        offsetStart: params.fragment.startOffset,
+                        offsetEnd: params.fragment.endOffset,
+                        textNodeIdx: [params.fragment.startNode.getIndex(), params.fragment.endNode.getIndex()]
+                    }),
+                    last = _.last(wrapper.contents());
+
+                return doc.createFragment(doc.CaretFragment, {node: last, offset: last.getText().length});
+            }, {
+                metadata: {
+                    description: gettext('Create a gap')
+                },
+                success: callback
+            });
+        }
+    },
+    getState: function(params) {
+        return {
+            allowed: params.fragment &&
+                        params.fragment.isValid() &&
+                        params.fragment instanceof params.fragment.TextRangeFragment &&
+                        params.fragment.hasSiblingBoundries() &&
+                        params.fragment.startNode.isInside('exercise.gap') &&
+                        !params.fragment.startNode.isInside({tagName: 'aside', klass: 'gap'}),
+                        
+            description: gettext('Turn selection into a gap')
+        };
+    }
+};
+
+var removeGap = {
+    name: 'removeGap',
+    params: {
+        fragment: {type: 'context', name: 'fragment'}
+    },
+    stateDefaults: {
+        icon: null,
+        label: gettext('Remove a gap'),
+        execute: function(callback, params) {
+            var doc = params.fragment.document;
+
+            doc.transaction(function() {
+                var ret = params.fragment.node.getParent('gap').unwrapContent();
+
+                return doc.createFragment(doc.CaretFragment, {node:ret.element2, offset: ret.element2.getText().length});
+            }, {
+                metadata: {
+                    description: gettext('Remove a gap')
+                },
+                success: callback
+            });
+        }
+    },
+    getState: function(params) {
+        return {
+            allowed: params.fragment &&
+                        params.fragment.isValid() &&
+                        params.fragment instanceof params.fragment.NodeFragment &&
+                        params.fragment.node.isInside('exercise.gap') &&
+                        params.fragment.node.isInside('gap'),
+                        
+            description: gettext('Remove a gap')
+        };
+    }
+};
+
+
+return [createGap, removeGap];
+
+});
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/gaps/element.js b/src/editor/plugins/core/edumed/gaps/element.js
new file mode 100644 (file)
index 0000000..db8d0c5
--- /dev/null
@@ -0,0 +1,53 @@
+define(function(require) {
+    
+'use strict';
+
+
+var $ = require('libs/jquery'),
+    _ = require('libs/underscore'),
+    elementBase = require('plugins/core/edumed/elementBase'),
+    viewTemplate = require('libs/text!./view.html');
+
+var OrderExerciceElement = Object.create(elementBase);
+_.extend(OrderExerciceElement, {
+    init: function() {
+        elementBase.init.call(this);
+        var view  = $(_.template(viewTemplate)());
+        this._container().append(view);
+
+        this.createContainer(this.wlxmlNode.contents(), {
+            resetBackground: true,
+            manages: function() {
+                return true;
+            },
+            dom: view.find('.content')
+        });
+
+        this.addToContextMenu('core.createGap');
+        this.contextMenuActions[0].on('actionExecuted', function(ret) {
+            if(ret instanceof this.wlxmlNode.document.Fragment && ret.isValid()) {
+                this.canvas.select(ret);
+            }
+        }.bind(this));
+        this.addToContextMenu('core.removeGap');
+    },
+    getVerticallyFirstTextElement: function() {
+        // doesnt container handle this?
+        var toret;
+        this.containers.some(function(container) {
+            toret = container.getVerticallyFirstTextElement();
+            return !!toret;
+        });
+        return toret;
+    }
+});
+
+return {tag: 'div', klass: 'exercise.gap', prototype: OrderExerciceElement};
+
+});
+
+
+    
+
+
+
diff --git a/src/editor/plugins/core/edumed/gaps/gaps.less b/src/editor/plugins/core/edumed/gaps/gaps.less
new file mode 100644 (file)
index 0000000..812b0e1
--- /dev/null
@@ -0,0 +1,19 @@
+[wlxml-tag="aside"] {
+    [wlxml-class='gap'] {
+        display: inline;
+    }
+}
+
+[wlxml-class="gap"] {
+    &:before, &:after {
+        color: darken(@blue, 10%);
+        font-weight: bold;
+        //vertical-align: super;
+    }
+    &:before {
+        content: "(";
+    }
+    &:after {
+        content: ")";
+    }
+}
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/gaps/view.html b/src/editor/plugins/core/edumed/gaps/view.html
new file mode 100644 (file)
index 0000000..7d4210b
--- /dev/null
@@ -0,0 +1,4 @@
+<div class="edumed-exercise exercise-gaps">
+    <div class="header"><%= gettext('Exercise') %>: <%= gettext('Gaps') %></div>
+    <div class="content"></div>
+</div>
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/order/element.js b/src/editor/plugins/core/edumed/order/element.js
new file mode 100644 (file)
index 0000000..724f2ec
--- /dev/null
@@ -0,0 +1,106 @@
+define(function(require) {
+    
+'use strict';
+
+/* globals gettext */
+
+var _ = require('libs/underscore');
+
+var elementBase = require('plugins/core/edumed/elementBase'),
+    OrderExerciseView = require('./view');
+
+var OrderExerciceElement = Object.create(elementBase);
+_.extend(OrderExerciceElement, {
+    init: function() {
+        elementBase.init.call(this);
+        
+        this.view = new OrderExerciseView(this, this.wlxmlNode);
+        this._container().append(this.view.dom);
+
+        this.view.on('newItemRequested', function() {
+            this.wlxmlNode.document.transaction(function() {
+                var textNode = this.wlxmlNode.object.addItem('');
+                var doc = this.wlxmlNode.document;
+                return doc.createFragment(doc.CaretFragment, {node: textNode, offset:0});
+            }.bind(this), {
+                metadata: {
+                    description: gettext('Add item to exercise')
+                },
+                success: function(ret) {
+                    this.canvas.select(ret);
+                }.bind(this)
+            });
+        }.bind(this));
+
+        this.view.on('moveAnswer', function(sourceItem, targetItem, where) {
+            this.wlxmlNode.document.transaction(function() {
+                sourceItem.setAnswer(targetItem.getAnswer() + (where === 'before' ? 0 : 1));
+            }, {
+                metadata: {
+                    description: gettext('Change solution')
+                }
+            });
+        }.bind(this));
+
+        this.view.on('moveItem', function(sourceItem, targetItem, where) {
+            this.wlxmlNode.document.transaction(function() {
+                targetItem.node[where](sourceItem.node);
+            }, {
+                metadata: {
+                    description: gettext('Change order')
+                }
+            });
+            
+        }.bind(this));
+
+        var exerciseNode = this.wlxmlNode;
+        this.createContainer(this.wlxmlNode.object.getDescription(), {
+            resetBackground: true,
+            manages: function(node, removedFrom) {
+                if(node.is('list.orderable') || (removedFrom && removedFrom.is('list.orderable'))) {
+                    return false;
+                }
+                return exerciseNode.sameNode(node.parent() || removedFrom); //!n.hasFollowingSibing(this.params.listnode);    
+            },
+            dom: this.view.dom.find('.description')
+        });
+
+        this.reloadView();
+    },
+    onNodeAdded: function(event) {
+        var node = event.meta.node;
+        if(this.wlxmlNode.object.isItemNode(node)) {
+            this.reloadView();
+        }
+    },
+    onNodeAttrChange: function(event) {
+        var node = event.meta.node;
+        if(node.is('item.answer') && node.parent() && node.parent().is('list.orderable')) {
+            this.reloadView();
+        }
+    },
+    onNodeDetached: function(event) {
+        var node = event.meta.node;
+        if(node.is('item.answer') && event.meta.parent && event.meta.parent.is('list.orderable')) {
+            this.reloadView();
+        }
+    },
+    reloadView: function() {
+        this.view.clearItems();
+        this.wlxmlNode.object.getItems().forEach(function(item) {
+            this.view.addItem(item);
+        }.bind(this));
+    },
+    getVerticallyFirstTextElement: function() {
+        var toret;
+        this.containers.some(function(container) {
+            toret = container.getVerticallyFirstTextElement();
+            return !!toret;
+        });
+        return toret;
+    }
+});
+
+return {tag: 'div', klass: 'exercise.order', prototype: OrderExerciceElement};
+
+});
diff --git a/src/editor/plugins/core/edumed/order/order.less b/src/editor/plugins/core/edumed/order/order.less
new file mode 100644 (file)
index 0000000..e0c1b52
--- /dev/null
@@ -0,0 +1,70 @@
+@exercise-order-gray: darken(#ddd, 15%);
+.exercise-order {
+    
+    overflow-x: hidden;
+
+    .description {
+        margin-bottom: 20px;
+    }
+
+    .wrapper {
+        display: table;
+        width: 100%;
+        border-radius: 5px;
+        background-color: #ddd;
+    }
+
+    .content {
+        display: table-cell;
+        width: calc(~'100% - 40px');
+        padding: 10px 10px;
+        cursor: default;
+
+        .dragEnter {
+            opacity: 0.5;
+        }
+    }
+
+    .handle {
+        vertical-align: middle;
+        display: table-cell;
+        width: 20px;
+        background-color: @exercise-order-gray;
+        text-align: center;
+        border-radius: 0 5px 5px 0;
+        padding: 10px 10px;
+
+        .dragEnter {
+            opacity: 0.5;
+        }
+    }
+
+    .placeholder {
+        height: 10px;
+        transition: height 0.05s;
+        background-color: transparent;
+        &.active {
+            height: 60px;
+            transition: height 0.05s;
+            background-color: lighten(@blue,40%);
+            border: 1px dashed @blue;
+        }
+        width:100%;
+    }
+
+    ol {
+        margin: 0;
+        counter-reset: edumed-order-counter;
+    }
+    ol li {
+        list-style-type: none;
+        cursor: move;
+        .dragged {
+            opacity: 0.6;
+        }
+    }
+
+    .nav-tabs > .active > a {
+        background-color: @exercise-order-gray;
+    }
+}
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/order/view.html b/src/editor/plugins/core/edumed/order/view.html
new file mode 100644 (file)
index 0000000..561de89
--- /dev/null
@@ -0,0 +1,10 @@
+<div class="edumed-exercise exercise-order">
+    <div class="header"><%= gettext('Exercise') %>: <%= gettext('Order') %></div>
+    <div class="description"></div>
+    <ul class="nav nav-tabs modePills">
+        <li class="active" mode="initial"><a href="#"><%= gettext('Initial') %></a></li>
+        <li mode="correct"><a href="#"><%= gettext('Solution') %></a></li>
+    </ul>
+    <div class="placeholder placeholder-top"></div>
+    <ol></ol>
+</div>
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/order/view.js b/src/editor/plugins/core/edumed/order/view.js
new file mode 100644 (file)
index 0000000..a90ded4
--- /dev/null
@@ -0,0 +1,214 @@
+define(function(require) {
+    
+'use strict';
+var $ = require('libs/jquery');
+
+var _ = require('libs/underscore'),
+    Backbone = require('libs/backbone'),
+    viewTemplate = require('libs/text!./view.html'),
+    viewItemTemplate = require('libs/text!./viewItem.html');
+
+
+var OrderExerciseView = function(element) {
+    this.element = element;
+    this.dom = $(_.template(viewTemplate)());
+    this.modePills = this.dom.find('.modePills');
+    this.list = this.dom.find('ol');
+    this.addButton = this.dom.find('button.add');
+    this.description = this.dom.find('.description');
+    this.itemViews = [];
+
+    this.addButton.on('click', function(e) {
+        e.stopPropagation();
+        e.preventDefault();
+        this.trigger('newItemRequested');
+        //_.last(this.itemViews).editStart();
+    }.bind(this));
+
+    this.modePills.find('a').on('click', function(e) {
+        e.stopPropagation();
+        e.preventDefault();
+        this.setMode($(e.target).parent().attr('mode'));
+    }.bind(this));
+
+    this.mode = 'initial';
+
+    var dropTargets = this.dom.find('.placeholder-top');
+
+    dropTargets.on('dragend', function() {
+        dropTargets.removeClass('dragged');
+    });
+
+    dropTargets.on('dragenter', function() {
+        var first = this.itemViews[0];
+        if(this.mode === 'correct') {
+            first = this.itemViews.slice(0)
+                .sort(function(view1, view2) {
+                    if(view1.item.getAnswer() > view2.item.getAnswer()) {
+                        return 1;
+                    }
+                    return -1;
+                })[0];
+        }
+        if(!this.allowDropAt(first, true)) {
+            return;
+        }
+        dropTargets.addClass('active');
+    }.bind(this));
+
+    dropTargets.on('dragleave', function() {
+        dropTargets.removeClass('active');
+    }.bind(this));
+
+    dropTargets.on('dragover', function(e) {
+        e.preventDefault();
+        e.originalEvent.dataTransfer.dropEffect = 'move';
+    });
+
+    dropTargets.on('drop', function(e) {
+        var vid = e.originalEvent.dataTransfer.getData('text');
+        var droppedItem = $('[vid='+vid+']').data('viewInstance');
+
+        var first = this.itemViews[0];
+        if(this.mode === 'correct') {
+            first = this.itemViews.slice(0)
+                .sort(function(view1, view2) {
+                    if(view1.item.getAnswer() > view2.item.getAnswer()) {
+                        return 1;
+                    }
+                    return -1;
+                })[0];
+        }
+
+        this.trigger(this.mode === 'initial' ? 'moveItem' : 'moveAnswer', droppedItem.item, first.item, 'before');
+        dropTargets.removeClass('active');
+        e.preventDefault();
+    }.bind(this));
+};
+_.extend(OrderExerciseView.prototype, Backbone.Events, {
+    addItem: function(item) {
+        var view = new ItemView(item, this);
+        view.on('edit', function(text) {
+            this.trigger('itemEdited', item, text);
+        }.bind(this));
+        view.on('receivedDrop', function(droppedItem) {
+            this.trigger(this.mode === 'initial' ? 'moveItem' : 'moveAnswer', droppedItem.item, item, 'after');
+        }.bind(this));
+        view.on('dragStarted', function(view) {
+            this.draggedView = view;
+        }.bind(this));
+        this.list.append(view.dom);
+        this.itemViews.push(view);
+
+        if(this.mode === 'correct') {
+            this.setMode(this.mode);
+        }
+    },
+    clearItems: function() {
+        this.list.empty();
+        this.itemViews.forEach(function(view) {
+            view.remove();
+        });
+        this.itemViews = [];
+    },
+    setMode: function(mode) {
+        this.modePills.find('li').removeClass('active');
+        this.modePills.find('[mode='+mode+']').addClass('active');
+        this.mode = mode;
+        this.list.children().detach();
+
+        if(this.mode === 'initial') {
+            this.itemViews.forEach(function(itemView) {
+                this.list.append(itemView.dom);
+            }.bind(this));
+        } else {
+            this.itemViews.slice(0)
+                .sort(function(view1, view2) {
+                    if(view1.item.getAnswer() > view2.item.getAnswer()) {
+                        return 1;
+                    }
+                    return -1;
+                })
+                .forEach(function(itemView) {
+                    this.list.append(itemView.dom);
+                }.bind(this));
+        }
+    },
+    allowDropAt: function(view, up) {
+        var arr = [this.draggedView.dom[0]];
+        if(!up) {
+            arr.push(this.draggedView.dom.prev()[0]);
+        }
+        return !_.contains(arr, view.dom[0]);
+    }
+});
+
+var ItemView = function(item, exerciseView) {
+    this.item = item;
+    this.exerciseView = exerciseView;
+    this.dom = $(_.template(viewItemTemplate)());
+    this.content = this.dom.find('.content');
+
+
+    var dropTargets = this.dom.find('.placeholder'),
+        dragSources = this.dom.find('.handle');
+
+    dragSources.on('dragstart', function(e) {
+        this.dom.addClass('dragged');
+        e.originalEvent.dataTransfer.setData('text', this.dom.attr('vid'));
+        e.originalEvent.effectAllowed = 'move';
+        this.trigger('dragStarted', this);
+
+    }.bind(this));
+
+    dropTargets.on('dragend', function() {
+        this.dom.removeClass('dragged');
+    });
+
+    dropTargets.on('dragenter', function() {
+        if(!this.exerciseView.allowDropAt(this)) {
+            return;
+        }
+        dropTargets.addClass('active');
+    }.bind(this));
+
+    dropTargets.on('dragleave', function() {
+        dropTargets.removeClass('active');
+    }.bind(this));
+
+    dropTargets.on('dragover', function(e) {
+        e.preventDefault();
+        e.originalEvent.dataTransfer.dropEffect = 'move';
+    });
+
+    dropTargets.on('drop', function(e) {
+        var vid = e.originalEvent.dataTransfer.getData('text');
+        var droppedItem = $('[vid='+vid+']').data('viewInstance');
+        e.preventDefault();
+        this.trigger('receivedDrop', droppedItem);
+    }.bind(this));
+
+    var content = this.content;
+    this.container = exerciseView.element.createContainer(item.node.contents(), {
+        resetBackground: true,
+        manages: function(node, originaParent) {
+            return item.node.sameNode(node.parent() || originaParent);
+        },
+        dom: content
+    });
+
+    this.dom.data('viewInstance', this);
+    this.dom.attr('vid', _.uniqueId());
+};
+
+_.extend(ItemView.prototype, Backbone.Events, {
+    remove: function() {
+        this.container.remove();
+    }
+});
+
+return OrderExerciseView;
+
+});
+
+
diff --git a/src/editor/plugins/core/edumed/order/viewItem.html b/src/editor/plugins/core/edumed/order/viewItem.html
new file mode 100644 (file)
index 0000000..33e242a
--- /dev/null
@@ -0,0 +1,9 @@
+<li>
+    <div class="wrapper">
+        <div class="handle" draggable="true">
+            <i class="icon-resize-vertical icon-white"></i>
+        </div>
+        <div class="content"></div>
+    </div>
+    <div class="placeholder"></div>
+</li>
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/replace/actions.js b/src/editor/plugins/core/edumed/replace/actions.js
new file mode 100644 (file)
index 0000000..be4782a
--- /dev/null
@@ -0,0 +1,154 @@
+define(function(require) {
+    
+'use strict';
+
+/* globals gettext */
+
+var _ = require('libs/underscore'),
+    Dialog = require('views/dialog/dialog');
+
+var markToReplace = {
+    name: 'markToReplace',
+    params: {
+        fragment: {type: 'context', name: 'fragment'}
+    },
+    stateDefaults: {
+        icon: null,
+        label: gettext('Mark to replace'),
+        execute: function(callback, params) {
+            var doc = params.fragment.document,
+                dialog = Dialog.create({
+                    title: gettext('Enter text to replace with'),
+                    executeButtonText: gettext('Apply'),
+                    cancelButtonText: gettext('Cancel'),
+                    fields: [
+                        {label: gettext('Text'), name: 'text', type: 'input'}
+                    ]
+                });
+
+
+            dialog.on('execute', function(event) {
+                doc.transaction(function() {
+                    var wrapper = params.fragment.startNode.parent().wrapText({
+                            _with: {tagName: 'span', attrs: {'class': 'answer', answer: event.formData.text}},
+                            offsetStart: params.fragment.startOffset,
+                            offsetEnd: params.fragment.endOffset,
+                            textNodeIdx: [params.fragment.startNode.getIndex(), params.fragment.endNode.getIndex()]
+                        }),
+                        last = _.last(wrapper.contents());
+
+                    return doc.createFragment(doc.CaretFragment, {node: last, offset: last.getText().length});
+                }, {
+                    metadata: {
+                        description: gettext('Mark to replace')
+                    },
+                    success: function(ret) { event.success(); callback(ret);}
+                });
+            });
+            dialog.show();
+
+        }
+    },
+    getState: function(params) {
+        return {
+            allowed: params.fragment &&
+                        params.fragment.isValid() &&
+                        params.fragment instanceof params.fragment.TextRangeFragment &&
+                        params.fragment.hasSameBoundries() &&
+                        params.fragment.startNode.isInside('exercise.replace') &&
+                        !params.fragment.startNode.isInside({tagName: 'span', klass: 'answer'}),
+                        
+            description: gettext('Mark selection to replacement')
+        };
+    }
+};
+
+var removeReplaceMark = {
+    name: 'removeReplaceMark',
+    params: {
+        fragment: {type: 'context', name: 'fragment'}
+    },
+    stateDefaults: {
+        icon: null,
+        label: gettext('Remove replace mark'),
+        execute: function(callback, params) {
+            var doc = params.fragment.document;
+
+            doc.transaction(function() {
+                var ret = params.fragment.node.getParent('answer').unwrapContent();
+
+                return doc.createFragment(doc.CaretFragment, {node:ret.element2, offset: ret.element2.getText().length});
+            }, {
+                metadata: {
+                    description: gettext('Remove replace mark')
+                },
+                success: callback
+            });
+        }
+    },
+    getState: function(params) {
+        return {
+            allowed: params.fragment &&
+                        params.fragment.isValid() &&
+                        params.fragment instanceof params.fragment.NodeFragment &&
+                        params.fragment.node.isInside('exercise.replace') &&
+                        params.fragment.node.isInside('answer'),
+                        
+            description: gettext('Remove replace mark')
+        };
+    }
+};
+
+var editReplaceMark = {
+    name: 'editReplaceMark',
+    params: {
+        fragment: {type: 'context', name: 'fragment'}
+    },
+    stateDefaults: {
+        icon: null,
+        label: gettext('Edit replace mark'),
+        execute: function(callback, params) {
+            var doc = params.fragment.document,
+                answerNode = params.fragment.node.getParent('answer'),
+                dialog = Dialog.create({
+                    title: gettext('Edit text to replace with'),
+                    executeButtonText: gettext('Apply'),
+                    cancelButtonText: gettext('Cancel'),
+                    fields: [
+                        {label: gettext('Text'), name: 'text', type: 'input', initialValue: answerNode.getAttr('answer')}
+                    ]
+                });
+
+
+            dialog.on('execute', function(event) {
+                doc.transaction(function() {
+                    answerNode.setAttr('answer', event.formData.text);
+                    var node = answerNode.contents()[0];
+                    return doc.createFragment(doc.CaretFragment, {node: node, offset: node.getText().length});
+                }, {
+                    metadata: {
+                        description: gettext('Edit answer')
+                    },
+                    success: function(ret) { event.success(); callback(ret);}
+                });
+            });
+            dialog.show();
+
+        }
+    },
+    getState: function(params) {
+        return {
+            allowed: params.fragment &&
+                        params.fragment.isValid() &&
+                        params.fragment instanceof params.fragment.NodeFragment &&
+                        params.fragment.node.isInside('exercise.replace') &&
+                        params.fragment.node.isInside('answer'),
+                    
+            description: gettext('Mark selection to replacement')
+        };
+    }
+};
+
+return [markToReplace, removeReplaceMark, editReplaceMark];
+
+});
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/replace/element.js b/src/editor/plugins/core/edumed/replace/element.js
new file mode 100644 (file)
index 0000000..f4157d2
--- /dev/null
@@ -0,0 +1,87 @@
+define(function(require) {
+    
+'use strict';
+
+
+var $ = require('libs/jquery'),
+    _ = require('libs/underscore'),
+    elementBase = require('plugins/core/edumed/elementBase'),
+    genericElement = require('modules/documentCanvas/canvas/genericElement'),
+    viewTemplate = require('libs/text!./view.html'),
+    tipTemplate = require('libs/text!./tip.html');
+
+var AnswerElement = Object.create(genericElement);
+_.extend(AnswerElement, {
+    init: function() {
+        genericElement.init.call(this);
+        this.tip = $(tipTemplate);
+        this.tip.text(this.wlxmlNode.getAttr('answer') || '');
+        this.tip.on('click', function(e) {
+            var doc = this.wlxmlNode.document,
+                textNode = this.wlxmlNode.contents()[0];
+            e.preventDefault();
+            e.stopPropagation();
+
+            if(textNode) {
+                this.canvas.select(doc.createFragment(doc.CaretFragment, {node:textNode, offset: textNode.getText().length}));
+                this.canvas.showContextMenu(this, {x: e.clientX, y: e.clientY});
+            }
+        }.bind(this));
+        this.addWidget(this.tip);
+    },
+    onNodeAttrChange: function(event) {
+        if(event.meta.attr === 'answer') {
+            this.tip.text(event.meta.newVal || '');
+        }
+    }
+});
+
+var ReplaceExerciseElement = Object.create(elementBase);
+_.extend(ReplaceExerciseElement, {
+    init: function() {
+        elementBase.init.call(this);
+        var view  = $(_.template(viewTemplate)());
+        this._container().append(view);
+
+
+        this.elementsRegister.register(
+            {tag: 'span', klass: 'answer', prototype: AnswerElement}
+        );
+
+        this.createContainer(this.wlxmlNode.contents(), {
+            resetBackground: true,
+            manages: function() {
+                return true;
+            },
+            dom: view.find('.content')
+        });
+
+        this.addToContextMenu('core.markToReplace');
+        this.contextMenuActions[0].on('actionExecuted', function(ret) {
+            if(ret instanceof this.wlxmlNode.document.Fragment && ret.isValid()) {
+                this.canvas.select(ret);
+            }
+        }.bind(this));
+        this.addToContextMenu('core.editReplaceMark');
+        this.addToContextMenu('core.removeReplaceMark');
+
+    },
+    getVerticallyFirstTextElement: function() {
+        var toret;
+        this.containers.some(function(container) {
+            toret = container.getVerticallyFirstTextElement();
+            return !!toret;
+        });
+        return toret;
+    }
+});
+
+return {tag: 'div', klass: 'exercise.replace', prototype: ReplaceExerciseElement};
+
+});
+
+
+    
+
+
+
diff --git a/src/editor/plugins/core/edumed/replace/replace.less b/src/editor/plugins/core/edumed/replace/replace.less
new file mode 100644 (file)
index 0000000..161a538
--- /dev/null
@@ -0,0 +1,16 @@
+.exercise-replace {
+    [wlxml-class="answer"] {
+        //color: @red;
+        text-decoration: line-through;
+    }
+
+    .tip {
+        position: absolute;
+        top: -12px;
+        font-size: 9px;
+        line-height: 12px;
+        color: lighten(@red, 10%);
+        white-space: nowrap;
+    }
+
+}
\ No newline at end of file
diff --git a/src/editor/plugins/core/edumed/replace/tip.html b/src/editor/plugins/core/edumed/replace/tip.html
new file mode 100644 (file)
index 0000000..5a82bef
--- /dev/null
@@ -0,0 +1,2 @@
+<div class="tip"></div>
+
diff --git a/src/editor/plugins/core/edumed/replace/view.html b/src/editor/plugins/core/edumed/replace/view.html
new file mode 100644 (file)
index 0000000..d427539
--- /dev/null
@@ -0,0 +1,4 @@
+<div class="edumed-exercise exercise-replace">
+    <div class="header"><%= gettext('Exercise') %>: <%= gettext('Replace') %></div>
+    <div class="content"></div>
+</div>
\ No newline at end of file
index 7a9d321..65b6e69 100644 (file)
@@ -8,10 +8,14 @@ var getBoundriesForAList = function(fragment) {
     var node;
 
     if(fragment instanceof fragment.RangeFragment && fragment.hasSiblingBoundries()) {
-        return fragment.boundriesSiblingParents();
+        return fragment.startNode.hasSameContextRoot(fragment.endNode) && fragment.boundriesSiblingParents();
     }
     if(fragment instanceof fragment.NodeFragment) {
         node = fragment.node.getNearestElementNode();
+        if(node.isContextRoot()) {
+            node = fragment.node;
+        }
+
         return {
             node1: node,
             node2: node
@@ -152,7 +156,7 @@ var toggleListAction = function(type) {
 
             }
             var boundries = getBoundriesForAList(params.fragment);
-            if(boundries) {
+            if(boundries && boundries.node1.hasSameContextRoot(boundries.node2)) {
                 return {
                     allowed: true,
                     description: interpolate(gettext('Make %s fragment(s) into list'), [countItems(getBoundriesForAList(params.fragment))]),
index e1e08a3..6322459 100644 (file)
@@ -5,6 +5,7 @@
 @import '../plugins/plugins.less';
 @import '../views/openSelect/openSelect.less';
 @import '../views/dialog/dialog.less';
+@import '../views/menu/menu.less';
 @import '../modules/rng/rng.less';
 @import '../modules/documentCanvas/documentCanvas.less';
 @import '../modules/sourceEditor/sourceEditor.less';
diff --git a/src/editor/views/menu/menu.html b/src/editor/views/menu/menu.html
new file mode 100644 (file)
index 0000000..bfb61fe
--- /dev/null
@@ -0,0 +1,7 @@
+<div class="rng-menu">
+    <div class="dropdown">
+        <ul class="dropdown-menu">
+        </ul>
+    </div>
+</div>
+
diff --git a/src/editor/views/menu/menu.js b/src/editor/views/menu/menu.js
new file mode 100644 (file)
index 0000000..dd8d01a
--- /dev/null
@@ -0,0 +1,66 @@
+define(function(require) {
+    
+'use strict';
+
+var $ = require('libs/jquery'),
+    Backbone = require('libs/backbone'),
+    template = require('libs/text!./menu.html'),
+    itemTemplate = require('libs/text!./menuitem.html');
+
+
+var Menu = function() {
+    this.dom = $(template);
+    this.actions = [];
+};
+$.extend(Menu.prototype, {
+    addAction: function(action) {
+        var item = new MenuItem(action);
+        item.on('execute', function() {
+            this.close();
+            action.execute();
+        }.bind(this));
+
+        this.actions.push(action);
+        this.dom.find('ul').append(item.dom);
+    },
+    close: function() {
+        this.dom.remove();
+    },
+    show: function() {
+        this.dom.find('.dropdown-menu').dropdown('toggle');
+    },
+    updateContextParam: function(k, v) {
+        this.actions.forEach(function(action) {
+            action.updateContextParam(k, v);
+        });
+    }
+});
+
+var MenuItem = function(action) {
+    this.action = action;
+    this.dom = $(itemTemplate);
+    
+    action.on('paramsChanged', function() {
+        this.render();
+    }.bind(this));
+
+    this.dom.on('click', function() {
+        if(this.action.getState().allowed) {
+            this.trigger('execute');
+        }
+    }.bind(this));
+
+    this.render();
+};
+$.extend(MenuItem.prototype, Backbone.Events, {
+    render: function() {
+        var state = this.action.getState();
+        this.dom.find('a').text(state.label || '?');
+        this.dom.toggleClass('disabled', !state.allowed);
+    }
+});
+
+
+return Menu;
+
+});
diff --git a/src/editor/views/menu/menu.less b/src/editor/views/menu/menu.less
new file mode 100644 (file)
index 0000000..ca9300d
--- /dev/null
@@ -0,0 +1,3 @@
+.rng-menu {
+    position: absolute;
+}
\ No newline at end of file
diff --git a/src/editor/views/menu/menuitem.html b/src/editor/views/menu/menuitem.html
new file mode 100644 (file)
index 0000000..a1085b9
--- /dev/null
@@ -0,0 +1 @@
+<li><a href="#"></li>
\ No newline at end of file
index f3edd31..0dd52e5 100644 (file)
@@ -15,7 +15,8 @@ var Runner = function(app, modules) {
         plugins = [],
         actionDefinitions = {},
         config,
-        actionsAppObject;
+        actionsAppObject,
+        currentContextMenu;
         
     _.each(_.keys(modules || {}), function(moduleName) {
         if(_.contains(app.permissions[moduleName] || [], 'handleEvents')) {
@@ -78,9 +79,18 @@ var Runner = function(app, modules) {
         this.registerActionsAppObject = function(_actionsAppObject) {
             actionsAppObject = _actionsAppObject;
         };
+
+        this.showContextMenu = function(menu, coors) {
+            if(currentContextMenu) {
+                currentContextMenu.close();
+            }
+            currentContextMenu = menu;
+            $(config.rootSelector).append(menu.dom);
+            menu.dom.css({top: coors.y, left: coors.x});
+            menu.show();
+        };
     };
-    
-    
+      
     this.setBootstrappedData = function(moduleName, data) {
         bootstrappedData[moduleName] = data;
     };
@@ -120,6 +130,13 @@ var Runner = function(app, modules) {
         app.initModules.forEach(function(moduleName) {
             getModuleInstance(moduleName).start();
         });
+
+        $(config.rootSelector)[0].addEventListener('click', function(e) {
+            if(currentContextMenu && !currentContextMenu.dom[0].contains(e.target)) {
+                currentContextMenu.close();
+                currentContextMenu = null;
+            }
+        }, true);
     };
 };
 
index 6329491..7326507 100644 (file)
@@ -169,7 +169,11 @@ var elementNodeTransformations = {
 
     setAttr: function(name, value, silent) {
         var oldVal = this.getAttr(name);
-        this._$.attr(name, value);
+        if(_.isUndefined(value)) {
+            this._$.removeAttr(name);
+        } else {
+            this._$.attr(name, value);
+        }
         if(!silent) {
             this.triggerChangeEvent('nodeAttrChange', {attr: name, oldVal: oldVal, newVal: value});
         }
@@ -359,7 +363,7 @@ var textNodeTransformations = {
             newElement.prepend({text: suffix});
         }
 
-        return {first: parentElement, second: newElement};
+        return {first: parentElement, second: newElement, created: newElement};
     },
 
     divideWithElementNode: function(node, params) {
index b1befdf..d932592 100644 (file)
@@ -79,6 +79,9 @@ $.extend(RangeFragment.prototype, {
     hasSiblingBoundries: function() {
         return this.isValid() && this.startNode.isSiblingOf(this.endNode);
     },
+    hasSameBoundries: function() {
+        return this.isValid() && this.startNode.sameNode(this.endNode);
+    },
     boundriesSiblingParents: function() {
         return this.startNode.document.getSiblingParents({
             node1: this.startNode,
diff --git a/src/wlxml/extensions/edumed/choice.xml b/src/wlxml/extensions/edumed/choice.xml
new file mode 100644 (file)
index 0000000..78d372b
--- /dev/null
@@ -0,0 +1,6 @@
+<div class="exercise.choice">
+    <div class="p"></div>
+    <div class="list">
+        <div class="item.answer" answer="true"></div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/src/wlxml/extensions/edumed/choiceSingle.xml b/src/wlxml/extensions/edumed/choiceSingle.xml
new file mode 100644 (file)
index 0000000..452d4a4
--- /dev/null
@@ -0,0 +1,6 @@
+<div class="exercise.choice.single">
+    <div class="p"></div>
+    <div class="list">
+        <div class="item.answer" answer="true"></div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/src/wlxml/extensions/edumed/choiceTrueOrFalse.xml b/src/wlxml/extensions/edumed/choiceTrueOrFalse.xml
new file mode 100644 (file)
index 0000000..a67623c
--- /dev/null
@@ -0,0 +1,6 @@
+<div class="exercise.choice.true-or-false">
+    <div class="p"></div>
+    <div class="list">
+        <div class="item.answer" answer="true"></div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/src/wlxml/extensions/edumed/edumed.js b/src/wlxml/extensions/edumed/edumed.js
new file mode 100644 (file)
index 0000000..ca29ad3
--- /dev/null
@@ -0,0 +1,234 @@
+define(function(require) {
+    
+'use strict';
+
+var _ = require('libs/underscore'),
+    templates = {
+        order: require('libs/text!./order.xml'),
+        choice: require('libs/text!./choice.xml'),
+        'choice.single': require('libs/text!./choiceSingle.xml'),
+        'choice.true-or-false': require('libs/text!./choiceTrueOrFalse.xml'),
+        gap: require('libs/text!./gaps.xml'),
+        replace: require('libs/text!./replace.xml')
+    };
+
+var Item = function(node, exerciseNode) {
+    Object.defineProperty(this, 'text', {
+        get: function() {
+            /* globals Node */
+            var firstNode = node.contents()[0];
+            if(firstNode && firstNode.nodeType === Node.TEXT_NODE) {
+                return firstNode.getText();
+            }
+            return '';
+        }
+    });
+    this.node = node;
+    this.exerciseNode = exerciseNode;
+};
+_.extend(Item.prototype, {
+    setText: function(text) {
+        /* globals Node */
+        var contents = this.node.contents();
+        if(contents.length === 1 && contents[0].nodeType === Node.TEXT_NODE) {
+            contents[0].setText(text);
+        } else {
+            contents.forEach(function(childNode) {
+                childNode.detach();
+            });
+            contents.append({text: text});
+        }
+    },
+    remove: function() {
+        this.node.detach();
+    },
+    getAnswer: function() {
+        var toret = parseInt(this.node.getAttr('answer'), 10);
+        if(_.isNaN(toret)) {
+            toret = 1;
+        }
+        return toret;
+    },
+    setAnswer: function(answer) {
+        answer = parseInt(answer, 10);
+        var prev = answer;
+        if(!_.isNumber(answer)) {
+            return;
+        }
+        
+        this.exerciseNode.object.getItems()
+            .sort(function(item1, item2) {
+                if(item1.getAnswer() > item2.getAnswer()) {
+                    return 1;
+                }
+                return -1;
+            })
+            .some(function(item) {
+                if(item.getAnswer() === prev && !item.node.sameNode(this.node)) {
+                    item.node.setAttr('answer', prev+1);
+                    prev = prev + 1;
+                }
+            }.bind(this));
+        this.node.setAttr('answer', answer);
+        
+    }
+});
+
+var isItemsList = function(node) {
+    return node.is('list.orderable');
+};
+
+
+var extension = {wlxmlClass: {'exercise.order': {
+    methods: {
+        isContextRoot: function(node) {
+            return this.object.isItemNode(node) || this.sameNode(node);
+        },
+        getItems: function() {
+            var toret = [],
+                exerciseNode = this;
+
+            this.contents().some(function(node) {
+                if(isItemsList(node)) {
+                    node.contents()
+                        .filter(function(node) {
+                            return node.is('item.answer');
+                        })
+                        .forEach(function(node) {
+                            toret.push(new Item(node, exerciseNode));
+                        });
+                    return true;
+                }
+            });
+            return toret;
+        },
+        isItemNode: function(node) {
+            var list;
+            if(!node) {
+                return;
+            }
+            this.contents().some(function(node) {
+                if(isItemsList(node)) {
+                    list = node;
+                    return true;
+                }
+            });
+            return list && list.sameNode(node.parent());
+        },
+        getDescription: function() {
+            var toret = [];
+            this.contents().some(function(node) {
+                if(isItemsList(node)) {
+                   return true;
+                }
+                toret.push(node);
+            });
+            return toret;
+        }
+    },
+    transformations: {
+        addItem: function(text) {
+            var toret;
+            this.contents().some(function(node) {
+                if(isItemsList(node)) {
+                    var itemNode = this.document.createDocumentNode({tagName: 'div', attrs: {'class': 'item.answer', answer: this.object.getItems().length+1}});
+                    toret = itemNode.append({text: text});
+                    node.append(itemNode);
+                    return true;
+                }
+            }.bind(this));
+            return toret;
+        },
+        setDescription: function(text) {
+            this.contents().some(function(node) {
+                var textNode;
+                if(node.is('p')) {
+                    textNode = node.contents()[0];
+                    if(!(textNode && textNode.nodeType === Node.TEXT_NODE)) {
+                        node.prepend({text:text});
+                    } else {
+                        textNode.setText(text);
+                    }
+                    return true;
+                }
+            });
+        }
+    }
+}}};
+
+var choiceMethods = {
+    isContextRoot: function(node) {
+        return this.object.isChoiceList(node.parent()) || this.sameNode(node);
+    },
+    getChoiceList: function() {
+        return this.contents()
+            .filter(function(n) { return this.object.isChoiceList(n); }.bind(this))[0];
+    },
+    isChoiceList: function(node) {
+        return node.is('list') && this.sameNode(node.parent());
+    },
+    isChoiceListItem: function(node) {
+        return this.object.isChoiceList(node.parent()) && node.is('item.answer');
+    }
+};
+
+extension.wlxmlClass['exercise.choice'] = {
+    transformations: {
+        setAnswer: function(itemNode, answer) {
+            if(!this.object.isChoiceListItem(itemNode)) {
+                return;
+            }
+            itemNode.setAttr('answer', answer ? 'true' : 'false');
+        }
+    },
+    methods: choiceMethods
+};
+
+extension.wlxmlClass['exercise.choice.single'] = {
+    transformations: {
+        markAsAnswer: function(itemNode) {
+            if(!this.object.isChoiceListItem(itemNode)) {
+                return;
+            }
+            this.object.getChoiceList().contents()
+                .filter(function(node) { return node.is('item.answer'); })
+                .forEach(function(node) {
+                    node.setAttr('answer', node.sameNode(itemNode) ? 'true' : 'false');
+                });
+        }
+    },
+    methods: choiceMethods
+};
+
+extension.wlxmlClass['exercise.choice.true-or-false'] = {
+    transformations: {
+        setAnswer: function(itemNode, answer) {
+            if(!this.object.isChoiceListItem(itemNode)) {
+                return;
+            }
+            itemNode.setAttr('answer', answer ? 'true' : 'false');
+        }
+    },
+    methods: choiceMethods
+};
+
+extension.wlxmlClass['exercise.gap'] = extension.wlxmlClass['exercise.replace'] = {
+    methods: {
+        isContextRoot: function(node) {
+            return this.sameNode(node);
+        }
+    }
+};
+
+extension.document = {
+    methods: {
+         edumedCreateExerciseNode: function(klass) {
+            void(klass);
+            return this.createDocumentNode(templates[klass]);
+         }
+    }
+};
+
+return extension;
+
+});
\ No newline at end of file
diff --git a/src/wlxml/extensions/edumed/edumed.test.js b/src/wlxml/extensions/edumed/edumed.test.js
new file mode 100644 (file)
index 0000000..c039f0d
--- /dev/null
@@ -0,0 +1,70 @@
+define(function(require) {
+    
+'use strict';
+/* globals describe, it */
+
+var chai = require('libs/chai'),
+    wlxml = require('wlxml/wlxml');
+    //edumedExtension = require('./edumed.js');
+
+var expect = chai.expect;
+
+
+var getDocumentFromXML = function(xml, options) {
+    var doc = wlxml.WLXMLDocumentFromXML(xml, options || {});
+    //doc.registerExtension(edumedExtension);
+    return doc;
+};
+
+
+describe('Setting answer', function() {
+    it('sets answer (1)', function() {
+        /* jshint multistr:true */
+        var doc = getDocumentFromXML('\
+                <div class="exercise.order">\
+                    <div class="list.orderable">\
+                        <div class="item.answer" answer="3">Element 3</div>\
+                        <div class="item.answer" answer="1">Element 1</div>\
+                        <div class="item.answer" answer="2">Element 2</div>\
+                    </div>\
+                </div>');
+
+        doc.root.object.getItems()[2].setAnswer(1);
+
+        var items = doc.root.object.getItems();
+
+        expect(items[0].getAnswer()).to.equal(3);
+        expect(items[1].getAnswer()).to.equal(2);
+        expect(items[2].getAnswer()).to.equal(1);
+
+    });
+    it('sets answer (2)', function() {
+        /* jshint multistr:true */
+        var doc = getDocumentFromXML('\
+                <div class="exercise.order">\
+                    <div class="list.orderable">\
+                        <div class="item.answer" answer="1">Element 1</div>\
+                        <div class="item.answer" answer="2">Element 2</div>\
+                        <div class="item.answer" answer="3">Element 3</div>\
+                    </div>\
+                </div>');
+        doc.transaction(function() {
+            doc.root.object.getItems()[2].setAnswer(2);
+        }, {
+            error: function(e) { throw e;}
+        });
+        
+
+        var items = doc.root.object.getItems();
+
+        expect(items[0].getAnswer()).to.equal(1);
+        expect(items[1].getAnswer()).to.equal(3);
+        expect(items[2].getAnswer()).to.equal(2);
+
+    });
+});
+
+
+
+
+});
\ No newline at end of file
diff --git a/src/wlxml/extensions/edumed/gaps.xml b/src/wlxml/extensions/edumed/gaps.xml
new file mode 100644 (file)
index 0000000..0a5b40f
--- /dev/null
@@ -0,0 +1,3 @@
+<div class="exercise.gap">
+    <div class="p"></div>
+</div>
\ No newline at end of file
diff --git a/src/wlxml/extensions/edumed/order.xml b/src/wlxml/extensions/edumed/order.xml
new file mode 100644 (file)
index 0000000..167e981
--- /dev/null
@@ -0,0 +1,5 @@
+<div class="exercise.order">
+  <div class="p"></div>
+  <div class="list.orderable">
+  </div>
+</div>
\ No newline at end of file
diff --git a/src/wlxml/extensions/edumed/replace.xml b/src/wlxml/extensions/edumed/replace.xml
new file mode 100644 (file)
index 0000000..2ff71c2
--- /dev/null
@@ -0,0 +1,3 @@
+<div class="exercise.replace">
+  <div class="p"></div>
+</div>
\ No newline at end of file
index 3ee65d4..8d88115 100644 (file)
@@ -111,7 +111,7 @@ extension.document.transformations.extractItems = {
             extractedItems = [],
             succeedingItems = [],
             items = list.contents(),
-            listIsNested = list.parent().is('item');
+            listIsNested = list.parent().is('item') && !list.parent().isContextRoot();
 
         
         items.forEach(function(item, idx) {
@@ -133,7 +133,7 @@ extension.document.transformations.extractItems = {
                 reference.after(item);
                 reference = item;
                 if(!listIsNested) {
-                    item.setClass('');
+                    item.setClass('p');
                 }
             });
             if(precedingItems.length === 0) {
@@ -143,14 +143,14 @@ extension.document.transformations.extractItems = {
             extractedItems.forEach(function(item) {
                 reference.before(item);
                 if(!listIsNested) {
-                    item.setClass('');
+                    item.setClass('p');
                 }
             });
         } else {
             extractedItems.forEach(function(item) {
                 reference.after(item);
                 if(!listIsNested) {
-                    item.setClass('');
+                    item.setClass('p');
                 }
                 reference = item;
             });
index 2d47778..277e828 100644 (file)
@@ -136,10 +136,10 @@ describe('Lists extension', function() {
             expect(list1.contents()[0].contents()[0].getText()).to.equal('0', 'first item of the first list is a first item of the original list');
 
             expect(oldItem1.contents()[0].getText()).to.equal('1', 'first item got extracted');
-            expect(oldItem1.getClass() === '').to.equal(true, 'first extracted element has no wlxml class');
+            expect(oldItem1.getClass() === 'p').to.equal(true, 'first extracted element became a paragraph');
 
             expect(oldItem2.contents()[0].getText()).to.equal('2', 'second item got extracted');
-            expect(oldItem2.getClass() === '').to.equal(true, 'second extracted element has no wlxml class');
+            expect(oldItem2.getClass() === 'p').to.equal(true, 'second extracted element became a paragraph');
 
             expect(list2.is('list')).to.equal(true, 'last section child is a list');
             expect(list2.contents().length).to.equal(1, 'second list has one child');
@@ -414,7 +414,7 @@ describe('Lists extension', function() {
             expect(sectionContents[0].is('list')).to.equal(true, 'first child is a list');
 
             expect(extractedItem.getTagName()).to.equal('div', 'extracted item is a wlxml div');
-            expect(extractedItem.getClass()).to.equal('', 'extracted item has no wlxml class');
+            expect(extractedItem.getClass()).to.equal('p', 'extracted item became a paragraph');
             expect(extractedItem.contents()[0].getText()).to.equal('1.1', 'extracted item ok');
             expect(sectionContents[2].is('list')).to.equal(true, 'second child is a list');
         });
index 5925ee6..ef70d3d 100644 (file)
@@ -4,8 +4,9 @@ define([
     'smartxml/smartxml',
     'smartxml/transformations',
     'wlxml/extensions/metadata/metadata',
-    'wlxml/extensions/comments/comments'
-], function($, _, smartxml, transformations, metadataExtension, commentExtension) {
+    'wlxml/extensions/comments/comments',
+    'wlxml/extensions/edumed/edumed'
+], function($, _, smartxml, transformations, metadataExtension, commentExtension, edumedExtension) {
     
 'use strict';
 
@@ -24,11 +25,39 @@ var WLXMLDocumentNodeMethods =  {
         me.concat(this.parents()).some(function(node) {
             if(node.is(query)) {
                 toret = node;
+            }
+            return !!toret || (!node.sameNode(this) && node.isContextRoot());
+        }.bind(this));
+
+        return toret;
+    },
+    isContextRoot: function() {
+        var me = this.nodeType === Node.ELEMENT_NODE ? [this] : [],
+            toret = false;
+        if(!this.parent()) {
+            return true;
+        }
+        me.concat(this.parents()).some(function(node) {
+            if(_.isFunction(node.object.isContextRoot) && node.object.isContextRoot(this)) {
+                toret = true;
                 return true;
             }
-        });
+        }.bind(this));
         return toret;
     },
+    getContextRoot: function() {
+        var contextRoot;
+        [this].concat(this.parents()).some(function(n) {
+            if(n.isContextRoot()) {
+                contextRoot = n;
+                return true;
+            }
+        });
+        return contextRoot;
+    },
+    hasSameContextRoot: function(other) {
+        return this.getContextRoot().sameNode(other.getContextRoot());
+    }
 };
 
 var getClassLists = function(klassName) {
@@ -90,6 +119,16 @@ $.extend(WLXMLElementNode.prototype, WLXMLDocumentNodeMethods, smartxml.ElementN
             return child.is(query);
         }.bind(this));
     },
+    find: function(query) {
+        var doc = this.document;
+        return this._$.find('*')
+            .map(function() {
+                return doc.createDocumentNode(this);
+            })
+            .filter(function() {
+                return this.is(query);
+            });
+    },
 
     _getXMLDOMToDump: function() {
         var DOM = this._$.clone(true, true),
@@ -157,7 +196,6 @@ var WLXMLDocumentNode = function() {
 };
 WLXMLDocumentNode.prototype = Object.create(smartxml.DocumentNode.prototype);
 
-
 var WLXMLTextNode = function() {
     smartxml.TextNode.apply(this, arguments);
 };
@@ -169,7 +207,7 @@ $.extend(WLXMLTextNode.prototype, WLXMLDocumentNodeMethods, {
 var WLXMLDocument = function(xml, options) {
     this.classMethods = {};
     this.classTransformations = {};
-    smartxml.Document.call(this, xml, [metadataExtension, commentExtension]);
+    smartxml.Document.call(this, xml, [metadataExtension, commentExtension, edumedExtension]);
     this.options = options;
 };
 
index 2b495a8..93b6df9 100644 (file)
@@ -239,6 +239,36 @@ describe('WLXMLDocument', function() {
             expect(testClassNode.object.testTransformation2().sameNode(testClassNode)).to.equal(true, '2');
         });
     });
+
+    describe('Context roots', function() {
+        var doc = getDocumentFromXML('<section><div class="a"><div class="b"><div class="c"></div></div></div></section>');
+        doc.registerExtension({wlxmlClass: {a: {methods: {
+            isContextRoot: function(node) {
+                return node.getClass() === 'b';
+            }
+        }}}});
+
+        var divA = doc.root.contents()[0],
+            divB = divA.contents()[0],
+            divC = divB.contents()[0];
+
+        it('allows extensions declaring a node as a context root', function() {
+            expect(divC.isContextRoot()).to.equal(false, 'c is not a context root');
+            expect(divB.isContextRoot()).to.equal(true, 'b is a context root');
+            expect(divA.isContextRoot()).to.equal(false, 'a is not a context root');
+        });
+
+        it('closes context for parent context quering methods', function() {
+            expect(divC.isInside('b')).to.equal(true, 'c inside b');
+            expect(divC.isInside('a')).to.equal(false, 'c not inside a');
+            expect(divC.isInside({tagName: 'section'})).to.equal(false, 'c not inside section');
+
+            expect(divB.isInside('a')).to.equal(true, 'b inside a');
+            expect(divB.isInside({tagName: 'section'})).to.equal(true, 'b inside section');
+
+            expect(divA.isInside({tagName: 'section'})).to.equal(true, 'a inside section');
+        });
+    });
 });
 
 });
\ No newline at end of file