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