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 = odf.OdfUtils,
 49         domUtils = 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      * Returns true if the supplied node is an ODF grouping element with no content
 70      * @param {!Node} element
 71      * @return {!boolean}
 72      */
 73     function isEmptyGroupingElement(element) {
 74         return odfUtils.isGroupingElement(element) && odfUtils.hasNoODFContent(element);
 75     }
 76 
 77     /**
 78      * Merges the source paragraph into the destination paragraph.
 79      * @param {!Element} destination Paragraph to merge content into
 80      * @param {!Element} source Paragraph to merge content from
 81      * @return {undefined}
 82      */
 83     function mergeParagraphs(destination, source) {
 84         var child;
 85 
 86         child = source.firstChild;
 87         while (child) {
 88             if (child.localName === 'editinfo') {
 89                 // TODO It should be the view's responsibility to clean these up. This would fix #431
 90                 source.removeChild(child);
 91             } else {
 92                 destination.appendChild(child);
 93                 // Empty spans need to be cleaned up on merge, as remove text only removes things that contain text content
 94                 // Child is moved across before collapsing so any foreign sub-elements are collapsed up the chain next to
 95                 // the destination location
 96                 domUtils.removeUnwantedNodes(child, isEmptyGroupingElement);
 97             }
 98             child = source.firstChild;
 99         }
100     }
101 
102     /**
103      * Returns true if the specified node is insignificant whitespace
104      * @param {!Node} node
105      * @return {!boolean}
106      */
107     function isInsignificantWhitespace(node) {
108         var textNode,
109             badNodeDescription;
110         if (node.nodeType === Node.TEXT_NODE) {
111             textNode = /**@type{!Text}*/(node);
112             if (textNode.length === 0) {
113                 // This is not a critical issue, but indicates an operation somewhere isn't correctly normalizing text nodes
114                 // after manipulation of the DOM.
115                 runtime.log("WARN: Empty text node found during merge operation");
116                 return true;
117             }
118             if (odfUtils.isODFWhitespace(textNode.data) && odfUtils.isSignificantWhitespace(textNode, 0) === false) {
119                 return true;
120             }
121             badNodeDescription = "#text";
122         } else {
123             badNodeDescription = (node.prefix ? (node.prefix + ":") : "") + node.localName;
124         }
125         runtime.log("WARN: Unexpected text element found near paragraph boundary [" + badNodeDescription + "]");
126         return false;
127     }
128 
129     /**
130      * Remove all the text nodes within the supplied range. These are expected to be insignificant whitespace only.
131      * Assertions will be thrown if this is not the case.
132      *
133      * @param {!Range} range
134      * @return {undefined}
135      */
136     function removeTextNodes(range) {
137         var emptyTextNodes;
138 
139         if (range.collapsed) {
140             return;
141         }
142 
143         domUtils.splitBoundaries(range);
144         // getTextElements may return some unexpected text nodes if the step filters haven't correctly identified the
145         // first/last step in the paragraph. Rather than failing completely in this case, simply log the unexpected
146         // items and skip them.
147         emptyTextNodes = odfUtils.getTextElements(range, false, true).filter(isInsignificantWhitespace);
148         emptyTextNodes.forEach(function(node) {
149             node.parentNode.removeChild(node);
150         });
151     }
152 
153     /**
154      * Discard insignificant whitespace between the start of the paragraph node and the first step in the paragraph
155      *
156      * @param {!core.StepIterator} stepIterator
157      * @param {!Element} paragraphElement
158      * @return {undefined}
159      */
160     function trimLeadingInsignificantWhitespace(stepIterator, paragraphElement) {
161         var range = paragraphElement.ownerDocument.createRange();
162         stepIterator.setPosition(paragraphElement, 0);
163         stepIterator.roundToNextStep();
164         range.setStart(paragraphElement, 0);
165         range.setEnd(stepIterator.container(), stepIterator.offset());
166         removeTextNodes(range);
167     }
168 
169     /**
170      * Discard insignificant whitespace between the last step in the paragraph and the end of the paragraph node
171      *
172      * @param {!core.StepIterator} stepIterator
173      * @param {!Element} paragraphElement
174      * @return {undefined}
175      */
176     function trimTrailingInsignificantWhitespace(stepIterator, paragraphElement) {
177         var range = paragraphElement.ownerDocument.createRange();
178         stepIterator.setPosition(paragraphElement, paragraphElement.childNodes.length);
179         stepIterator.roundToPreviousStep();
180         range.setStart(stepIterator.container(), stepIterator.offset());
181         range.setEnd(paragraphElement, paragraphElement.childNodes.length);
182         removeTextNodes(range);
183     }
184 
185     /**
186      * Fetch the paragraph at the specified step. In addition, if a stepIterator is provided,
187      * set the step iterator position to the exact DOM point of the requested step.
188      *
189      * @param {!ops.OdtDocument} odtDocument
190      * @param {!number} steps
191      * @param {!core.StepIterator=} stepIterator
192      * @return {!Element}
193      */
194     function getParagraphAtStep(odtDocument, steps, stepIterator) {
195         var domPoint = odtDocument.convertCursorStepToDomPoint(steps),
196             paragraph = odfUtils.getParagraphElement(domPoint.node, domPoint.offset);
197         runtime.assert(Boolean(paragraph), "Paragraph not found at step " + steps);
198         if (stepIterator) {
199             stepIterator.setPosition(domPoint.node, domPoint.offset);
200         }
201         return /**@type{!Element}*/(paragraph);
202     }
203 
204     /**
205      * @param {!ops.Document} document
206      * @return {!boolean}
207      */
208     this.execute = function (document) {
209         var odtDocument = /**@type{!ops.OdtDocument}*/(document),
210             sourceParagraph,
211             destinationParagraph,
212             cursor = odtDocument.getCursor(memberid),
213             rootNode = odtDocument.getRootNode(),
214             collapseRules = new odf.CollapsingRules(rootNode),
215             stepIterator = odtDocument.createStepIterator(rootNode, 0, [odtDocument.getPositionFilter()], rootNode),
216             downgradeOffset;
217 
218         // Asserting a specific order for destination + source makes it easier to decide which ends to upgrade
219         runtime.assert(destinationStartPosition < sourceStartPosition,
220                         "Destination paragraph (" + destinationStartPosition + ") must be " +
221                         "before source paragraph (" + sourceStartPosition + ")");
222 
223         destinationParagraph = getParagraphAtStep(odtDocument, destinationStartPosition);
224 
225         // Merging is not expected to be able to re-order document content. It is only ever removing a single paragraph
226         // split and merging the content back into the previous paragraph. This helps ensure OT behaviour is straightforward
227         sourceParagraph = getParagraphAtStep(odtDocument, sourceStartPosition, stepIterator);
228         stepIterator.previousStep();
229         runtime.assert(domUtils.containsNode(destinationParagraph, stepIterator.container()),
230                         "Destination paragraph must be adjacent to the source paragraph");
231 
232         trimTrailingInsignificantWhitespace(stepIterator, destinationParagraph);
233         downgradeOffset = destinationParagraph.childNodes.length;
234         trimLeadingInsignificantWhitespace(stepIterator, sourceParagraph);
235 
236         mergeParagraphs(destinationParagraph, sourceParagraph);
237         // All children have been migrated, now consume up the source parent chain
238         runtime.assert(sourceParagraph.childNodes.length === 0, "Source paragraph should be empty before it is removed");
239         // Merge into parent logic still necessary as the parent may have surrounding containers that collapse
240         // (e.g., is now inside an empty list)
241         collapseRules.mergeChildrenIntoParent(sourceParagraph);
242 
243         // Merging removes a single step between the boundary of the two paragraphs
244         odtDocument.emit(ops.OdtDocument.signalStepsRemoved, {position: sourceStartPosition - 1});
245 
246         // Downgrade trailing spaces at the end of the destination paragraph, and the beginning of the source paragraph.
247         // These are the only two places that might need downgrading as a result of the merge.
248         // NB: if the destination paragraph was empty before the merge, this might actually check the
249         // paragraph just prior to the destination. However, as the downgrade also checks 2 steps after the specified
250         // one though, there is no harm caused by this.
251         stepIterator.setPosition(destinationParagraph, downgradeOffset);
252         stepIterator.roundToClosestStep();
253         if (!stepIterator.previousStep()) {
254             // If no previous step is found, round back up to the next available step
255             stepIterator.roundToNextStep();
256         }
257         odtDocument.downgradeWhitespaces(stepIterator);
258 
259         if (paragraphStyleName) {
260             destinationParagraph.setAttributeNS(textns, "text:style-name", paragraphStyleName);
261         } else {
262             destinationParagraph.removeAttributeNS(textns, "style-name");
263         }
264 
265         if (cursor && moveCursor) {
266             odtDocument.moveCursor(memberid, sourceStartPosition - 1, 0);
267             odtDocument.emit(ops.Document.signalCursorMoved, cursor);
268         }
269 
270         odtDocument.fixCursorPositions();
271         odtDocument.getOdfCanvas().refreshSize();
272         // TODO: signal also the deleted paragraphs, so e.g. SessionView can clean up the EditInfo
273         odtDocument.emit(ops.OdtDocument.signalParagraphChanged, {
274             paragraphElement: destinationParagraph,
275             memberId: memberid,
276             timeStamp: timestamp
277         });
278 
279         odtDocument.getOdfCanvas().rerenderAnnotations();
280         return true;
281     };
282 
283     /**
284      * @return {!ops.OpMergeParagraph.Spec}
285      */
286     this.spec = function () {
287         return {
288             optype: "MergeParagraph",
289             memberid: memberid,
290             timestamp: timestamp,
291             moveCursor: moveCursor,
292             paragraphStyleName: paragraphStyleName,
293             sourceStartPosition: sourceStartPosition,
294             destinationStartPosition: destinationStartPosition
295         };
296     };
297 };
298 /**@typedef{{
299     optype:string,
300     memberid:string,
301     timestamp:number,
302     moveCursor: !boolean,
303     paragraphStyleName: !string,
304     sourceStartPosition: !number,
305     destinationStartPosition: !number
306 }}*/
307 ops.OpMergeParagraph.Spec;
308 /**@typedef{{
309     memberid:string,
310     timestamp:(number|undefined),
311     moveCursor: !boolean,
312     paragraphStyleName: !string,
313     sourceStartPosition: !number,
314     destinationStartPosition: !number
315 }}*/
316 ops.OpMergeParagraph.InitSpec;
317