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