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