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