1 /** 2 * Copyright (C) 2013 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 core, ops, gui, odf, runtime*/ 26 27 /** 28 * @constructor 29 * @implements {core.Destroyable} 30 * @param {!ops.Session} session 31 * @param {!gui.SessionConstraints} sessionConstraints 32 * @param {!gui.SessionContext} sessionContext 33 * @param {!string} inputMemberId 34 * @param {function(!number, !number, !boolean):ops.Operation} directStyleOp 35 * @param {function(!number):!Array.<!ops.Operation>} paragraphStyleOps 36 */ 37 gui.TextController = function TextController( 38 session, 39 sessionConstraints, 40 sessionContext, 41 inputMemberId, 42 directStyleOp, 43 paragraphStyleOps 44 ) { 45 "use strict"; 46 47 var odtDocument = session.getOdtDocument(), 48 odfUtils = odf.OdfUtils, 49 domUtils = core.DomUtils, 50 /** 51 * @const 52 * @type {!boolean} 53 */ 54 BACKWARD = false, 55 /** 56 * @const 57 * @type {!boolean} 58 */ 59 FORWARD = true, 60 isEnabled = false, 61 /** @const */ 62 textns = odf.Namespaces.textns, 63 /**@const*/ 64 NEXT = core.StepDirection.NEXT; 65 66 /** 67 * @return {undefined} 68 */ 69 function updateEnabledState() { 70 if (sessionConstraints.getState(gui.CommonConstraints.EDIT.REVIEW_MODE) === true) { 71 isEnabled = /**@type{!boolean}*/(sessionContext.isLocalCursorWithinOwnAnnotation()); 72 } else { 73 isEnabled = true; 74 } 75 } 76 77 /** 78 * @param {!ops.OdtCursor} cursor 79 * @return {undefined} 80 */ 81 function onCursorEvent(cursor) { 82 if (cursor.getMemberId() === inputMemberId) { 83 updateEnabledState(); 84 } 85 } 86 87 /** 88 * @return {!boolean} 89 */ 90 this.isEnabled = function () { 91 return isEnabled; 92 }; 93 94 /** 95 * Return the equivalent cursor range of the specified DOM range. 96 * This is found by rounding the range's start and end DOM points to the closest step as defined by the document's 97 * position filter (and optionally the root filter as well). 98 * 99 * @param {!Range} range Range to convert to an equivalent cursor selection 100 * @param {!Element} subTree Subtree to limit step searches within. E.g., limit to steps within a certain paragraph. 101 * @param {!boolean} withRootFilter Specify true to restrict steps to be within the same root as the range's 102 * start container. 103 * @return {!{position: !number, length: !number}} 104 */ 105 function domToCursorRange(range, subTree, withRootFilter) { 106 var filters = [odtDocument.getPositionFilter()], 107 startStep, 108 endStep, 109 stepIterator; 110 111 if (withRootFilter) { 112 filters.push(odtDocument.createRootFilter(/**@type{!Node}*/(range.startContainer))); 113 } 114 115 stepIterator = odtDocument.createStepIterator(/**@type{!Node}*/(range.startContainer), range.startOffset, 116 filters, subTree); 117 if (!stepIterator.roundToClosestStep()) { 118 runtime.assert(false, "No walkable step found in paragraph element at range start"); 119 } 120 startStep = odtDocument.convertDomPointToCursorStep(stepIterator.container(), stepIterator.offset()); 121 122 if (range.collapsed) { 123 endStep = startStep; 124 } else { 125 stepIterator.setPosition(/**@type{!Node}*/(range.endContainer), range.endOffset); 126 if (!stepIterator.roundToClosestStep()) { 127 runtime.assert(false, "No walkable step found in paragraph element at range end"); 128 } 129 endStep = odtDocument.convertDomPointToCursorStep(stepIterator.container(), stepIterator.offset()); 130 } 131 return { 132 position: /**@type{!number}*/(startStep), 133 length: /**@type{!number}*/(endStep - startStep) 134 }; 135 } 136 137 /** 138 * Creates operations to remove the provided selection and update the destination 139 * paragraph's style if necessary. 140 * @param {!Range} range 141 * @return {!Array.<!ops.Operation>} 142 */ 143 function createRemoveSelectionOps(range) { 144 var firstParagraph, 145 lastParagraph, 146 mergedParagraphStyleName, 147 previousParagraphStart, 148 paragraphs = odfUtils.getParagraphElements(range), 149 paragraphRange = /**@type{!Range}*/(range.cloneRange()), 150 operations = []; 151 152 // If the removal range spans several paragraphs, decide the final paragraph's style name. 153 firstParagraph = paragraphs[0]; 154 if (paragraphs.length > 1) { 155 if (odfUtils.hasNoODFContent(firstParagraph)) { 156 // If the first paragraph is empty, the last paragraph's style wins, otherwise the first wins. 157 lastParagraph = paragraphs[paragraphs.length - 1]; 158 mergedParagraphStyleName = lastParagraph.getAttributeNS(odf.Namespaces.textns, 'style-name') || ""; 159 160 // Side note: 161 // According to https://developer.mozilla.org/en-US/docs/Web/API/element.getAttributeNS, if there is no 162 // explicitly defined style, getAttributeNS might return either "" or null or undefined depending on the 163 // implementation. Simplify the operation by combining all these cases to be "" 164 } else { 165 mergedParagraphStyleName = firstParagraph.getAttributeNS(odf.Namespaces.textns, 'style-name') || ""; 166 } 167 } 168 169 // Note, the operations are built up in reverse order to the paragraph DOM order. This prevents the need for 170 // any translation of paragraph start limits as the last paragraph will be removed and merged first 171 paragraphs.forEach(function(paragraph, index) { 172 var paragraphStart, 173 removeLimits, 174 intersectionRange, 175 removeOp, 176 mergeOp; 177 178 paragraphRange.setStart(paragraph, 0); 179 paragraphRange.collapse(true); 180 paragraphStart = domToCursorRange(paragraphRange, paragraph, false).position; 181 if (index > 0) { 182 mergeOp = new ops.OpMergeParagraph(); 183 mergeOp.init({ 184 memberid: inputMemberId, 185 paragraphStyleName: mergedParagraphStyleName, 186 destinationStartPosition: previousParagraphStart, 187 sourceStartPosition: paragraphStart, 188 // For perf reasons, only the very last merge paragraph op should move the cursor 189 moveCursor: index === 1 190 }); 191 operations.unshift(mergeOp); 192 } 193 previousParagraphStart = paragraphStart; 194 195 paragraphRange.selectNodeContents(paragraph); 196 // The paragraph limits will differ from the text remove limits if either 197 // 1. the remove range starts within an different inline root such as within an annotation 198 // 2. the remove range doesn't cover the entire paragraph (i.e., it starts or ends within the paragraph) 199 intersectionRange = domUtils.rangeIntersection(paragraphRange, range); 200 if (intersectionRange) { 201 removeLimits = domToCursorRange(intersectionRange, paragraph, true); 202 203 if (removeLimits.length > 0) { 204 removeOp = new ops.OpRemoveText(); 205 removeOp.init({ 206 memberid: inputMemberId, 207 position: removeLimits.position, 208 length: removeLimits.length 209 }); 210 operations.unshift(removeOp); 211 } 212 } 213 }); 214 215 return operations; 216 } 217 218 /** 219 * Ensures the provided selection is a "forward" selection (i.e., length is positive) 220 * @param {!{position: number, length: number}} selection 221 * @return {!{position: number, length: number}} 222 */ 223 function toForwardSelection(selection) { 224 if (selection.length < 0) { 225 selection.position += selection.length; 226 selection.length = -selection.length; 227 } 228 return selection; 229 } 230 231 /** 232 * Insert a paragraph break at the current cursor location. Will remove any currently selected text first 233 * @return {!boolean} 234 */ 235 this.enqueueParagraphSplittingOps = function() { 236 if (!isEnabled) { 237 return false; 238 } 239 240 var cursor = odtDocument.getCursor(inputMemberId), 241 range = cursor.getSelectedRange(), 242 selection = toForwardSelection(odtDocument.getCursorSelection(inputMemberId)), 243 op, 244 operations = [], 245 styleOps, 246 originalParagraph = /**@type{!Element}*/(odfUtils.getParagraphElement(cursor.getNode())), 247 paragraphStyle = originalParagraph.getAttributeNS(textns, "style-name") || ""; 248 249 if (selection.length > 0) { 250 operations = operations.concat(createRemoveSelectionOps(range)); 251 } 252 253 op = new ops.OpSplitParagraph(); 254 op.init({ 255 memberid: inputMemberId, 256 position: selection.position, 257 paragraphStyleName: paragraphStyle, 258 sourceParagraphPosition: odtDocument.convertDomPointToCursorStep(originalParagraph, 0, NEXT), 259 moveCursor: true 260 }); 261 operations.push(op); 262 263 // disabled for now, because nowjs seems to revert the order of the ops, which does not work here TODO: grouping of ops 264 /* 265 if (isAtEndOfParagraph) { 266 paragraphNode = odfUtils.getParagraphElement(odtDocument.getCursor(inputMemberId).getNode()); 267 nextStyleName = odtDocument.getFormatting().getParagraphStyleAttribute(styleName, odf.Namespaces.stylens, 'next-style-name'); 268 269 if (nextStyleName && nextStyleName !== styleName) { 270 op = new ops.OpSetParagraphStyle(); 271 op.init({ 272 memberid: inputMemberId, 273 position: position + 1, // +1 should be at the start of the new paragraph 274 styleName: nextStyleName 275 }); 276 operations.push(op); 277 } 278 } 279 */ 280 281 if (paragraphStyleOps) { 282 styleOps = paragraphStyleOps(selection.position + 1); 283 operations = operations.concat(styleOps); 284 } 285 session.enqueue(operations); 286 return true; 287 }; 288 289 /** 290 * Checks if there are any walkable positions in the specified direction within 291 * the current root, starting at the specified node. 292 * The iterator is constrained within the root element for the current cursor position so 293 * iteration will stop once the root is entirely walked in the requested direction 294 * @param {!Element} cursorNode 295 * @return {!core.StepIterator} 296 */ 297 function createStepIterator(cursorNode) { 298 var cursorRoot = odtDocument.getRootElement(cursorNode), 299 filters = [odtDocument.getPositionFilter(), odtDocument.createRootFilter(cursorRoot)]; 300 301 return odtDocument.createStepIterator(cursorNode, 0, filters, cursorRoot); 302 } 303 304 /** 305 * Remove the current selection, or if the cursor is collapsed, remove the next step 306 * in the specified direction. 307 * 308 * @param {!boolean} isForward True indicates delete the next step. False indicates delete the previous step 309 * @return {!boolean} 310 */ 311 function removeTextInDirection(isForward) { 312 if (!isEnabled) { 313 return false; 314 } 315 316 var cursorNode, 317 // Take a clone of the range as it will be modified if the selection length is 0 318 range = /**@type{!Range}*/(odtDocument.getCursor(inputMemberId).getSelectedRange().cloneRange()), 319 selection = toForwardSelection(odtDocument.getCursorSelection(inputMemberId)), 320 stepIterator; 321 322 if (selection.length === 0) { 323 selection = undefined; 324 cursorNode = odtDocument.getCursor(inputMemberId).getNode(); 325 stepIterator = createStepIterator(cursorNode); 326 // There must be at least one more step in the root same root as the cursor node 327 // in order to do something if there is no selected text 328 // TODO Superstition alert - Step rounding is probably not necessary as cursor should always be at a step 329 if (stepIterator.roundToClosestStep() 330 && (isForward ? stepIterator.nextStep() : stepIterator.previousStep())) { 331 selection = toForwardSelection(odtDocument.convertDomToCursorRange({ 332 anchorNode: cursorNode, 333 anchorOffset: 0, 334 focusNode: stepIterator.container(), 335 focusOffset: stepIterator.offset() 336 })); 337 if (isForward) { 338 range.setStart(cursorNode, 0); 339 range.setEnd(stepIterator.container(), stepIterator.offset()); 340 } else { 341 range.setStart(stepIterator.container(), stepIterator.offset()); 342 range.setEnd(cursorNode, 0); 343 } 344 } 345 } 346 if (selection) { 347 session.enqueue(createRemoveSelectionOps(range)); 348 } 349 return selection !== undefined; 350 } 351 352 /** 353 * Removes the currently selected content. If no content is selected and there is at least 354 * one character to the left of the current selection, that character will be removed instead. 355 * @return {!boolean} 356 */ 357 this.removeTextByBackspaceKey = function () { 358 return removeTextInDirection(BACKWARD); 359 }; 360 361 /** 362 * Removes the currently selected content. If no content is selected and there is at least 363 * one character to the right of the current selection, that character will be removed instead. 364 * @return {!boolean} 365 */ 366 this.removeTextByDeleteKey = function () { 367 return removeTextInDirection(FORWARD); 368 }; 369 370 /** 371 * Removes the currently selected content 372 * @return {!boolean} 373 */ 374 this.removeCurrentSelection = function () { 375 if (!isEnabled) { 376 return false; 377 } 378 379 var range = odtDocument.getCursor(inputMemberId).getSelectedRange(); 380 session.enqueue(createRemoveSelectionOps(range)); 381 return true; // The function is always considered handled, even if nothing is removed 382 }; 383 384 /** 385 * Removes currently selected text (if any) before inserting the supplied text. 386 * @param {!string} text 387 * @return {undefined} 388 */ 389 function insertText(text) { 390 if (!isEnabled) { 391 return; 392 } 393 394 var range = odtDocument.getCursor(inputMemberId).getSelectedRange(), 395 selection = toForwardSelection(odtDocument.getCursorSelection(inputMemberId)), 396 op, stylingOp, operations = [], useCachedStyle = false; 397 398 if (selection.length > 0) { 399 operations = operations.concat(createRemoveSelectionOps(range)); 400 useCachedStyle = true; 401 } 402 403 op = new ops.OpInsertText(); 404 op.init({ 405 memberid: inputMemberId, 406 position: selection.position, 407 text: text, 408 moveCursor: true 409 }); 410 operations.push(op); 411 if (directStyleOp) { 412 stylingOp = directStyleOp(selection.position, text.length, useCachedStyle); 413 if (stylingOp) { 414 operations.push(stylingOp); 415 } 416 } 417 session.enqueue(operations); 418 } 419 this.insertText = insertText; 420 421 /** 422 * @param {!function(!Error=)} callback, passing an error object in case of error 423 * @return {undefined} 424 */ 425 this.destroy = function (callback) { 426 odtDocument.unsubscribe(ops.Document.signalCursorMoved, onCursorEvent); 427 sessionConstraints.unsubscribe(gui.CommonConstraints.EDIT.REVIEW_MODE, updateEnabledState); 428 callback(); 429 }; 430 431 function init() { 432 odtDocument.subscribe(ops.Document.signalCursorMoved, onCursorEvent); 433 sessionConstraints.subscribe(gui.CommonConstraints.EDIT.REVIEW_MODE, updateEnabledState); 434 updateEnabledState(); 435 } 436 init(); 437 }; 438