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