1 /**
  2  * Copyright (C) 2012 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, gui, odf, ops, runtime, Node*/
 26 
 27 /**
 28  * Class that represents a caret in a document.
 29  * The caret is implemented by the left border of a span positioned absolutely
 30  * to the cursor element, with a width of 0 px and a height of 1em (CSS rules).
 31  * Blinking is done by switching the color of the border from transparent to
 32  * the member color and back.
 33  * @constructor
 34  * @implements {core.Destroyable}
 35  * @param {!ops.OdtCursor} cursor
 36  * @param {boolean} avatarInitiallyVisible Sets the initial visibility of the caret's avatar
 37  * @param {boolean} blinkOnRangeSelect Specify that the caret should blink if a non-collapsed range is selected
 38  */
 39 gui.Caret = function Caret(cursor, avatarInitiallyVisible, blinkOnRangeSelect) {
 40     "use strict";
 41     var /**@const*/
 42         cursorns = 'urn:webodf:names:cursor',
 43         /**@const*/
 44         MIN_OVERLAY_HEIGHT_PX = 8, /** 8px = 6pt font size */
 45         /**@const*/
 46         BLINK_PERIOD_MS = 500,
 47         /**@type{!HTMLElement}*/
 48         caretOverlay,
 49         /**@type{!HTMLElement}*/
 50         caretElement,
 51         /**@type{!gui.Avatar}*/
 52         avatar,
 53         /**@type{?Element}*/
 54         overlayElement,
 55         /**@type{!Element}*/
 56         caretSizer,
 57         /**@type{!Range}*/
 58         caretSizerRange,
 59         canvas = cursor.getDocument().getCanvas(),
 60         domUtils = new core.DomUtils(),
 61         guiStepUtils = new gui.GuiStepUtils(),
 62         /**@type{!core.StepIterator}*/
 63         stepIterator,
 64         /**@type{!core.ScheduledTask}*/
 65         redrawTask,
 66         /**@type{!core.ScheduledTask}*/
 67         blinkTask,
 68         /**@type{boolean}*/
 69         shouldResetBlink = false,
 70         /**@type{boolean}*/
 71         shouldCheckCaretVisibility = false,
 72         /**@type{boolean}*/
 73         shouldUpdateCaretSize = false,
 74         /**@type{!{isFocused:boolean,isShown:boolean,visibility:string}}*/
 75         state = {
 76             isFocused: false,
 77             isShown: true,
 78             visibility: "hidden"
 79         },
 80         /**@type{!{isFocused:boolean,isShown:boolean,visibility:string}}*/
 81         lastState = {
 82             isFocused: !state.isFocused,
 83             isShown: !state.isShown,
 84             visibility: "hidden"
 85         };
 86 
 87     /**
 88      * @return {undefined}
 89      */
 90     function blinkCaret() {
 91         // switch between transparent and color
 92         caretElement.style.opacity = caretElement.style.opacity === "0" ? "1" : "0";
 93         blinkTask.trigger(); // Trigger next blink to occur in BLINK_PERIOD_MS
 94     }
 95 
 96     /**
 97      * Calculates the bounding client rect of the caret element,
 98      * expanded with a specific margin
 99      * @param {!Element} caretElement
100      * @param {!{left:!number,top:!number,right:!number,bottom:!number}} margin
101      * @return {!{left:!number,top:!number,right:!number,bottom:!number}}
102      */
103     function getCaretClientRectWithMargin(caretElement, margin) {
104         var caretRect = caretElement.getBoundingClientRect();
105 
106         return {
107             left:   caretRect.left - margin.left,
108             top:    caretRect.top - margin.top,
109             right:  caretRect.right + margin.right,
110             bottom: caretRect.bottom + margin.bottom
111         };
112     }
113 
114     /**
115      * @return {?ClientRect}
116      */
117     function getCaretSizeFromCursor() {
118         // The node itself has a slightly different BCR to a range created around it's contents.
119         // Am not quite sure why, and the inspector gives no clues.
120         caretSizerRange.selectNodeContents(caretSizer);
121         return caretSizerRange.getBoundingClientRect();
122     }
123 
124     /**
125      * Get the client rectangle for the nearest selection point to the caret.
126      * This works on the assumption that the next or previous sibling is likely to
127      * be a text node that will provide an accurate rectangle for the caret's desired
128      * position. The horizontal position of the caret is specified in the "right" property
129      * as a caret generally appears to the right of the character or object is represents.
130      *
131      * @return {!{height: !number, top: !number, right: !number}}
132      */
133     function getSelectionRect() {
134         var node = cursor.getNode(),
135             caretRectangle,
136             nextRectangle,
137             selectionRectangle,
138             rootRect = /**@type{!ClientRect}*/(domUtils.getBoundingClientRect(canvas.getSizer())),
139             useLeftEdge = false;
140 
141         if (node.getClientRects().length > 0) {
142             // If the cursor is visible, use that as the caret location.
143             // The most common reason for the cursor to be visible is because the user is entering some text
144             // via an IME, or no nearby rect was discovered and cursor was forced visible for caret rect calculations
145             // (see below when the show-caret attribute is set).
146             selectionRectangle = getCaretSizeFromCursor();
147             useLeftEdge = true;
148         } else {
149             // Need to resync the stepIterator prior to every use as it isn't automatically kept up-to-date
150             // with the cursor's actual document position
151             stepIterator.setPosition(node, 0);
152             selectionRectangle = guiStepUtils.getContentRect(stepIterator);
153             if (!selectionRectangle && stepIterator.nextStep()) {
154                 // Under some circumstances (either no associated content, or whitespace wrapping) the client rect of the
155                 // next sibling will actually be a more accurate visual representation of the caret's position.
156                 nextRectangle = guiStepUtils.getContentRect(stepIterator);
157                 if (nextRectangle) {
158                     selectionRectangle = nextRectangle;
159                     useLeftEdge = true;
160                 }
161             }
162 
163             if (!selectionRectangle) {
164                 // Handle the case where there are no nearby visible rects from which to determine the caret position.
165                 // Generally, making the cursor visible will cause word-wrapping and other undesirable features
166                 // if near an area the end of a wrapped line (e.g., #86).
167                 // However, as the previous checks have ascertained, there are no text nodes nearby, hence, making the
168                 // cursor visible won't change any wrapping.
169                 node.setAttributeNS(cursorns, "caret-sizer-active", "true");
170                 selectionRectangle = getCaretSizeFromCursor();
171                 useLeftEdge = true;
172             }
173 
174             if (!selectionRectangle) {
175                 // Finally, if there is still no selection rectangle, crawl up the DOM hierarchy the cursor node is in
176                 // and try and find something visible to use. Less ideal than actually having a visible rect... better than
177                 // crashing or hiding the caret entirely though :)
178 
179                 runtime.log("WARN: No suitable client rectangle found for visual caret for " + cursor.getMemberId());
180                 // TODO are the better fallbacks than this?
181                 while (node) {
182                     if (/**@type{!Element}*/(node).getClientRects().length > 0) {
183                         selectionRectangle = domUtils.getBoundingClientRect(node);
184                         useLeftEdge = true;
185                         break;
186                     }
187                     node = node.parentNode;
188                 }
189             }
190         }
191 
192         selectionRectangle = domUtils.translateRect(/**@type{!ClientRect}*/(selectionRectangle), rootRect, canvas.getZoomLevel());
193         caretRectangle = {
194             top: selectionRectangle.top,
195             height: selectionRectangle.height,
196             right: useLeftEdge ? selectionRectangle.left : selectionRectangle.right
197         };
198         return caretRectangle;
199     }
200 
201     /**
202      * Tweak the height and top offset of the caret to display closely inline in
203      * the text block.
204      * This uses ranges to account for line-height and text offsets.
205      *
206      * This adjustment is necessary as various combinations of fonts and line
207      * sizes otherwise cause the caret to appear above or below the natural line
208      * of the text.
209      * Fonts known to cause this problem:
210      * - STIXGeneral (MacOS, Chrome & Safari)
211      * @return {undefined}
212      */
213     function updateOverlayHeightAndPosition() {
214         var selectionRect = getSelectionRect(),
215             cursorStyle;
216 
217         if (selectionRect.height < MIN_OVERLAY_HEIGHT_PX) {
218             // ClientRect's are read-only, so a whole new object is necessary to modify these values
219             selectionRect = {
220                 top: selectionRect.top - ((MIN_OVERLAY_HEIGHT_PX - selectionRect.height) / 2),
221                 height: MIN_OVERLAY_HEIGHT_PX,
222                 right: selectionRect.right
223             };
224         }
225         caretOverlay.style.height = selectionRect.height + "px";
226         caretOverlay.style.top = selectionRect.top + "px";
227         caretOverlay.style.left = selectionRect.right + "px";
228 
229         // Update the overlay element
230         if (overlayElement) {
231             cursorStyle = runtime.getWindow().getComputedStyle(cursor.getNode(), null);
232             if (cursorStyle.font) {
233                 overlayElement.style.font = cursorStyle.font;
234             } else {
235                 // On IE, window.getComputedStyle(element).font returns "".
236                 // Therefore we need to individually set the font properties.
237                 overlayElement.style.fontStyle = cursorStyle.fontStyle;
238                 overlayElement.style.fontVariant = cursorStyle.fontVariant;
239                 overlayElement.style.fontWeight = cursorStyle.fontWeight;
240                 overlayElement.style.fontSize = cursorStyle.fontSize;
241                 overlayElement.style.lineHeight = cursorStyle.lineHeight;
242                 overlayElement.style.fontFamily = cursorStyle.fontFamily;
243             }
244         }
245     }
246 
247     /**
248      * Checks whether the caret is currently in view. If the caret is not on screen,
249      * this will scroll the caret into view.
250      * @return {undefined}
251      */
252     function ensureVisible() {
253         var canvasElement = cursor.getDocument().getCanvas().getElement(),
254             canvasContainerElement = /**@type{!HTMLElement}*/(canvasElement.parentNode),
255             caretRect,
256             canvasContainerRect,
257         // margin around the caret when calculating the visibility,
258         // to have the caret not stick directly to the containing border
259         // size in pixels, and also to avoid it hiding below scrollbars.
260         // The scrollbar width is in most cases the offsetWidth - clientWidth.
261         // We assume a 5px distance from the boundary is A Good Thing.
262             horizontalMargin = canvasContainerElement.offsetWidth - canvasContainerElement.clientWidth + 5,
263             verticalMargin = canvasContainerElement.offsetHeight - canvasContainerElement.clientHeight + 5;
264 
265         // The visible part of the canvas is set by changing the
266         // scrollLeft/scrollTop properties of the containing element
267         // accordingly. Both are 0 if the canvas top-left corner is exactly
268         // in the top-left corner of the container.
269         // To find out the proper values for them. these other values are needed:
270         // * position of the caret inside the canvas
271         // * size of the caret
272         // * size of the canvas
273 
274         caretRect = getCaretClientRectWithMargin(caretElement, {
275             top: verticalMargin,
276             left: horizontalMargin,
277             bottom: verticalMargin,
278             right: horizontalMargin
279         });
280         canvasContainerRect = canvasContainerElement.getBoundingClientRect();
281 
282         // Vertical adjustment
283         if (caretRect.top < canvasContainerRect.top) {
284             canvasContainerElement.scrollTop -= canvasContainerRect.top - caretRect.top;
285         } else if (caretRect.bottom > canvasContainerRect.bottom) {
286             canvasContainerElement.scrollTop += caretRect.bottom - canvasContainerRect.bottom;
287         }
288 
289         // Horizontal adjustment
290         if (caretRect.left < canvasContainerRect.left) {
291             canvasContainerElement.scrollLeft -= canvasContainerRect.left - caretRect.left;
292         } else if (caretRect.right > canvasContainerRect.right) {
293             canvasContainerElement.scrollLeft += caretRect.right - canvasContainerRect.right;
294         }
295     }
296 
297     /**
298      * Returns true if the requested property is different between the last state
299      * and the current state
300      * @param {!string} property
301      * @return {!boolean}
302      */
303     function hasStateChanged(property) {
304         return lastState[property] !== state[property];
305     }
306 
307     /**
308      * Update all properties in the last state to match the current state
309      * @return {undefined}
310      */
311     function saveState() {
312         Object.keys(state).forEach(function (key) {
313             lastState[key] = state[key];
314         });
315     }
316 
317     /**
318      * Synchronize the requested caret state & visible state
319      * @return {undefined}
320      */
321     function updateCaret() {
322         if (state.isShown === false || cursor.getSelectionType() !== ops.OdtCursor.RangeSelection
323                 || (!blinkOnRangeSelect && !cursor.getSelectedRange().collapsed)) {
324             // Hide the caret entirely if:
325             // - the caret is deliberately hidden (e.g., the parent window has lost focus)
326             // - the selection is not a range selection (e.g., an image has been selected)
327             // - the blinkOnRangeSelect is false and the cursor has a non-collapsed range
328             state.visibility = "hidden";
329             caretElement.style.visibility = "hidden";
330             blinkTask.cancel();
331         } else {
332             // For all other cases, the caret is expected to be visible and either static (isFocused = false), or blinking
333             state.visibility = "visible";
334             caretElement.style.visibility = "visible";
335 
336             if (state.isFocused === false) {
337                 caretElement.style.opacity = "1";
338                 blinkTask.cancel();
339             } else {
340                 if (shouldResetBlink || hasStateChanged("visibility")) {
341                     // If the caret has just become visible, reset the opacity so it is immediately shown
342                     caretElement.style.opacity = "1";
343                     // Cancel any existing blink instructions to ensure the opacity is not toggled for BLINK_PERIOD_MS
344                     // It will immediately be rescheduled below so blinking resumes
345                     blinkTask.cancel();
346                 }
347                 // Set the caret blinking. If the caret was already visible and already blinking,
348                 // this call will have no effect.
349                 blinkTask.trigger();
350             }
351 
352             if (shouldUpdateCaretSize || shouldCheckCaretVisibility || hasStateChanged("visibility")) {
353                 // Ensure the caret height and position are correct if the caret has just become visible,
354                 // or is just about to be scrolled into view. This is necessary because client rectangles
355                 // are not reported when an element is hidden, so the caret size is likely to be out of date
356                 // when it is drawn
357                 updateOverlayHeightAndPosition();
358             }
359 
360             if (shouldCheckCaretVisibility) {
361                 ensureVisible();
362             }
363         }
364 
365         if (hasStateChanged("isFocused")) {
366             // Currently, setting the focus state on the avatar whilst the caret is hidden is harmless
367             avatar.markAsFocussed(state.isFocused);
368         }
369         saveState();
370 
371         // Always reset all requested updates after a render. All requests should be ignored while the caret
372         // is hidden, and should not be queued up for later. This prevents unexpected behaviours when re-showing
373         // the caret (e.g., suddenly scrolling the caret into view at an undesirable time later just because
374         // it becomes visible).
375         shouldResetBlink = false;
376         shouldCheckCaretVisibility = false;
377         shouldUpdateCaretSize = false;
378     }
379 
380     /**
381      * Recalculate the caret size and position (but don't scroll into view)
382      * @return {undefined}
383      */
384     this.handleUpdate = function() {
385         shouldUpdateCaretSize = true;
386         if (state.visibility !== "hidden") {
387             // There are significant performance costs with calculating the caret size, so still
388             // want to avoid computing this until all ops have been performed.
389             // However, if the caret size is wildly incorrect for it's new position after an update
390             // (e.g., caret moving from beside an image to beside text), the caret will be user visible
391             // before the render occurs, and results in a large caret momentarily flashing before shrinking
392             // to an appropriate size.
393             // To prevent this flicker, we hide the caret until it is redrawn, as an absent caret is far less
394             // noticeable than an oversized one.
395             state.visibility = "hidden";
396             caretElement.style.visibility = "hidden";
397 
398             // Remove the cursor visibility override. This prevents word-wrapping from occurring
399             // as a result of the cursor being incorrectly visible. Will be re-shown if necessary
400             // when the caret is rendered
401             cursor.getNode().removeAttributeNS(cursorns, "caret-sizer-active");
402         }
403         redrawTask.trigger();
404     };
405 
406     /**
407      * @return {undefined}
408      */
409     this.refreshCursorBlinking = function(){
410         shouldResetBlink = true;
411         redrawTask.trigger();
412     };
413 
414     /**
415      * @return {undefined}
416      */
417     this.setFocus = function () {
418         state.isFocused = true;
419         redrawTask.trigger();
420     };
421     /**
422      * @return {undefined}
423      */
424     this.removeFocus = function () {
425         state.isFocused = false;
426         redrawTask.trigger();
427     };
428     /**
429      * @return {undefined}
430      */
431     this.show = function () {
432         state.isShown = true;
433         redrawTask.trigger();
434     };
435     /**
436      * @return {undefined}
437      */
438     this.hide = function () {
439         state.isShown = false;
440         redrawTask.trigger();
441     };
442     /**
443      * @param {string} url
444      * @return {undefined}
445      */
446     this.setAvatarImageUrl = function (url) {
447         avatar.setImageUrl(url);
448     };
449     /**
450      * @param {string} newColor
451      * @return {undefined}
452      */
453     this.setColor = function (newColor) {
454         caretElement.style.borderColor = newColor;
455         avatar.setColor(newColor);
456     };
457     /**
458      * @return {!ops.OdtCursor}}
459      */
460     this.getCursor = function () {
461         return cursor;
462     };
463     /**
464      * @return {!Element}
465      */
466     this.getFocusElement = function () {
467         return caretElement;
468     };
469     /**
470      * @return {undefined}
471      */
472     this.toggleHandleVisibility = function () {
473         if (avatar.isVisible()) {
474             avatar.hide();
475         } else {
476             avatar.show();
477         }
478     };
479     /**
480      * @return {undefined}
481      */
482     this.showHandle = function () {
483         avatar.show();
484     };
485     /**
486      * @return {undefined}
487      */
488     this.hideHandle = function () {
489         avatar.hide();
490     };
491 
492     /**
493      * @param {!Element} element
494      * @return {undefined}
495      */
496     this.setOverlayElement  = function (element) {
497         overlayElement = element;
498         caretOverlay.appendChild(element);
499         shouldUpdateCaretSize = true;
500         redrawTask.trigger();
501     };
502 
503     /**
504      * Scrolls the view on the canvas in such a way that the caret is
505      * completely visible, with a small margin around.
506      * The view on the canvas is only scrolled as much as needed.
507      * If the caret is already visible nothing will happen.
508      * @return {undefined}
509      */
510     this.ensureVisible = function() {
511         shouldCheckCaretVisibility = true;
512         redrawTask.trigger();
513     };
514 
515     /**
516      * @param {!function(!Object=)} callback
517      * @return {undefined}
518      */
519     function destroy(callback) {
520         caretOverlay.parentNode.removeChild(caretOverlay);
521         caretSizer.parentNode.removeChild(caretSizer);
522         callback();
523     }
524 
525     /**
526      * @param {!function(!Error=)} callback Callback to call when the destroy is complete, passing an error object in case of error
527      * @return {undefined}
528      */
529     this.destroy = function (callback) {
530         var cleanup = [redrawTask.destroy, blinkTask.destroy, avatar.destroy, destroy];
531         core.Async.destroyAll(cleanup, callback);
532     };
533 
534     function init() {
535         var odtDocument = /**@type{!ops.OdtDocument}*/(cursor.getDocument()),
536             positionFilters = [odtDocument.createRootFilter(cursor.getMemberId()), odtDocument.getPositionFilter()],
537             dom = odtDocument.getDOMDocument(),
538             editinfons = "urn:webodf:names:editinfo";
539 
540         caretSizerRange = /**@type{!Range}*/(dom.createRange());
541 
542         caretSizer = dom.createElement("span");
543         caretSizer.className = "webodf-caretSizer";
544         caretSizer.textContent = "|";
545         cursor.getNode().appendChild(caretSizer);
546 
547         caretOverlay = /**@type{!HTMLElement}*/(dom.createElement("div"));
548         caretOverlay.setAttributeNS(editinfons, "editinfo:memberid", cursor.getMemberId());
549         caretOverlay.className = "webodf-caretOverlay";
550 
551         caretElement = /**@type{!HTMLElement}*/(dom.createElement("div"));
552         caretElement.className = "caret";
553         caretOverlay.appendChild(caretElement);
554 
555         avatar = new gui.Avatar(caretOverlay, avatarInitiallyVisible);
556 
557         canvas.getSizer().appendChild(caretOverlay);
558 
559         stepIterator = odtDocument.createStepIterator(cursor.getNode(), 0, positionFilters, odtDocument.getRootNode());
560 
561         redrawTask = core.Task.createRedrawTask(updateCaret);
562         blinkTask = core.Task.createTimeoutTask(blinkCaret, BLINK_PERIOD_MS);
563         redrawTask.triggerImmediate();
564     }
565     init();
566 };
567