1 /**
  2  * Copyright (C) 2010-2014 KO GmbH <copyright@kogmbh.com>
  3  *
  4  * @licstart
  5  * This file is part of WebODF.
  6  *
  7  * WebODF is free software: you can redistribute it and/or modify it
  8  * under the terms of the GNU Affero General Public License (GNU AGPL)
  9  * as published by the Free Software Foundation, either version 3 of
 10  * the License, or (at your option) any later version.
 11  *
 12  * WebODF is distributed in the hope that it will be useful, but
 13  * WITHOUT ANY WARRANTY; without even the implied warranty of
 14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 15  * GNU Affero General Public License for more details.
 16  *
 17  * You should have received a copy of the GNU Affero General Public License
 18  * along with WebODF.  If not, see <http://www.gnu.org/licenses/>.
 19  * @licend
 20  *
 21  * @source: http://www.webodf.org/
 22  * @source: https://github.com/kogmbh/WebODF/
 23  */
 24 
 25 /*global ops, runtime, odf, core, Node*/
 26 
 27 /**
 28  * Merges two adjacent paragraphs together into the first paragraph. The destination paragraph
 29  * is expected to always be the first paragraph in DOM order. No content (other than editinfo elements)
 30  * are removed as part of this operation. Once all child nodes have been shifted from the source paragraph,
 31  * the source paragraph and any collapsible parents will be cleaned up.
 32  *
 33  * @constructor
 34  * @implements ops.Operation
 35  */
 36 ops.OpMergeParagraph = function OpMergeParagraph() {
 37     "use strict";
 38 
 39     var memberid, timestamp,
 40         /**@type {!boolean}*/
 41         moveCursor,
 42         /**@type{!string}*/
 43         paragraphStyleName,
 44         /**@type{!number}*/
 45         sourceStartPosition,
 46         /**@type{!number}*/
 47         destinationStartPosition,
 48         odfUtils = new odf.OdfUtils(),
 49         domUtils = new core.DomUtils(),
 50         /**@const*/
 51         textns = odf.Namespaces.textns;
 52 
 53     /**
 54      * @param {!ops.OpMergeParagraph.InitSpec} data
 55      */
 56     this.init = function (data) {
 57         memberid = data.memberid;
 58         timestamp = data.timestamp;
 59         moveCursor = data.moveCursor;
 60         paragraphStyleName = data.paragraphStyleName;
 61         sourceStartPosition = parseInt(data.sourceStartPosition, 10);
 62         destinationStartPosition = parseInt(data.destinationStartPosition, 10);
 63     };
 64 
 65     this.isEdit = true;
 66     this.group = undefined;
 67 
 68     /**
 69      * Merges the source paragraph into the destination paragraph.
 70      * @param {!Element} destination Paragraph to merge content into
 71      * @param {!Element} source Paragraph to merge content from
 72      * @return {undefined}
 73      */
 74     function mergeParagraphs(destination, source) {
 75         var child;
 76 
 77         child = source.firstChild;
 78         while (child) {
 79             if (child.localName === 'editinfo'
 80                 || (odfUtils.isGroupingElement(child) && odfUtils.hasNoODFContent(child))) {
 81                 // Empty spans need to be cleaned up on merge, as remove text only removes things that contain text content
 82                 source.removeChild(child);
 83             } else {
 84                 // TODO It should be the view's responsibility to clean these up. This would fix #431
 85                 destination.appendChild(child);
 86             }
 87             child = source.firstChild;
 88         }
 89     }
 90 
 91     /**
 92      * Remove all the text nodes within the supplied range. These are expected to be insignificant whitespace only.
 93      * Assertions will be thrown if this is not the case.
 94      *
 95      * @param {!Range} range
 96      * @return {undefined}
 97      */
 98     function removeTextNodes(range) {
 99         var textNodes;
100 
101         if (range.collapsed) {
102             return;
103         }
104 
105         domUtils.splitBoundaries(range);
106         textNodes = odfUtils.getTextElements(range, false, true);
107         textNodes.forEach(function(node) {
108             var textNode = /**@type{!Text}*/(node);
109 
110             runtime.assert(textNode.nodeType === Node.TEXT_NODE, "Expected node type 3 (Node.TEXT_NODE), found node type " + textNode.nodeType);
111             if (textNode.length > 0) {
112                 runtime.assert(odfUtils.isODFWhitespace(textNode.data),
113                     "Non-whitespace node found between paragraph boundary and first or last step");
114 
115                 // Significant whitespace is only ever the first space char in a series of space.
116                 // Therefore, only need to check the first character to ensure complete string is insignificant whitespace.
117                 runtime.assert(odfUtils.isSignificantWhitespace(textNode, 0) === false,
118                     "Significant whitespace node found between paragraph boundary and first or last step");
119             } else {
120                 // This is not a critical issue, but indicates an operation somewhere isn't correctly normalizing text nodes
121                 // after manipulation of the DOM.
122                 runtime.log("WARN: Empty text node found during merge operation");
123             }
124             textNode.parentNode.removeChild(textNode);
125         });
126     }
127 
128     /**
129      * Remove all insignificant whitespace between the paragraph node boundaries and the first and last step within the
130      * paragraph. This prevents this insignificant whitespace accidentally becoming significant whitespace during the
131      * merge.
132      *
133      * @param {!core.StepIterator} stepIterator
134      * @param {!Element} paragraphElement
135      * @return {undefined}
136      */
137     function trimInsignificantWhitespace(stepIterator, paragraphElement) {
138         var range = paragraphElement.ownerDocument.createRange();
139 
140         // Discard insignificant whitespace between the start of the paragraph node and the first step in the paragraph
141         stepIterator.setPosition(paragraphElement, 0);
142         stepIterator.roundToNextStep();
143         range.setStart(paragraphElement, 0);
144         range.setEnd(stepIterator.container(), stepIterator.offset());
145         removeTextNodes(range);
146 
147         // Discard insignificant whitespace between the last step in the paragraph and the end of the paragraph node
148         stepIterator.setPosition(paragraphElement, paragraphElement.childNodes.length);
149         stepIterator.roundToPreviousStep();
150         range.setStart(stepIterator.container(), stepIterator.offset());
151         range.setEnd(paragraphElement, paragraphElement.childNodes.length);
152         removeTextNodes(range);
153     }
154 
155     /**
156      * @param {!ops.OdtDocument} odtDocument
157      * @param {!number} steps
158      * @returns {!Element}
159      */
160     function getParagraphAtStep(odtDocument, steps) {
161         var domPoint = odtDocument.convertCursorStepToDomPoint(steps),
162             paragraph = odfUtils.getParagraphElement(domPoint.node, domPoint.offset);
163         runtime.assert(Boolean(paragraph), "Paragraph not found at step " + steps);
164         return /**@type{!Element}*/(paragraph);
165     }
166 
167     /**
168      * @param {!ops.Document} document
169      */
170     this.execute = function (document) {
171         var odtDocument = /**@type{!ops.OdtDocument}*/(document),
172             sourceParagraph,
173             destinationParagraph,
174             cursor = odtDocument.getCursor(memberid),
175             rootNode = odtDocument.getRootNode(),
176             collapseRules = new odf.CollapsingRules(rootNode),
177             stepIterator = odtDocument.createStepIterator(rootNode, 0, [odtDocument.getPositionFilter()], rootNode),
178             downgradeOffset;
179 
180         // Asserting a specific order for destination + source makes it easier to decide which ends to upgrade
181         runtime.assert(destinationStartPosition < sourceStartPosition,
182                         "Destination paragraph (" + destinationStartPosition + ") must be " +
183                         "before source paragraph (" + sourceStartPosition + ")");
184 
185         destinationParagraph = getParagraphAtStep(odtDocument, destinationStartPosition);
186         sourceParagraph = getParagraphAtStep(odtDocument, sourceStartPosition);
187 
188         // Merging is not expected to be able to re-order document content. It is only ever removing a single paragraph
189         // split and merging the content back into the previous paragraph. This helps ensure OT behaviour is straightforward
190         stepIterator.setPosition(sourceParagraph, 0);
191         stepIterator.previousStep();
192         runtime.assert(domUtils.containsNode(destinationParagraph, stepIterator.container()),
193                         "Destination paragraph must be adjacent to the source paragraph");
194 
195         trimInsignificantWhitespace(stepIterator, destinationParagraph);
196         downgradeOffset = destinationParagraph.childNodes.length;
197         trimInsignificantWhitespace(stepIterator, sourceParagraph);
198 
199         mergeParagraphs(destinationParagraph, sourceParagraph);
200         // All children have been migrated, now consume up the source parent chain
201         collapseRules.mergeChildrenIntoParent(sourceParagraph);
202 
203         // Merging removes a single step between the boundary of the two paragraphs
204         odtDocument.emit(ops.OdtDocument.signalStepsRemoved, {position: sourceStartPosition - 1});
205 
206         // Downgrade trailing spaces at the end of the destination paragraph, and the beginning of the source paragraph.
207         // These are the only two places that might need downgrading as a result of the merge.
208         // NB: if the destination paragraph was empty before the merge, this might actually check the
209         // paragraph just prior to the destination. However, as the downgrade also checks 2 steps after the specified
210         // one though, there is no harm caused by this.
211         stepIterator.setPosition(destinationParagraph, downgradeOffset);
212         stepIterator.roundToClosestStep();
213         if (!stepIterator.previousStep()) {
214             // If no previous step is found, round back up to the next available step
215             stepIterator.roundToNextStep();
216         }
217         odtDocument.downgradeWhitespaces(stepIterator);
218 
219         if (paragraphStyleName) {
220             destinationParagraph.setAttributeNS(textns, "text:style-name", paragraphStyleName);
221         } else {
222             destinationParagraph.removeAttributeNS(textns, "style-name");
223         }
224 
225         if (cursor && moveCursor) {
226             odtDocument.moveCursor(memberid, sourceStartPosition - 1, 0);
227             odtDocument.emit(ops.Document.signalCursorMoved, cursor);
228         }
229 
230         odtDocument.fixCursorPositions();
231         odtDocument.getOdfCanvas().refreshSize();
232         // TODO: signal also the deleted paragraphs, so e.g. SessionView can clean up the EditInfo
233         odtDocument.emit(ops.OdtDocument.signalParagraphChanged, {
234             paragraphElement: destinationParagraph,
235             memberId: memberid,
236             timeStamp: timestamp
237         });
238 
239         odtDocument.getOdfCanvas().rerenderAnnotations();
240         return true;
241     };
242 
243     /**
244      * @return {!ops.OpMergeParagraph.Spec}
245      */
246     this.spec = function () {
247         return {
248             optype: "MergeParagraph",
249             memberid: memberid,
250             timestamp: timestamp,
251             moveCursor: moveCursor,
252             paragraphStyleName: paragraphStyleName,
253             sourceStartPosition: sourceStartPosition,
254             destinationStartPosition: destinationStartPosition
255         };
256     };
257 };
258 /**@typedef{{
259     optype:string,
260     memberid:string,
261     timestamp:number,
262     moveCursor: !boolean,
263     paragraphStyleName: !string,
264     sourceStartPosition: !number,
265     destinationStartPosition: !number
266 }}*/
267 ops.OpMergeParagraph.Spec;
268 /**@typedef{{
269     memberid:string,
270     timestamp:(number|undefined),
271     moveCursor: !boolean,
272     paragraphStyleName: !string,
273     sourceStartPosition: !number,
274     destinationStartPosition: !number
275 }}*/
276 ops.OpMergeParagraph.InitSpec;
277