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