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