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