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