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