diff --git a/DEVELOPMENT.rst b/DEVELOPMENT.rst index d54b47776..0e2358029 100644 --- a/DEVELOPMENT.rst +++ b/DEVELOPMENT.rst @@ -96,3 +96,12 @@ To setup and activate the virtual environment in step c. use:: > .virtualenv\Scripts\activate.bat All other commands are the same as for GNU/Linux and Mac OS X. + + +3. Running the test cases +------------------------- + +a. Running Angular.js test cases +'''''''''''''''''''''''''''''''' + + $ node_modules/.bin/karma start tests/karma/karma.conf.js diff --git a/openslides/core/static/css/app.css b/openslides/core/static/css/app.css index f6d2974d4..89866095d 100644 --- a/openslides/core/static/css/app.css +++ b/openslides/core/static/css/app.css @@ -23,6 +23,10 @@ div { text-align: left; } +blockquote { + font-size: inherit; +} + h1, h2, h3, h4, h5, h6 { font-family: "Roboto Condensed",Helvetica,Arial,sans-serif; font-weight: 400; @@ -295,15 +299,138 @@ img { width: auto; margin-top: 20px; background: #fff; - padding: 20px; border: 1px solid #d3d3d3; } +.col1 .details .line-number-setter { + margin-top: 0; + margin-bottom: 55px; + margin-left: 15px; +} + +.col1 .details .line-number-setter .btn.disabled { + cursor: default; + opacity: 1; + background-color: #eee; +} + +.col1 .details .inline-editing-activator { + margin-right: 13px; +} + .col1 ul, .col1 ol { margin-left: 20px; } +/* Toolbar to save motion in inline editing mode */ +.motion-save-toolbar { + position: fixed; + bottom: 0; + left: 50%; + height: 75px; + width: 300px; + background: rgba(242, 222, 222, 0.9); + color: black; + text-align: center; + padding: 5px; + z-index: 10; + display: none; + border: 1px solid #d3d3d3; + margin-left: -150px; + border-bottom: none; + -webkit-box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.75); + -moz-box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.75); + box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.75); +} +.motion-save-toolbar.visible { + display: block; +} + +.motion-save-toolbar .changed-hint { + display: block; + line-height: 16px; + text-align: center; + margin-bottom: 10px; + font-weight: bold; +} + +.motion-save-toolbar label { + font-weight: normal; + line-height: 16px; + text-align: left; + padding-left: 16px; + margin-top: 5px; + margin-left: 15px; +} + +.motion-save-toolbar label input { + margin-left: -16px; +} + +/*** Line numbers ***/ +.motion-text.line-numbers-outside { + padding-left: 35px; + position: relative; +} +.motion-text.line-numbers-outside .os-line-number { + display: inline-block; + font-size: 0; + line-height: 0; + width: 0; + height: 0; +} +.motion-text.line-numbers-outside .os-line-number:after { + content: attr(data-line-number); + position: absolute; + left: 0; + vertical-align: top; + margin-top: -5px; + color: gray; + font-family: Courier, serif; + font-size: 13px; +} + +.motion-text.line-numbers-inline .os-line-break { + display: none; +} +.motion-text.line-numbers-inline .os-line-number { + display: inline-block; +} +.motion-text.line-numbers-inline .os-line-number:after { + display: inline-block; + content: attr(data-line-number); + vertical-align: top; + font-size: 11px; + color: gray; + font-family: Courier, serif; + margin-top: -3px; + margin-left: 0; + margin-right: 0; +} + +.motion-text.line-numbers-none .os-line-break { + display: none; +} +.motion-text.line-numbers-none .os-line-number { + display: none; +} + +.os-line-number { + user-select: none; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; + -o-user-select: none; +} +.os-line-number:after { + user-select: none; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; + -o-user-select: none; +} + /** Projector sidebar column **/ #content .col2 { diff --git a/openslides/core/static/css/projector.css b/openslides/core/static/css/projector.css index 63aed0c98..6bb6d300d 100644 --- a/openslides/core/static/css/projector.css +++ b/openslides/core/static/css/projector.css @@ -335,3 +335,50 @@ tr.elected td { .nextSpeakers li { line-height: 150%; } + + + +/*** Line numbers ***/ +.motion-text.line-numbers-outside { + padding-left: 0; + margin-left: 25px; + position: relative; +} + +.motion-text.line-numbers-outside .os-line-number { + display: inline-block; +} +.motion-text.line-numbers-outside .os-line-number:after { + content: attr(data-line-number); + position: absolute; + left: -25px; + vertical-align: top; + color: gray; + font-family: Courier, serif; + font-size: 13px; +} + +.motion-text.line-numbers-inline .os-line-break { + display: none; +} +.motion-text.line-numbers-inline .os-line-number { + display: inline-block; +} +.motion-text.line-numbers-inline .os-line-number:after { + display: inline-block; + content: attr(data-line-number); + vertical-align: top; + font-size: 0.75em; + color: gray; + font-family: Courier, serif; + margin-top: -5px; + margin-left: 0; +} + + +.motion-text.line-numbers-none .os-line-break { + display: none; +} +.motion-text.line-numbers-none .os-line-number { + display: none; +} diff --git a/openslides/core/static/js/core/site.js b/openslides/core/static/js/core/site.js index 8366bbe6d..77a347a01 100644 --- a/openslides/core/static/js/core/site.js +++ b/openslides/core/static/js/core/site.js @@ -768,12 +768,15 @@ angular.module('OpenSlidesApp.core.site', [ 'gettextCatalog', function (gettextCatalog) { return { - getOptions: function (images) { + getOptions: function (images, inlineMode) { + if (inlineMode === undefined) { + inlineMode = false; + } return { language_url: '/static/tinymce/i18n/' + gettextCatalog.getCurrentLanguage() + '.js', theme_url: '/static/js/openslides-libs.js', skin_url: '/static/tinymce/skins/lightgray/', - inline: false, + inline: inlineMode, statusbar: false, browser_spellcheck: true, image_advtab: true, diff --git a/openslides/motions/apps.py b/openslides/motions/apps.py index 228698ef7..0f08c6445 100644 --- a/openslides/motions/apps.py +++ b/openslides/motions/apps.py @@ -7,7 +7,8 @@ class MotionsAppConfig(AppConfig): verbose_name = 'OpenSlides Motion' angular_site_module = True angular_projector_module = True - js_files = ['js/motions/base.js', 'js/motions/site.js', 'js/motions/projector.js'] + js_files = ['js/motions/base.js', 'js/motions/site.js', 'js/motions/projector.js', + 'js/motions/linenumbering.js', 'js/motions/diff.js'] def ready(self): # Load projector elements. diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py index 7071e73ce..18d1ac59c 100644 --- a/openslides/motions/config_variables.py +++ b/openslides/motions/config_variables.py @@ -56,6 +56,30 @@ def get_config_variables(): subgroup='General', translatable=True) + yield ConfigVariable( + name='motions_default_line_numbering', + default_value='none', + input_type='choice', + label='Default line numbering', + choices=( + {'value': 'outside', 'display_name': 'Outside'}, + {'value': 'inline', 'display_name': 'Inline'}, + {'value': 'none', 'display_name': 'None'}), + weight=322, + group='Motions', + subgroup='General') + + yield ConfigVariable( + name='motions_line_length', + default_value=80, + input_type='integer', + label='Line length', + help_text='The maximum number of characters per line. Relevant when line numbering is enabled. Min: 40', + weight=323, + group='Motions', + subgroup='General', + validators=(MinValueValidator(40),)) + yield ConfigVariable( name='motions_stop_submitting', default_value=False, diff --git a/openslides/motions/static/js/motions/base.js b/openslides/motions/static/js/motions/base.js index 69d7b8c1d..dc7c302c7 100644 --- a/openslides/motions/static/js/motions/base.js +++ b/openslides/motions/static/js/motions/base.js @@ -2,7 +2,11 @@ "use strict"; -angular.module('OpenSlidesApp.motions', ['OpenSlidesApp.users']) +angular.module('OpenSlidesApp.motions', [ + 'OpenSlidesApp.users', + 'OpenSlidesApp.motions.lineNumbering', + 'OpenSlidesApp.motions.diff' +]) .factory('WorkflowState', [ 'DS', @@ -112,7 +116,8 @@ angular.module('OpenSlidesApp.motions', ['OpenSlidesApp.users']) 'gettext', 'operator', 'Config', - function(DS, MotionPoll, jsDataModel, gettext, operator, Config) { + 'lineNumberingService', + function(DS, MotionPoll, jsDataModel, gettext, operator, Config, lineNumberingService) { var name = 'motions/motion'; return DS.defineResource({ name: name, @@ -140,6 +145,15 @@ angular.module('OpenSlidesApp.motions', ['OpenSlidesApp.users']) getText: function (versionId) { return this.getVersion(versionId).text; }, + getTextWithLineBreaks: function (versionId) { + var lineLength = Config.get('motions_line_length').value, + html = this.getVersion(versionId).text; + + return lineNumberingService.insertLineNumbers(html, lineLength); + }, + setTextStrippingLineBreaks: function (versionId, text) { + this.text = lineNumberingService.stripLineNumbers(text); + }, getReason: function (versionId) { return this.getVersion(versionId).reason; }, diff --git a/openslides/motions/static/js/motions/diff.js b/openslides/motions/static/js/motions/diff.js new file mode 100644 index 000000000..78cd07a9d --- /dev/null +++ b/openslides/motions/static/js/motions/diff.js @@ -0,0 +1,407 @@ +(function () { + +"use strict"; + +angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumbering']) + +.service('diffService', function (lineNumberingService) { + var ELEMENT_NODE = 1, + TEXT_NODE = 3, + DOCUMENT_FRAGMENT_NODE = 11; + + + this.getLineNumberNode = function(fragment, lineNumber) { + return fragment.querySelector('os-linebreak.os-line-number.line-number-' + lineNumber); + }; + + this._getNodeContextTrace = function(node) { + var context = [], + currNode = node; + while (currNode) { + context.unshift(currNode); + currNode = currNode.parentNode; + } + return context; + }; + + this._insertInternalLineMarkers = function(fragment) { + if (fragment.querySelectorAll('OS-LINEBREAK').length > 0) { + // Prevent duplicate calls + return; + } + var lineNumbers = fragment.querySelectorAll('span.os-line-number'); + for (var i = 0; i < lineNumbers.length; i++) { + var insertBefore = lineNumbers[i]; + while (insertBefore.parentNode.nodeType != DOCUMENT_FRAGMENT_NODE && insertBefore.parentNode.childNodes[0] == insertBefore) { + insertBefore = insertBefore.parentNode; + } + var lineMarker = document.createElement('OS-LINEBREAK'); + lineMarker.setAttribute('data-line-number', lineNumbers[i].getAttribute('data-line-number')); + lineMarker.setAttribute('class', lineNumbers[i].getAttribute('class')); + insertBefore.parentNode.insertBefore(lineMarker, insertBefore); + } + }; + + /* + * Returns an array with the following values: + * 0: the most specific DOM-node that contains both line numbers + * 1: the context of node1 (an array of dom-elements; 0 is the document fragment) + * 2: the context of node2 (an array of dom-elements; 0 is the document fragment) + * 3: the index of [0] in the two arrays + */ + this._getCommonAncestor = function(node1, node2) { + var trace1 = this._getNodeContextTrace(node1), + trace2 = this._getNodeContextTrace(node2), + commonAncestor = null, + commonIndex = null, + childTrace1 = [], + childTrace2 = []; + + for (var i = 0; i < trace1.length && i < trace2.length; i++) { + if (trace1[i] == trace2[i]) { + commonAncestor = trace1[i]; + commonIndex = i; + } + } + for (i = commonIndex + 1; i < trace1.length; i++) { + childTrace1.push(trace1[i]); + } + for (i = commonIndex + 1; i < trace2.length; i++) { + childTrace2.push(trace2[i]); + } + return { + 'commonAncestor': commonAncestor, + 'trace1' : childTrace1, + 'trace2' : childTrace2, + 'index': commonIndex + }; + }; + + this._serializeTag = function(node) { + if (node.nodeType == DOCUMENT_FRAGMENT_NODE) { + // Fragments are only placeholders and do not have an HTML representation + return ''; + } + var html = '<' + node.nodeName; + for (var i = 0; i < node.attributes.length; i++) { + var attr = node.attributes[i]; + html += " " + attr.name + "=\"" + attr.value + "\""; + } + html += '>'; + return html; + }; + + this._serializeDom = function(node, stripLineNumbers) { + if (node.nodeType == TEXT_NODE) { + return node.nodeValue; + } + if (stripLineNumbers && ( + lineNumberingService._isOsLineNumberNode(node) || lineNumberingService._isOsLineBreakNode(node))) { + return ''; + } + if (node.nodeName == 'OS-LINEBREAK') { + return ''; + } + if (node.nodeName == 'BR') { + var br = ''; + } + + var html = this._serializeTag(node); + for (var i = 0; i < node.childNodes.length; i++) { + if (node.childNodes[i].nodeType == TEXT_NODE) { + html += node.childNodes[i].nodeValue; + } else if (!stripLineNumbers || (!lineNumberingService._isOsLineNumberNode(node.childNodes[i]) && !lineNumberingService._isOsLineBreakNode(node.childNodes[i]))) { + html += this._serializeDom(node.childNodes[i], stripLineNumbers); + } + } + if (node.nodeType != DOCUMENT_FRAGMENT_NODE) { + html += ''; + } + + return html; + }; + + /** + * Implementation hint: the first element of "toChildTrace" array needs to be a child element of "node" + */ + this._serializePartialDomToChild = function(node, toChildTrace, stripLineNumbers) { + if (lineNumberingService._isOsLineNumberNode(node) || lineNumberingService._isOsLineBreakNode(node)) { + return ''; + } + if (node.nodeName == 'OS-LINEBREAK') { + return ''; + } + + var html = this._serializeTag(node); + + for (var i = 0, found = false; i < node.childNodes.length && !found; i++) { + if (node.childNodes[i] == toChildTrace[0]) { + found = true; + var remainingTrace = toChildTrace; + remainingTrace.shift(); + if (!lineNumberingService._isOsLineNumberNode(node.childNodes[i])) { + html += this._serializePartialDomToChild(node.childNodes[i], remainingTrace, stripLineNumbers); + } + } else if (node.childNodes[i].nodeType == TEXT_NODE) { + html += node.childNodes[i].nodeValue; + } else { + if (!stripLineNumbers || (!lineNumberingService._isOsLineNumberNode(node.childNodes[i]) && + !lineNumberingService._isOsLineBreakNode(node.childNodes[i]))) { + html += this._serializeDom(node.childNodes[i], stripLineNumbers); + } + } + } + if (!found) { + console.trace(); + throw "Inconsistency or invalid call of this function detected"; + } + return html; + }; + + /** + * Implementation hint: the first element of "toChildTrace" array needs to be a child element of "node" + */ + this._serializePartialDomFromChild = function(node, fromChildTrace, stripLineNumbers) { + if (lineNumberingService._isOsLineNumberNode(node) || lineNumberingService._isOsLineBreakNode(node)) { + return ''; + } + if (node.nodeName == 'OS-LINEBREAK') { + return ''; + } + + var html = ''; + for (var i = 0, found = false; i < node.childNodes.length; i++) { + if (node.childNodes[i] == fromChildTrace[0]) { + found = true; + var remainingTrace = fromChildTrace; + remainingTrace.shift(); + if (!lineNumberingService._isOsLineNumberNode(node.childNodes[i])) { + html += this._serializePartialDomFromChild(node.childNodes[i], remainingTrace, stripLineNumbers); + } + } else if (found) { + if (node.childNodes[i].nodeType == TEXT_NODE) { + html += node.childNodes[i].nodeValue; + } else { + if (!stripLineNumbers || (!lineNumberingService._isOsLineNumberNode(node.childNodes[i]) && + !lineNumberingService._isOsLineBreakNode(node.childNodes[i]))) { + html += this._serializeDom(node.childNodes[i], stripLineNumbers); + } + } + } + } + if (!found) { + console.trace(); + throw "Inconsistency or invalid call of this function detected"; + } + if (node.nodeType != DOCUMENT_FRAGMENT_NODE) { + html += ''; + } + return html; + }; + + this.htmlToFragment = function(html) { + var fragment = document.createDocumentFragment(), + div = document.createElement('DIV'); + div.innerHTML = html; + while (div.childElementCount) { + var child = div.childNodes[0]; + div.removeChild(child); + fragment.appendChild(child); + } + return fragment; + }; + + /** + * Returns the HTML snippet between two given line numbers. + * + * Hint: + * - The last line (toLine) is not included anymore, as the number refers to the line breaking element + * + * In addition to the HTML snippet, additional information is provided regarding the most specific DOM element + * that contains the whole section specified by the line numbers (like a P-element if only one paragraph is selected + * or the most outer DIV, if multiple sections selected). + * + * This additional information is meant to render the snippet correctly without producing broken HTML + * + * The return object has the following fields: + * - html: The HTML between the two line numbers. + * Line numbers and automatically set line breaks are stripped. + * All HTML tags are converted to uppercase + * (e.g. Line 2
  • Line3
  • Line 4
    ) + * - ancestor: the most specific DOM element that contains the HTML snippet (e.g. a UL, if several LIs are selected) + * - outerContextStart: An HTML string that opens all necessary tags to get the browser into the rendering mode + * of the ancestor element (e.g.
      in the case of the multiple LIs) + * - outerContectEnd: An HTML string that closes all necessary tags from the ancestor element (e.g.
    + * - innerContextStart: A string that opens all necessary tags between the ancestor + * and the beginning of the selection (e.g.
  • ) + * - innerContextEnd: A string that closes all tags after the end of the selection to the ancestor (e.g.
  • ) + * - previousHtml: The HTML before the selected area begins (including line numbers) + * - previousHtmlEndSnippet: A HTML snippet that closes all open tags from previousHtml + * - followingHtml: The HTML after the selected area + * - followingHtmlStartSnippet: A HTML snippet that opens all HTML tags necessary to render "followingHtml" + * + */ + this.extractRangeByLineNumbers = function(fragment, fromLine, toLine) { + this._insertInternalLineMarkers(fragment); + + var fromLineNode = this.getLineNumberNode(fragment, fromLine), + toLineNode = this.getLineNumberNode(fragment, toLine), + ancestorData = this._getCommonAncestor(fromLineNode, toLineNode); + + var fromChildTraceRel = ancestorData.trace1, + fromChildTraceAbs = this._getNodeContextTrace(fromLineNode), + toChildTraceRel = ancestorData.trace2, + toChildTraceAbs = this._getNodeContextTrace(toLineNode), + ancestor = ancestorData.commonAncestor, + html = '', + outerContextStart = '', + outerContextEnd = '', + innerContextStart = '', + innerContextEnd = '', + previousHtmlEndSnippet = '', + followingHtmlStartSnippet = ''; + + + fromChildTraceAbs.shift(); + var previousHtml = this._serializePartialDomToChild(fragment, fromChildTraceAbs, false); + toChildTraceAbs.shift(); + var followingHtml = this._serializePartialDomFromChild(fragment, toChildTraceAbs, false); + + var currNode = fromLineNode.parentNode; + while (currNode.parentNode) { + previousHtmlEndSnippet += ''; + currNode = currNode.parentNode; + } + currNode = toLineNode.parentNode; + while (currNode.parentNode) { + followingHtmlStartSnippet = this._serializeTag(currNode) + followingHtmlStartSnippet; + currNode = currNode.parentNode; + } + + var found = false; + for (var i = 0; i < fromChildTraceRel.length && !found; i++) { + if (fromChildTraceRel[i].nodeName == 'OS-LINEBREAK') { + found = true; + } else { + innerContextStart += this._serializeTag(fromChildTraceRel[i]); + } + } + found = false; + for (i = 0; i < toChildTraceRel.length && !found; i++) { + if (toChildTraceRel[i].nodeName == 'OS-LINEBREAK') { + found = true; + } else { + innerContextEnd = '' + innerContextEnd; + } + } + + found = false; + for (i = 0; i < ancestor.childNodes.length; i++) { + if (ancestor.childNodes[i] == fromChildTraceRel[0]) { + found = true; + fromChildTraceRel.shift(); + html += this._serializePartialDomFromChild(ancestor.childNodes[i], fromChildTraceRel, true); + } else if (ancestor.childNodes[i] == toChildTraceRel[0]) { + found = false; + toChildTraceRel.shift(); + html += this._serializePartialDomToChild(ancestor.childNodes[i], toChildTraceRel, true); + } else if (found === true) { + html += this._serializeDom(ancestor.childNodes[i], true); + } + } + + currNode = ancestor; + while (currNode.parentNode) { + outerContextStart = this._serializeTag(currNode) + outerContextStart; + outerContextEnd += ''; + currNode = currNode.parentNode; + } + + return { + 'html': html, + 'ancestor': ancestor, + 'outerContextStart': outerContextStart, + 'outerContextEnd': outerContextEnd, + 'innerContextStart': innerContextStart, + 'innerContextEnd': innerContextEnd, + 'previousHtml': previousHtml, + 'previousHtmlEndSnippet': previousHtmlEndSnippet, + 'followingHtml': followingHtml, + 'followingHtmlStartSnippet': followingHtmlStartSnippet + }; + + }; + + this._replaceLinesMergeNodeArrays = function(nodes1, nodes2) { + if (nodes1.length === 0) { + return nodes2; + } + if (nodes2.length === 0) { + return nodes1; + } + + var out = []; + for (var i = 0; i < nodes1.length - 1; i++) { + out.push(nodes1[i]); + } + + out.push(nodes1[nodes1.length - 1]); + out.push(nodes2[0]); + + for (i = 1; i < nodes2.length; i++) { + out.push(nodes2[i]); + } + + /* + if (node1.nodeName != node2.nodeName) { + return null; + } + var newNode = node1.ownerDocument.createElement(node1.nodeName); + for (var i = 0; i < node1.attributes.length; i++) { + var attr = node1.attributes[i]; + newNode.setAttribute(attr.name, attr.value); + } + return newNode; + */ + return out; + }; + + this.replaceLines = function (fragment, newHTML, fromLine, toLine) { + var data = this.extractRangeByLineNumbers(fragment, fromLine, toLine), + previousHtml = data.previousHtml + data.previousHtmlEndSnippet, + previousFragment = this.htmlToFragment(previousHtml), + followingHtml = data.followingHtmlStartSnippet + data.followingHtml, + followingFragment = this.htmlToFragment(followingHtml), + newFragment = this.htmlToFragment(newHTML), + child; + + var merged = document.createDocumentFragment(); + + while (previousFragment.children.length > 0) { + child = previousFragment.children[0]; + previousFragment.removeChild(child); + merged.appendChild(child); + } + while (newFragment.children.length > 0) { + child = newFragment.children[0]; + newFragment.removeChild(child); + merged.appendChild(child); + } + while (followingFragment.children.length > 0) { + child = followingFragment.children[0]; + followingFragment.removeChild(child); + merged.appendChild(child); + } + //var merged = this._replaceLinesAttemptMerge(lastOfPrevious, firstOfReplaced); + + return this._serializeDom(merged, true); + }; +}); + + +}()); diff --git a/openslides/motions/static/js/motions/linenumbering.js b/openslides/motions/static/js/motions/linenumbering.js new file mode 100644 index 000000000..f4038ed1d --- /dev/null +++ b/openslides/motions/static/js/motions/linenumbering.js @@ -0,0 +1,329 @@ +(function () { + +"use strict"; + +angular.module('OpenSlidesApp.motions.lineNumbering', []) + +/** + * Current limitations of this implementation: + * + * Only the following inline elements are supported: + * - 'SPAN', 'A', 'EM', 'S', 'B', 'I', 'STRONG', 'U', 'BIG', 'SMALL', 'SUB', 'SUP', 'TT' + * + * Only other inline elements are allowed within inline elements. + * No constructs like
    are allowed. CSS-attributes like 'display: block' are ignored. + */ + +.service('lineNumberingService', function () { + var ELEMENT_NODE = 1, + TEXT_NODE = 3; + + this._currentInlineOffset = null; + this._currentLineNumber = null; + this._prependLineNumberToFirstText = false; + + this._isInlineElement = function (node) { + var inlineElements = [ + 'SPAN', 'A', 'EM', 'S', 'B', 'I', 'STRONG', 'U', 'BIG', 'SMALL', 'SUB', 'SUP', 'TT' + ]; + return (inlineElements.indexOf(node.nodeName) > -1); + }; + + this._isOsLineBreakNode = function (node) { + var isLineBreak = false; + if (node && node.nodeType === ELEMENT_NODE && node.nodeName == 'BR' && node.hasAttribute('class')) { + var classes = node.getAttribute('class').split(' '); + if (classes.indexOf('os-line-break') > -1) { + isLineBreak = true; + } + } + return isLineBreak; + }; + + this._isOsLineNumberNode = function (node) { + var isLineNumber = false; + if (node && node.nodeType === ELEMENT_NODE && node.nodeName == 'SPAN' && node.hasAttribute('class')) { + var classes = node.getAttribute('class').split(' '); + if (classes.indexOf('os-line-number') > -1) { + isLineNumber = true; + } + } + return isLineNumber; + }; + + /** + * Splits a TEXT_NODE into an array of TEXT_NODEs and BR-Elements separating them into lines. + * Each line has a maximum length of 'length', with one exception: spaces are accepted to exceed the length. + * Otherwise the string is split by the last space or dash in the line. + * + * @param node + * @param length + * @returns Array + * @private + */ + this._textNodeToLines = function (node, length) { + var out = [], + currLineStart = 0, + i = 0, + firstTextNode = true, + lastBreakableIndex = null, + service = this; + + var createLineBreak = function() { + var br = document.createElement('br'); + br.setAttribute('class', 'os-line-break'); + return br; + }; + var createLineNumber = function() { + var node = document.createElement('span'); + var lineNumber = service._currentLineNumber; + service._currentLineNumber++; + node.setAttribute('class', 'os-line-number line-number-' + lineNumber); + node.setAttribute('data-line-number', lineNumber + ''); + node.setAttribute('contenteditable', 'false'); + node.innerHTML = ' '; // Prevent tinymce from stripping out empty span's + return node; + }; + var addLine = function (text) { + var newNode = document.createTextNode(text); + if (firstTextNode) { + firstTextNode = false; + } else { + out.push(createLineBreak()); + out.push(createLineNumber()); + } + out.push(newNode); + }; + + if (node.nodeValue == "\n") { + out.push(node); + } else { + + // This happens if a previous inline element exactly stretches to the end of the line + if (this._currentInlineOffset >= length) { + out.push(createLineBreak()); + out.push(createLineNumber()); + this._currentInlineOffset = 0; + } else if (this._prependLineNumberToFirstText) { + out.push(createLineNumber()); + } + this._prependLineNumberToFirstText = false; + + while (i < node.nodeValue.length) { + var lineBreakAt = null; + if (this._currentInlineOffset >= length) { + if (lastBreakableIndex !== null) { + lineBreakAt = lastBreakableIndex; + } else { + lineBreakAt = i - 1; + } + } + if (lineBreakAt !== null && node.nodeValue[i] != ' ') { + var currLine = node.nodeValue.substring(currLineStart, lineBreakAt + 1); + addLine(currLine); + + currLineStart = lineBreakAt + 1; + this._currentInlineOffset = i - lineBreakAt - 1; + lastBreakableIndex = null; + } + + if (node.nodeValue[i] == ' ' || node.nodeValue[i] == '-') { + lastBreakableIndex = i; + } + + this._currentInlineOffset++; + i++; + + } + addLine(node.nodeValue.substring(currLineStart)); + } + return out; + }; + + + /** + * Moves line breaking and line numbering markup before inline elements + * + * @param innerNode + * @param outerNode + * @private + */ + this._moveLeadingLineBreaksToOuterNode = function (innerNode, outerNode) { + if (this._isInlineElement(innerNode)) { + if (this._isOsLineBreakNode(innerNode.firstChild)) { + var br = innerNode.firstChild; + innerNode.removeChild(br); + outerNode.appendChild(br); + } + if (this._isOsLineNumberNode(innerNode.firstChild)) { + var span = innerNode.firstChild; + innerNode.removeChild(span); + outerNode.appendChild(span); + } + } + }; + + + this._insertLineNumbersToInlineNode = function (node, length) { + var oldChildren = [], i; + for (i = 0; i < node.childNodes.length; i++) { + oldChildren.push(node.childNodes[i]); + } + + while (node.firstChild) { + node.removeChild(node.firstChild); + } + + for (i = 0; i < oldChildren.length; i++) { + if (oldChildren[i].nodeType == TEXT_NODE) { + var ret = this._textNodeToLines(oldChildren[i], length); + for (var j = 0; j < ret.length; j++) { + node.appendChild(ret[j]); + } + } else if (oldChildren[i].nodeType == ELEMENT_NODE) { + var changedNode = this._insertLineNumbersToNode(oldChildren[i], length); + this._moveLeadingLineBreaksToOuterNode(changedNode, node); + node.appendChild(changedNode); + } else { + throw 'Unknown nodeType: ' + i + ': ' + oldChildren[i]; + } + } + + return node; + }; + + this._calcBlockNodeLength = function (node, oldLength) { + var newLength = oldLength; + switch (node.nodeName) { + case 'LI': + newLength -= 5; + break; + case 'BLOCKQUOTE': + newLength -= 20; + break; + case 'DIV': + case 'P': + var styles = node.getAttribute("style"), + padding = 0; + if (styles) { + var leftpad = styles.split("padding-left:"); + if (leftpad.length > 1) { + leftpad = parseInt(leftpad[1]); + padding += leftpad; + } + var rightpad = styles.split("padding-right:"); + if (rightpad.length > 1) { + rightpad = parseInt(rightpad[1]); + padding += rightpad; + } + newLength -= (padding / 5); + } + break; + case 'H1': + newLength *= 0.5; + break; + case 'H2': + newLength *= 0.66; + break; + case 'H3': + newLength *= 0.66; + break; + } + return Math.ceil(newLength); + }; + + this._insertLineNumbersToBlockNode = function (node, length) { + this._currentInlineOffset = 0; + this._prependLineNumberToFirstText = true; + + var oldChildren = [], i; + for (i = 0; i < node.childNodes.length; i++) { + oldChildren.push(node.childNodes[i]); + } + + while (node.firstChild) { + node.removeChild(node.firstChild); + } + + for (i = 0; i < oldChildren.length; i++) { + if (oldChildren[i].nodeType == TEXT_NODE) { + var ret = this._textNodeToLines(oldChildren[i], length); + for (var j = 0; j < ret.length; j++) { + node.appendChild(ret[j]); + } + } else if (oldChildren[i].nodeType == ELEMENT_NODE) { + var changedNode = this._insertLineNumbersToNode(oldChildren[i], length); + this._moveLeadingLineBreaksToOuterNode(changedNode, node); + node.appendChild(changedNode); + } else { + throw 'Unknown nodeType: ' + i + ': ' + oldChildren[i]; + } + } + + this._currentInlineOffset = 0; + this._prependLineNumberToFirstText = true; + + return node; + }; + + this._insertLineNumbersToNode = function (node, length) { + if (node.nodeType !== ELEMENT_NODE) { + throw 'This method may only be called for ELEMENT-nodes: ' + node.nodeValue; + } + if (this._isInlineElement(node)) { + return this._insertLineNumbersToInlineNode(node, length); + } else { + var newLength = this._calcBlockNodeLength(node, length); + return this._insertLineNumbersToBlockNode(node, newLength); + } + }; + + this._stripLineNumbers = function (node) { + + for (var i = 0; i < node.childNodes.length; i++) { + if (this._isOsLineBreakNode(node.childNodes[i]) || this._isOsLineNumberNode(node.childNodes[i])) { + node.removeChild(node.childNodes[i]); + i--; + } else { + this._stripLineNumbers(node.childNodes[i]); + } + } + }; + + this._nodesToHtml = function (nodes) { + var root = document.createElement('div'); + for (var i in nodes) { + if (nodes.hasOwnProperty(i)) { + root.appendChild(nodes[i]); + } + } + return root.innerHTML; + }; + + this.insertLineNumbersNode = function (html, lineLength) { + var root = document.createElement('div'); + root.innerHTML = html; + + this._currentInlineOffset = 0; + this._currentLineNumber = 1; + this._prependLineNumberToFirstText = true; + + return this._insertLineNumbersToNode(root, lineLength); + }; + + this.insertLineNumbers = function (html, lineLength) { + var newRoot = this.insertLineNumbersNode(html, lineLength); + + return newRoot.innerHTML; + }; + + this.stripLineNumbers = function (html) { + var root = document.createElement('div'); + root.innerHTML = html; + this._stripLineNumbers(root); + return root.innerHTML; + }; +}); + + +}()); diff --git a/openslides/motions/static/js/motions/projector.js b/openslides/motions/static/js/motions/projector.js index 23efa505b..bb4c91c87 100644 --- a/openslides/motions/static/js/motions/projector.js +++ b/openslides/motions/static/js/motions/projector.js @@ -17,7 +17,8 @@ angular.module('OpenSlidesApp.motions.projector', ['OpenSlidesApp.motions']) '$scope', 'Motion', 'User', - function($scope, Motion, User) { + 'Config', + function($scope, Motion, User, Config) { // Attention! Each object that is used here has to be dealt on server side. // Add it to the coresponding get_requirements method of the ProjectorElement // class. @@ -32,6 +33,8 @@ angular.module('OpenSlidesApp.motions.projector', ['OpenSlidesApp.motions']) // load all users User.findAll(); User.bindAll({}, $scope, 'users'); + + Config.bindOne('motions_default_line_numbering', $scope, 'line_numbering'); } ]); diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js index b5d3cf109..4ed2ecc4f 100644 --- a/openslides/motions/static/js/motions/site.js +++ b/openslides/motions/static/js/motions/site.js @@ -2,7 +2,7 @@ 'use strict'; -angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions']) +angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlidesApp.motions.diff']) .factory('MotionContentProvider', ['gettextCatalog', function(gettextCatalog) { /** @@ -767,6 +767,7 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions']) .controller('MotionDetailCtrl', [ '$scope', '$http', + '$timeout', 'ngDialog', 'MotionForm', 'Motion', @@ -775,16 +776,18 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions']) 'Tag', 'User', 'Workflow', + 'Editor', + 'Config', 'motion', 'SingleMotionContentProvider', 'MotionContentProvider', 'PdfMakeConverter', 'PdfMakeDocumentProvider', 'gettextCatalog', - function($scope, $http, ngDialog, MotionForm, - Motion, Category, Mediafile, Tag, - User, Workflow, motion, - SingleMotionContentProvider, MotionContentProvider, PdfMakeConverter, PdfMakeDocumentProvider, gettextCatalog) { + 'diffService', + function($scope, $http, $timeout, ngDialog, MotionForm, Motion, Category, Mediafile, Tag, User, Workflow, Editor, + Config,motion, SingleMotionContentProvider, MotionContentProvider, PdfMakeConverter, + PdfMakeDocumentProvider, gettextCatalog, diffService) { Motion.bindOne(motion.id, $scope, 'motion'); Category.bindAll({}, $scope, 'categories'); Mediafile.bindAll({}, $scope, 'mediafiles'); @@ -794,6 +797,8 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions']) Motion.loadRelations(motion, 'agenda_item'); $scope.version = motion.active_version; $scope.isCollapsed = true; + $scope.lineNumberMode = Config.get('motions_default_line_numbering').value; + $scope.lineBrokenText = motion.getTextWithLineBreaks($scope.version); $scope.makePDF = function(){ var content = motion.getText($scope.version) + motion.getReason($scope.version), @@ -824,6 +829,9 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions']) // open edit dialog $scope.openDialog = function (motion) { + if ($scope.inlineEditing.active) { + $scope.disableInlineEditing(); + } ngDialog.open(MotionForm.getDialog(motion)); }; // support @@ -871,13 +879,24 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions']) // show specific version $scope.showVersion = function (version) { $scope.version = version.id; + $scope.lineBrokenText = motion.getTextWithLineBreaks($scope.version); + $scope.inlineEditing.allowed = (motion.isAllowed('update') && $scope.version == motion.getVersion(-1).id); + $scope.inlineEditing.changed = false; + $scope.inlineEditing.active = false; + if ($scope.inlineEditing.editor) { + $scope.inlineEditing.editor.setContent($scope.lineBrokenText); + $scope.inlineEditing.editor.setMode("readonly"); + $scope.inlineEditing.originalHtml = $scope.inlineEditing.editor.getContent(); + } else { + $scope.inlineEditing.originalHtml = $scope.lineBrokenText; + } }; // permit specific version $scope.permitVersion = function (version) { $http.put('/rest/motions/motion/' + motion.id + '/manage_version/', {'version_number': version.version_number}) .then(function(success) { - $scope.version = version.id; + $scope.showVersion(version); }); }; // delete specific version @@ -886,9 +905,101 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions']) {headers: {'Content-Type': 'application/json'}, data: JSON.stringify({version_number: version.version_number})}) .then(function(success) { - $scope.version = motion.active_version; + $scope.showVersion(motion.active_version); }); }; + + // Inline editing functions + $scope.inlineEditing = { + allowed: (motion.isAllowed('update') && $scope.version == motion.getVersion(-1).id), + active: false, + changed: false, + trivialChange: false, + trivialChangeAllowed: false, + editor: null, + originalHtml: $scope.lineBrokenText, + }; + + if (motion.state.versioning && Config.get('motions_allow_disable_versioning').value) { + $scope.inlineEditing.trivialChange = true; + $scope.inlineEditing.trivialChangeAllowed = true; + } + + $scope.$watch( + function () { + return Motion.lastModified(); + }, + function () { + $scope.inlineEditing.trivialChangeAllowed = + (motion.state.versioning && Config.get('motions_allow_disable_versioning').value); + } + ); + + $scope.tinymceOptions = Editor.getOptions(null, true); + $scope.tinymceOptions.readonly = 1; + $scope.tinymceOptions.setup = function (editor) { + $scope.inlineEditing.editor = editor; + editor.on("init", function () { + $scope.lineBrokenText = motion.getTextWithLineBreaks($scope.version); + $scope.inlineEditing.editor.setContent($scope.lineBrokenText); + $scope.inlineEditing.originalHtml = $scope.inlineEditing.editor.getContent(); + $scope.inlineEditing.changed = false; + }); + editor.on("change", function () { + $scope.inlineEditing.changed = (editor.getContent() != $scope.inlineEditing.originalHtml); + }); + editor.on("undo", function() { + $scope.inlineEditing.changed = (editor.getContent() != $scope.inlineEditing.originalHtml); + }); + }; + + $scope.enableInlineEditing = function() { + $scope.inlineEditing.editor.setMode("design"); + $scope.inlineEditing.active = true; + $scope.inlineEditing.changed = false; + + $scope.lineBrokenText = motion.getTextWithLineBreaks($scope.version); + $scope.inlineEditing.editor.setContent($scope.lineBrokenText); + $scope.inlineEditing.originalHtml = $scope.inlineEditing.editor.getContent(); + $timeout(function() { + $scope.inlineEditing.editor.focus(); + }, 100); + }; + + $scope.disableInlineEditing = function() { + $scope.inlineEditing.editor.setMode("readonly"); + $scope.inlineEditing.active = false; + $scope.inlineEditing.changed = false; + $scope.lineBrokenText = $scope.inlineEditing.originalHtml; + $scope.inlineEditing.editor.setContent($scope.inlineEditing.originalHtml); + }; + + $scope.motionInlineSave = function () { + if (!$scope.inlineEditing.allowed) { + throw "No permission to update motion"; + } + + motion.setTextStrippingLineBreaks(motion.active_version, $scope.inlineEditing.editor.getContent()); + motion.disable_versioning = $scope.inlineEditing.trivialChange; + + Motion.inject(motion); + // save change motion object on server + Motion.save(motion, { method: 'PATCH' }).then( + function(success) { + $scope.showVersion(motion.getVersion(-1)); + }, + function (error) { + // save error: revert all changes by restore + // (refresh) original motion object from server + Motion.refresh(motion); + var message = ''; + for (var e in error.data) { + message += e + ': ' + error.data[e] + ' '; + } + $scope.alert = {type: 'danger', msg: message, show: true}; + } + ); + }; } ]) @@ -1389,6 +1500,15 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions']) gettext('Show paragraph numbering (only in PDF)'); /// Prefix for the identifier for amendments gettext('A'); + gettext('Default line numbering'); + /// Line numbering: Outside + gettext('Outside'); + /// Line numbering: Inline + gettext('Inline'); + /// Line numbering: None + gettext('None'); + gettext('Line length'); + gettext('The maximum number of characters per line. Relevant when line numbering is enabled. Min: 40'); } ]); diff --git a/openslides/motions/static/templates/motions/motion-detail.html b/openslides/motions/static/templates/motions/motion-detail.html index cbf62a254..8f4fdd080 100644 --- a/openslides/motions/static/templates/motions/motion-detail.html +++ b/openslides/motions/static/templates/motions/motion-detail.html @@ -227,9 +227,66 @@
    -
    -

    Text

    -
    +
    + + +
    + +
    +
    +
    + +
    + + + +
    +
    + +
    + +
    +
    +
    +
    +
    + +
    +
    The text has been changed.
    + + +
    +
    +
    +
    +
    @@ -286,6 +343,8 @@
    • {{ message.message }} +
    • +
    diff --git a/openslides/motions/static/templates/motions/slide_motion.html b/openslides/motions/static/templates/motions/slide_motion.html index 72d0ddabc..20d73c4cf 100644 --- a/openslides/motions/static/templates/motions/slide_motion.html +++ b/openslides/motions/static/templates/motions/slide_motion.html @@ -70,7 +70,8 @@
    -
    +

    Reason

    diff --git a/package.json b/package.json index bec040cf0..01f202033 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "prepublish": "bower install && gulp" }, "devDependencies": { + "angular-mocks": "~1.5.7", "bower": "~1.7.2", "es6-promise": "~3.0.2", "gulp": "~3.9.0", @@ -15,7 +16,11 @@ "gulp-jshint": "~2.0.0", "gulp-rename": "~1.2.2", "gulp-uglify": "~1.5.2", + "jasmine": "~2.4.1", "jshint": "~2.9.2", + "karma": "~1.1.0", + "karma-jasmine": "~1.0.2", + "karma-chrome-launcher": "~1.0.1", "main-bower-files": "~2.11.1", "po2json": "~0.4.1", "sprintf-js": "~1.0.3", diff --git a/tests/karma/karma.conf.js b/tests/karma/karma.conf.js new file mode 100644 index 000000000..b3e7e9554 --- /dev/null +++ b/tests/karma/karma.conf.js @@ -0,0 +1,76 @@ +// Karma configuration +// Generated on Sun Jun 26 2016 14:46:31 GMT+0200 (CEST) + +module.exports = function(config) { + config.set({ + + // base path that will be used to resolve all patterns (eg. files, exclude) + basePath: '../..', + + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['jasmine'], + + + // list of files / patterns to load in the browser + files: [ + 'openslides/static/js/openslides-libs.js', + 'node_modules/angular-mocks/angular-mocks.js', + 'openslides/motions/static/js/motions/linenumbering.js', + 'openslides/motions/static/js/motions/diff.js', + 'tests/karma/*/*.test.js' + ], + + + // list of files to exclude + exclude: [ + ], + + + // preprocess matching files before serving them to the browser + // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor + preprocessors: { + }, + + + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ['progress'], + + + // web server port + port: 9876, + + + // enable / disable colors in the output (reporters and logs) + colors: true, + + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + client: { + captureConsole: true + }, + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: true, + + + // start these browsers + // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + browsers: ['Chrome'], + + + // Continuous Integration mode + // if true, Karma captures browsers, runs the tests and exits + singleRun: false, + + // Concurrency level + // how many browser should be started simultaneous + concurrency: Infinity + }) +} diff --git a/tests/karma/motions/diff.service.test.js b/tests/karma/motions/diff.service.test.js new file mode 100644 index 000000000..21e0d57b1 --- /dev/null +++ b/tests/karma/motions/diff.service.test.js @@ -0,0 +1,162 @@ +describe('linenumbering', function () { + + beforeEach(module('OpenSlidesApp.motions.diff')); + + var diffService, baseHtmlDom1, baseHtmlDom2, + brMarkup = function (no) { + return '
    ' + + ' '; + }, + noMarkup = function (no) { + return ' '; + }; + + beforeEach(inject(function (_diffService_) { + diffService = _diffService_; + + baseHtmlDom1 = diffService.htmlToFragment('

    ' + + noMarkup(1) + 'Line 1 ' + brMarkup(2) + 'Line 2' + + brMarkup(3) + 'Line 3
    ' + noMarkup(4) + 'Line 4 ' + brMarkup(5) + 'Line
    5

    ' + + '
      ' + + '
    • ' + noMarkup(6) + 'Line 6 ' + brMarkup(7) + 'Line 7' + '
    • ' + + '
      • ' + + '
      • ' + noMarkup(8) + 'Level 2 LI 8
      • ' + + '
      • ' + noMarkup(9) + 'Level 2 LI 9
      • ' + + '
    • ' + + '
    ' + + '

    ' + noMarkup(10) + 'Line 10 ' + brMarkup(11) + 'Line 11

    '); + + baseHtmlDom2 = diffService.htmlToFragment('

     Single text line

    \ +

     sdfsdfsdfsdf dsfsdfsdfdsflkewjrl ksjfl ksdjf klnlkjBavaria ipsum dolor sit amet Biazelt Auffisteign
     Schorsch mim Radl foahn Ohrwaschl Steckerleis wann griagd ma nacha wos z’dringa glacht Mamalad,
    ' + + ' muass? I bin a woschechta Bayer sowos oamoi und sei und glei wirds no fui lustiga: Jo mei khkhis des
     schee middn ognudelt, Trachtnhuat Biawambn gscheid: Griasd eich midnand etza nix Gwiass woass ma ned
    ' + + ' owe. Dahoam gscheckate middn Spuiratz des is a gmahde Wiesn. Des is schee so Obazda san da, Haferl
     pfenningguat schoo griasd eich midnand.

    \ +
      \ +
    •  Auffi Gamsbart nimma de Sepp Ledahosn Ohrwaschl um Godds wujn Wiesn Deandlgwand Mongdratzal! Jo
       leck mi Mamalad i daad mechad?
    • \ +
    •  Do nackata Wurscht i hob di narrisch gean, Diandldrahn Deandlgwand vui huift vui woaß?
    • \ +
    •  Ned Mamalad auffi i bin a woschechta Bayer greaßt eich nachad, umananda gwiss nia need
       Weiznglasl.
    • \ +
    •  Woibbadinga noch da Giasinga Heiwog Biazelt mechad mim Spuiratz, soi zwoa.
    • \ +
    \ +

     I waar soweid Blosmusi es nomoi. Broadwurschtbudn des is a gmahde Wiesn Kirwa mogsd a Bussal
     Guglhupf schüds nei. Luja i moan oiwei Baamwach Watschnbaam, wiavui baddscher! Biakriagal a fescha
    ' + + ' 1Bua Semmlkneedl iabaroi oba um Godds wujn Ledahosn wui Greichats. Geh um Godds wujn luja heid
     greaßt eich nachad woaß Breihaus eam! De om auf’n Gipfe auf gehds beim Schichtl mehra Baamwach a
     bissal wos gehd ollaweil gscheid:

    \ +
    \ +

     Scheans Schdarmbeaga See i hob di narrisch gean i jo mei is des schee! Nia eam
     hod vasteh i sog ja nix, i red ja bloß sammawiedaguad, umma eana obandeln! Zwoa
     jo mei scheans amoi, san und hoggd Milli barfuaßat gscheit. Foidweg vui huift
    ' + + ' vui singan, mehra Biakriagal om auf’n Gipfe! Ozapfa sodala Charivari greaßt eich
     nachad Broadwurschtbudn do middn liberalitas Bavariae sowos Leonhardifahrt:

    \ +
    \ +

     Wui helfgod Wiesn, ognudelt schaugn: Dahoam gelbe Rüam Schneid singan wo hi sauba i moan scho aa no
     a Maß a Maß und no a Maß nimma. Is umananda a ganze Hoiwe zwoa, Schneid. Vui huift vui Brodzeid kumm
    ' + + ' geh naa i daad vo de allerweil, gor. Woaß wia Gams, damischa. A ganze Hoiwe Ohrwaschl Greichats
     iabaroi Prosd Engelgwand nix Reiwadatschi.Weibaleid ognudelt Ledahosn noch da Giasinga Heiwog i daad
    ' + + ' Almrausch, Ewig und drei Dog nackata wea ko, dea ko. Meidromml Graudwiggal nois dei, nackata. No
     Diandldrahn nix Gwiass woass ma ned hod boarischer: Samma sammawiedaguad wos, i hoam Brodzeid. Jo
    ' + + ' mei Sepp Gaudi, is ma Wuascht do Hendl Xaver Prosd eana an a bravs. Sauwedda an Brezn, abfieseln.

    '); + + diffService._insertInternalLineMarkers(baseHtmlDom1); + diffService._insertInternalLineMarkers(baseHtmlDom2); + })); + + + describe('extraction of lines', function () { + it('locates line number nodes', function() { + var lineNumberNode = diffService.getLineNumberNode(baseHtmlDom1, 4); + expect(lineNumberNode.parentNode.nodeName).toBe('STRONG'); + + lineNumberNode = diffService.getLineNumberNode(baseHtmlDom1, 9); + expect(lineNumberNode.parentNode.nodeName).toBe('UL'); + + lineNumberNode = diffService.getLineNumberNode(baseHtmlDom1, 15); + expect(lineNumberNode).toBe(null); + }); + + it('finds the common ancestor', function() { + var fromLineNode, toLineNode, commonAncestor; + + fromLineNode = diffService.getLineNumberNode(baseHtmlDom1, 6); + toLineNode = diffService.getLineNumberNode(baseHtmlDom1, 7); + commonAncestor = diffService._getCommonAncestor(fromLineNode, toLineNode); + expect(commonAncestor.commonAncestor.nodeName).toBe("#document-fragment"); + + fromLineNode = diffService.getLineNumberNode(baseHtmlDom1, 6); + toLineNode = diffService.getLineNumberNode(baseHtmlDom1, 8); + commonAncestor = diffService._getCommonAncestor(fromLineNode, toLineNode); + expect(commonAncestor.commonAncestor.nodeName).toBe("#document-fragment"); + + fromLineNode = diffService.getLineNumberNode(baseHtmlDom1, 6); + toLineNode = diffService.getLineNumberNode(baseHtmlDom1, 10); + commonAncestor = diffService._getCommonAncestor(fromLineNode, toLineNode); + expect(commonAncestor.commonAncestor.nodeName).toBe("#document-fragment"); + + }); + + it('renders DOMs correctly (1)', function() { + var lineNo = diffService.getLineNumberNode(baseHtmlDom1, 7), + greatParent = lineNo.parentNode.parentNode, + lineTrace = [lineNo.parentNode, lineNo]; + + var pre = diffService._serializePartialDomToChild(greatParent, lineTrace, true); + expect(pre).toBe('
    • Line 6 '); + + lineTrace = [lineNo.parentNode, lineNo]; + var post = diffService._serializePartialDomFromChild(greatParent, lineTrace, true); + expect(post).toBe('Line 7' + '
    • ' + + '
      • ' + + '
      • Level 2 LI 8
      • ' + + '
      • Level 2 LI 9
      • ' + + '
    • ' + + '
    '); + }); + + it('renders DOMs correctly (2)', function() { + var lineNo = diffService.getLineNumberNode(baseHtmlDom1, 9), + greatParent = lineNo.parentNode.parentNode, + lineTrace = [lineNo.parentNode, lineNo]; + + var pre = diffService._serializePartialDomToChild(greatParent, lineTrace, true); + expect(pre).toBe('
    • Level 2 LI 8
    • '); + }); + + it('extracts a single line', function () { + var diff = diffService.extractRangeByLineNumbers(baseHtmlDom1, 1, 2); + expect(diff.html).toBe('

      Line 1 '); + expect(diff.outerContextStart).toBe(''); + expect(diff.outerContextEnd).toBe(''); + }); + + it('extracts lines from nested UL/LI-structures', function () { + var diff = diffService.extractRangeByLineNumbers(baseHtmlDom1, 7, 9); + expect(diff.html).toBe('Line 7

      • Level 2 LI 8
      • '); + expect(diff.ancestor.nodeName).toBe('UL'); + expect(diff.outerContextStart).toBe('
          '); + expect(diff.outerContextEnd).toBe('
        '); + expect(diff.innerContextStart).toBe('
      • '); + expect(diff.innerContextEnd).toBe('
    • '); + expect(diff.previousHtmlEndSnippet).toBe('
    '); + expect(diff.followingHtmlStartSnippet).toBe('
      • '); + }); + + it('extracts lines from a more complex example', function () { + var diff = diffService.extractRangeByLineNumbers(baseHtmlDom2, 6, 11, true); + + expect(diff.html).toBe('owe. Dahoam gscheckate middn Spuiratz des is a gmahde Wiesn. Des is schee so Obazda san da, Haferl pfenningguat schoo griasd eich midnand.

        • Auffi Gamsbart nimma de Sepp Ledahosn Ohrwaschl um Godds wujn Wiesn Deandlgwand Mongdratzal! Jo leck mi Mamalad i daad mechad?
        • Do nackata Wurscht i hob di narrisch gean, Diandldrahn Deandlgwand vui huift vui woaß?
        • '); + expect(diff.ancestor.nodeName).toBe('#document-fragment'); + expect(diff.outerContextStart).toBe(''); + expect(diff.outerContextEnd).toBe(''); + expect(diff.innerContextStart).toBe('

          '); + expect(diff.innerContextEnd).toBe('

        '); + expect(diff.previousHtmlEndSnippet).toBe('

        '); + expect(diff.followingHtmlStartSnippet).toBe('
          '); + }); + + }); + + describe('merging lines into the original motion', function () { + + it('replaces LIs by a P', function () { + var merged = diffService.replaceLines(baseHtmlDom1, '

          Replaced a UL by a P

          ', 6, 9); + expect(merged).toBe('

          Line 1 Line 2Line 3
          Line 4 Line
          5

          Replaced a UL by a P

            • Level 2 LI 9

          Line 10 Line 11

          '); + }); + /* + it('replaces LIs by another LI', function () { + var merged = diffService.replaceLines(baseHtmlDom1, '
          • A new LI
          ', 6, 9); + expect(merged).toBe(''); + }); + */ + + }); +}); diff --git a/tests/karma/motions/linenumbering.service.test.js b/tests/karma/motions/linenumbering.service.test.js new file mode 100644 index 000000000..812359269 --- /dev/null +++ b/tests/karma/motions/linenumbering.service.test.js @@ -0,0 +1,216 @@ +describe('linenumbering', function () { + + beforeEach(module('OpenSlidesApp.motions.lineNumbering')); + + var lineNumberingService, + brMarkup = function (no) { + return '
          ' + + ' '; + }, + noMarkup = function (no) { + return ' '; + }, + longstr = function (length) { + var outstr = ''; + for (var i = 0; i < length; i++) { + outstr += String.fromCharCode(65 + (i % 26)); + } + return outstr; + }; + + beforeEach(inject(function (_lineNumberingService_) { + lineNumberingService = _lineNumberingService_; + })); + + describe('line numbering: test nodes', function () { + it('breaks very short lines', function () { + var textNode = document.createTextNode("0123"); + lineNumberingService._currentInlineOffset = 0; + var out = lineNumberingService._textNodeToLines(textNode, 5); + var outHtml = lineNumberingService._nodesToHtml(out); + expect(outHtml).toBe('0123'); + expect(lineNumberingService._currentInlineOffset).toBe(4); + }); + + it('breaks simple lines', function () { + var textNode = document.createTextNode("012345678901234567"); + lineNumberingService._currentInlineOffset = 0; + lineNumberingService._currentLineNumber = 1; + var out = lineNumberingService._textNodeToLines(textNode, 5); + var outHtml = lineNumberingService._nodesToHtml(out); + expect(outHtml).toBe('01234' + brMarkup(1) + '56789' + brMarkup(2) + '01234' + brMarkup(3) + '567'); + expect(lineNumberingService._currentInlineOffset).toBe(3); + }); + + it('breaks simple lines with offset', function () { + var textNode = document.createTextNode("012345678901234567"); + lineNumberingService._currentInlineOffset = 2; + lineNumberingService._currentLineNumber = 1; + var out = lineNumberingService._textNodeToLines(textNode, 5); + var outHtml = lineNumberingService._nodesToHtml(out); + expect(outHtml).toBe('012' + brMarkup(1) + '34567' + brMarkup(2) + '89012' + brMarkup(3) + '34567'); + expect(lineNumberingService._currentInlineOffset).toBe(5); + }); + + it('breaks simple lines with offset equaling to length', function () { + var textNode = document.createTextNode("012345678901234567"); + lineNumberingService._currentInlineOffset = 5; + lineNumberingService._currentLineNumber = 1; + var out = lineNumberingService._textNodeToLines(textNode, 5); + var outHtml = lineNumberingService._nodesToHtml(out); + expect(outHtml).toBe(brMarkup(1) + '01234' + brMarkup(2) + '56789' + brMarkup(3) + '01234' + brMarkup(4) + '567'); + expect(lineNumberingService._currentInlineOffset).toBe(3); + }); + + it('breaks simple lines with spaces (1)', function () { + var textNode = document.createTextNode("0123 45 67 89012 34 567"); + lineNumberingService._currentInlineOffset = 0; + lineNumberingService._currentLineNumber = 1; + var out = lineNumberingService._textNodeToLines(textNode, 5); + var outHtml = lineNumberingService._nodesToHtml(out); + expect(outHtml).toBe('0123 ' + brMarkup(1) + '45 67 ' + brMarkup(2) + '89012 ' + brMarkup(3) + '34 ' + brMarkup(4) + '567'); + expect(lineNumberingService._currentInlineOffset).toBe(3); + }); + + it('breaks simple lines with spaces (2)', function () { + var textNode = document.createTextNode("0123 45 67 89012tes 344 "); + lineNumberingService._currentInlineOffset = 0; + lineNumberingService._currentLineNumber = 1; + var out = lineNumberingService._textNodeToLines(textNode, 5); + var outHtml = lineNumberingService._nodesToHtml(out); + expect(outHtml).toBe('0123 ' + brMarkup(1) + '45 67 ' + brMarkup(2) + '89012' + brMarkup(3) + 'tes ' + brMarkup(4) + '344 '); + expect(lineNumberingService._currentInlineOffset).toBe(4); + }); + + it('breaks simple lines with spaces (3)', function () { + var textNode = document.createTextNode("I'm a Demo-Text"); + lineNumberingService._currentInlineOffset = 0; + lineNumberingService._currentLineNumber = 1; + var out = lineNumberingService._textNodeToLines(textNode, 5); + var outHtml = lineNumberingService._nodesToHtml(out); + expect(outHtml).toBe('I\'m a ' + brMarkup(1) + 'Demo-' + brMarkup(2) + 'Text'); + expect(lineNumberingService._currentInlineOffset).toBe(4); + }); + + it('breaks simple lines with spaces (4)', function () { + var textNode = document.createTextNode("I'm a LongDemo-Text"); + lineNumberingService._currentInlineOffset = 0; + lineNumberingService._currentLineNumber = 1; + var out = lineNumberingService._textNodeToLines(textNode, 5); + var outHtml = lineNumberingService._nodesToHtml(out); + expect(outHtml).toBe('I\'m a ' + brMarkup(1) + 'LongD' + brMarkup(2) + 'emo-' + brMarkup(3) + 'Text'); + expect(lineNumberingService._currentInlineOffset).toBe(4); + }); + }); + + + describe('line numbering: inline nodes', function () { + it('leaves a simple SPAN untouched', function () { + var inHtml = "Test"; + var outHtml = lineNumberingService.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe(noMarkup(1) + 'Test'); + expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml); + }); + + it('breaks lines in a simple SPAN', function () { + var inHtml = "Lorem ipsum dolorsit amet"; + var outHtml = lineNumberingService.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe(noMarkup(1) + 'Lorem ' + brMarkup(2) + 'ipsum ' + brMarkup(3) + 'dolor' + brMarkup(4) + 'sit ' + brMarkup(5) + 'amet'); + expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml); + }); + + it('breaks lines in nested inline elements', function () { + var inHtml = "Lorem ipsum dolorsit amet"; + var outHtml = lineNumberingService.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe(noMarkup(1) + 'Lorem ' + brMarkup(2) + 'ipsum ' + brMarkup(3) + 'dolor' + brMarkup(4) + 'sit ' + brMarkup(5) + 'amet'); + expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml); + }); + }); + + + describe('line numbering: block nodes', function () { + it('leaves a simple DIV untouched', function () { + var inHtml = "
          Test
          "; + var outHtml = lineNumberingService.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe('
          ' + noMarkup(1) + 'Test
          '); + expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml); + }); + + it('breaks a DIV containing only inline elements', function () { + var inHtml = "
          Test Test12345678 Test
          "; + var outHtml = lineNumberingService.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe('
          ' + noMarkup(1) + 'Test ' + brMarkup(2) + 'Test1' + brMarkup(3) + '23456' + brMarkup(4) + '78 ' + brMarkup(5) + 'Test
          '); + expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml); + }); + + it('handles a DIV within a DIV correctly', function () { + var inHtml = "
          Te
          Te Test
          Test
          "; + var outHtml = lineNumberingService.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe('
          ' + noMarkup(1) + 'Te
          ' + noMarkup(2) + 'Te ' + brMarkup(3) + 'Test
          ' + noMarkup(4) + 'Test
          '); + expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml); + }); + + it('ignores white spaces between block element tags', function () { + var inHtml = "
            \n
          • Test
          • \n
          "; + var outHtml = lineNumberingService.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe("
            \n
          • " + noMarkup(1) + 'Test
          • \n
          '); + expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml); + }); + }); + + + describe('indentation for block elements', function () { + it('indents LI-elements', function () { + var inHtml = '
          ' +longstr(100) + '
          • ' + longstr(100) + '
          ' + longstr(100) + '
          '; + var expected = '
          ' + noMarkup(1) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' + brMarkup(2) + 'CDEFGHIJKLMNOPQRSTUV' + + '
          • ' + noMarkup(3) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVW' + brMarkup(4) + 'XYZABCDEFGHIJKLMNOPQRSTUV' + + '
          ' + noMarkup(5) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' + brMarkup(6) + 'CDEFGHIJKLMNOPQRSTUV
          '; + var outHtml = lineNumberingService.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe(expected); + expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml); + }); + + it('indents BLOCKQUOTE-elements', function () { + var inHtml = '
          ' +longstr(100) + '
          ' + longstr(100) + '
          ' + longstr(100) + '
          '; + var expected = '
          ' + noMarkup(1) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' + brMarkup(2) + 'CDEFGHIJKLMNOPQRSTUV' + + '
          ' + noMarkup(3) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH' + brMarkup(4) + 'IJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUV' + + '
          ' + noMarkup(5) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' + brMarkup(6) + 'CDEFGHIJKLMNOPQRSTUV
          '; + var outHtml = lineNumberingService.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe(expected); + expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml); + }); + + it('shortens the line for H1-elements by 1/2', function () { + var inHtml = '

          ' + longstr(80) + '

          '; + var expected = '

          ' + noMarkup(1) + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMN' + + brMarkup(2) + 'OPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB

          '; + var outHtml = lineNumberingService.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe(expected); + expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml); + }); + + it('shortens the line for H2-elements by 2/3', function () { + var inHtml = '

          ' + longstr(80) + '

          '; + var expected = '

          ' + noMarkup(1) + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZA' + + brMarkup(2) + 'BCDEFGHIJKLMNOPQRSTUVWXYZAB

          '; + var outHtml = lineNumberingService.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe(expected); + expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml); + }); + + it('indents Ps with 30px-padding by 6 characters', function () { + var inHtml = '
          ' + longstr(80) + '
          '; + var expected = '
          ' + noMarkup(1) + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUV' + + brMarkup(2) + 'WXYZAB
          '; + var outHtml = lineNumberingService.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe(expected); + expect(lineNumberingService.stripLineNumbers(outHtml)).toBe(inHtml); + }); + }); +});