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 Node, NodeFilter, gui, odf, ops, runtime, core*/ 26 27 /** 28 * A GUI class that attaches to a cursor and renders it's selection 29 * as an SVG polygon. 30 * @constructor 31 * @implements {core.Destroyable} 32 * @implements {gui.SelectionView} 33 * @param {!ops.OdtCursor} cursor 34 */ 35 gui.SvgSelectionView = function SvgSelectionView(cursor) { 36 "use strict"; 37 38 var /**@type{!ops.Document}*/ 39 document = cursor.getDocument(), 40 documentRoot, // initialized by addOverlay 41 /**@type{!HTMLElement}*/ 42 sizer, 43 doc = document.getDOMDocument(), 44 svgns = "http://www.w3.org/2000/svg", 45 overlay = doc.createElementNS(svgns, 'svg'), 46 polygon = doc.createElementNS(svgns, 'polygon'), 47 handle1 = doc.createElementNS(svgns, 'circle'), 48 handle2 = doc.createElementNS(svgns, 'circle'), 49 odfUtils = odf.OdfUtils, 50 domUtils = core.DomUtils, 51 /**@type{!gui.ZoomHelper}*/ 52 zoomHelper = document.getCanvas().getZoomHelper(), 53 /**@type{boolean}*/ 54 isVisible = true, 55 positionIterator = cursor.getDocument().createPositionIterator(document.getRootNode()), 56 /**@const*/ 57 FILTER_ACCEPT = NodeFilter.FILTER_ACCEPT, 58 /**@const*/ 59 FILTER_REJECT = NodeFilter.FILTER_REJECT, 60 /**@const*/ 61 HANDLE_RADIUS = 8, 62 /**@type{!core.ScheduledTask}*/ 63 renderTask; 64 65 /** 66 * This evil little check is necessary because someone, not mentioning any names *cough* 67 * added an extremely hacky undo manager that replaces the root node in order to go back 68 * to a prior document state. 69 * This makes things very sad, and kills baby kittens. 70 * Unfortunately, no-one has had time yet to write a *real* undo stack... so we just need 71 * to cope with it for now. 72 */ 73 function addOverlay() { 74 var newDocumentRoot = document.getRootNode(); 75 if (documentRoot !== newDocumentRoot) { 76 documentRoot = newDocumentRoot; 77 sizer = document.getCanvas().getSizer(); 78 sizer.appendChild(overlay); 79 overlay.setAttribute('class', 'webodf-selectionOverlay'); 80 handle1.setAttribute('class', 'webodf-draggable'); 81 handle2.setAttribute('class', 'webodf-draggable'); 82 handle1.setAttribute('end', 'left'); 83 handle2.setAttribute('end', 'right'); 84 handle1.setAttribute('r', HANDLE_RADIUS); 85 handle2.setAttribute('r', HANDLE_RADIUS); 86 overlay.appendChild(polygon); 87 overlay.appendChild(handle1); 88 overlay.appendChild(handle2); 89 } 90 } 91 92 /** 93 * Returns true if the supplied range has 1 or more visible client rectangles. 94 * A range might not be visible if it: 95 * - contains only hidden nodes 96 * - contains only collapsed whitespace (e.g., multiple whitespace characters will only display as 1 character) 97 * 98 * @param {!Range} range 99 * @return {!boolean} 100 */ 101 function isRangeVisible(range) { 102 var bcr = range.getBoundingClientRect(); 103 return Boolean(bcr && bcr.height !== 0); 104 } 105 106 /** 107 * Set the range to the last visible selection in the text nodes array 108 * @param {!Range} range 109 * @param {!Array.<!Element|!Text>} nodes 110 * @return {!boolean} 111 */ 112 function lastVisibleRect(range, nodes) { 113 var nextNodeIndex = nodes.length - 1, 114 node = nodes[nextNodeIndex], 115 startOffset, 116 endOffset; 117 if (range.endContainer === node) { 118 startOffset = range.endOffset; 119 } else if (node.nodeType === Node.TEXT_NODE) { 120 startOffset = node.length; 121 } else { 122 startOffset = node.childNodes.length; 123 } 124 endOffset = startOffset; 125 range.setStart(node, startOffset); 126 range.setEnd(node, endOffset); 127 while (!isRangeVisible(range)) { 128 if (node.nodeType === Node.ELEMENT_NODE && startOffset > 0) { 129 // Extending start to cover character node. End offset remains unchanged 130 startOffset = 0; 131 } else if (node.nodeType === Node.TEXT_NODE && startOffset > 0) { 132 // Extending start to include one more text char. End offset remains unchanged 133 startOffset -= 1; 134 } else if (nodes[nextNodeIndex]) { 135 // Moving range to a new node. Start collapsed at last available point 136 node = nodes[nextNodeIndex]; 137 nextNodeIndex -= 1; 138 startOffset = endOffset = node.length || node.childNodes.length; 139 } else { 140 // Iteration complete. No more nodes left to explore 141 return false; 142 } 143 range.setStart(node, startOffset); 144 range.setEnd(node, endOffset); 145 } 146 return true; 147 } 148 149 /** 150 * Set the range to the first visible selection in the text nodes array 151 * @param {!Range} range 152 * @param {!Array.<!Element|!Text>} nodes 153 * @return {!boolean} 154 */ 155 function firstVisibleRect(range, nodes) { 156 var nextNodeIndex = 0, 157 node = nodes[nextNodeIndex], 158 startOffset = range.startContainer === node ? range.startOffset : 0, 159 endOffset = startOffset; 160 range.setStart(node, startOffset); 161 range.setEnd(node, endOffset); 162 while (!isRangeVisible(range)) { 163 if (node.nodeType === Node.ELEMENT_NODE && endOffset < node.childNodes.length) { 164 // Extending end to cover character node. Start offset remains unchanged 165 endOffset = node.childNodes.length; 166 } else if (node.nodeType === Node.TEXT_NODE && endOffset < node.length) { 167 // Extending end to include one more text char. Start offset remains unchanged 168 endOffset += 1; 169 } else if (nodes[nextNodeIndex]) { 170 // Moving range to a new node. Start collapsed at first available point 171 node = nodes[nextNodeIndex]; 172 nextNodeIndex += 1; 173 startOffset = endOffset = 0; 174 } else { 175 // Iteration complete. No more nodes left to explore 176 return false; 177 } 178 range.setStart(node, startOffset); 179 range.setEnd(node, endOffset); 180 } 181 return true; 182 } 183 184 /** 185 * Returns the 'extreme' ranges for a range. 186 * This returns 3 ranges, where the firstRange is attached to the first 187 * position in the first text node in the original range, 188 * the lastRange is attached to the last text node's last position, 189 * and the fillerRange starts at the start of firstRange and ends at the end of 190 * lastRange. 191 * @param {!Range} range 192 * @return {?{firstRange: !Range, lastRange: !Range, fillerRange: !Range}} 193 */ 194 function getExtremeRanges(range) { 195 var nodes = odfUtils.getTextElements(range, true, false), 196 firstRange = /**@type {!Range}*/(range.cloneRange()), 197 lastRange = /**@type {!Range}*/(range.cloneRange()), 198 fillerRange = range.cloneRange(); 199 200 if (!nodes.length) { 201 return null; 202 } 203 204 if (!firstVisibleRect(firstRange, nodes)) { 205 return null; 206 } 207 208 if (!lastVisibleRect(lastRange, nodes)) { 209 return null; 210 } 211 212 fillerRange.setStart(firstRange.startContainer, firstRange.startOffset); 213 fillerRange.setEnd(lastRange.endContainer, lastRange.endOffset); 214 215 return { 216 firstRange: firstRange, 217 lastRange: lastRange, 218 fillerRange: fillerRange 219 }; 220 } 221 222 /** 223 * Returns the bounding rectangle of two given rectangles 224 * @param {!ClientRect|!{top: !number, left: !number, bottom: !number, right: !number, width: !number, height: !number}} rect1 225 * @param {!ClientRect|!{top: !number, left: !number, bottom: !number, right: !number, width: !number, height: !number}} rect2 226 * @return {!{top: !number, left: !number, bottom: !number, right: !number, width: !number, height: !number}} 227 */ 228 function getBoundingRect(rect1, rect2) { 229 var resultRect = {}; 230 resultRect.top = Math.min(rect1.top, rect2.top); 231 resultRect.left = Math.min(rect1.left, rect2.left); 232 resultRect.right = Math.max(rect1.right, rect2.right); 233 resultRect.bottom = Math.max(rect1.bottom, rect2.bottom); 234 resultRect.width = resultRect.right - resultRect.left; 235 resultRect.height = resultRect.bottom - resultRect.top; 236 return resultRect; 237 } 238 239 /** 240 * Checks if the newRect is a collapsed rect, and if it is not, 241 * returns the bounding rect of the originalRect and the newRect. 242 * If it is collapsed, returns the originalRect. 243 * Bad ad-hoc function, but I want to keep the size of the code smaller 244 * @param {ClientRect|{top: !number, left: !number, bottom: !number, right: !number, width: !number, height: !number}} originalRect 245 * @param {ClientRect|{top: !number, left: !number, bottom: !number, right: !number, width: !number, height: !number}} newRect 246 * @return {?ClientRect|{top: !number, left: !number, bottom: !number, right: !number, width: !number, height: !number}} 247 */ 248 function checkAndGrowOrCreateRect(originalRect, newRect) { 249 if (newRect && newRect.width > 0 && newRect.height > 0) { 250 if (!originalRect) { 251 originalRect = newRect; 252 } else { 253 originalRect = getBoundingRect(originalRect, newRect); 254 } 255 } 256 return originalRect; 257 } 258 259 /** 260 * Chrome's implementation of getBoundingClientRect is buggy in that it sometimes 261 * includes the ClientRect of a partially covered parent in the bounding rect. 262 * Therefore, instead of simply using getBoundingClientRect on the fillerRange, 263 * we have to carefully compute our own filler rect. 264 * This is done by climbing up the ancestries of both the startContainer and endContainer, 265 * to just one level below the commonAncestorContainer. Then, we iterate between the 266 * 'firstNode' and 'lastNode' and compute the bounding rect of all the siblings in between. 267 * The resulting rect will have the correct width, but the height will be equal or greater than 268 * what a correct getBoundingClientRect would give us. This is not a problem though, because 269 * we only require the width of this filler rect; the top and bottom of the firstRect and lastRect 270 * are enough for the rest. 271 * This function also improves upon getBoundingClientRect in another way: 272 * it computes the bounding rects of the paragraph nodes between the two ends, instead of the 273 * bounding rect of the *range*. This means that unlike gBCR, the bounding rect will not cover absolutely 274 * positioned children such as annotation nodes. 275 * @param {!Range} fillerRange 276 * @return {ClientRect|{top: number, left: number, bottom: number, right: number, width: number, height: number}} 277 */ 278 function getFillerRect(fillerRange) { 279 var containerNode = fillerRange.commonAncestorContainer, 280 /**@type{!Node}*/ 281 firstNode = /**@type{!Node}*/(fillerRange.startContainer), 282 /**@type{!Node}*/ 283 lastNode = /**@type{!Node}*/(fillerRange.endContainer), 284 firstOffset = fillerRange.startOffset, 285 lastOffset = fillerRange.endOffset, 286 currentNode, 287 lastMeasuredNode, 288 firstSibling, 289 lastSibling, 290 grownRect = null, 291 currentRect, 292 range = doc.createRange(), 293 /**@type{!core.PositionFilter}*/ 294 rootFilter, 295 odfNodeFilter = new odf.OdfNodeFilter(), 296 treeWalker; 297 298 /** 299 * This checks if the node is allowed by the odf filter and the root filter. 300 * @param {!Node} node 301 * @return {!number} 302 */ 303 function acceptNode(node) { 304 positionIterator.setUnfilteredPosition(node, 0); 305 if (odfNodeFilter.acceptNode(node) === FILTER_ACCEPT 306 && rootFilter.acceptPosition(positionIterator) === FILTER_ACCEPT) { 307 return FILTER_ACCEPT; 308 } 309 return FILTER_REJECT; 310 } 311 312 /** 313 * If the node is acceptable, check if the node is a grouping element. 314 * If yes, then get it's complete bounding rect (we should use the 315 *getBoundingClientRect call on nodes whenever possible, since it is 316 * extremely buggy on ranges. This has the added good side-effect of 317 * not taking annotations' rects into the bounding rect. 318 * @param {!Node} node 319 * @return {?ClientRect} 320 */ 321 function getRectFromNodeAfterFiltering(node) { 322 var rect = null; 323 // If the sibling is acceptable by the odfNodeFilter and the rootFilter, 324 // only then take into account it's dimensions 325 if (acceptNode(node) === FILTER_ACCEPT) { 326 rect = domUtils.getBoundingClientRect(node); 327 } 328 return rect; 329 } 330 331 332 // If the entire range is for just one node 333 // then we can get the bounding rect for the range and be done with it 334 if (firstNode === containerNode || lastNode === containerNode) { 335 range = fillerRange.cloneRange(); 336 grownRect = range.getBoundingClientRect(); 337 range.detach(); 338 return grownRect; 339 } 340 341 // Compute the firstSibling and lastSibling, 342 // which are top-level siblings just below the common ancestor node 343 firstSibling = firstNode; 344 while (firstSibling.parentNode !== containerNode) { 345 firstSibling = firstSibling.parentNode; 346 } 347 lastSibling = lastNode; 348 while (lastSibling.parentNode !== containerNode) { 349 lastSibling = lastSibling.parentNode; 350 } 351 352 // We use a root filter to avoid taking any rects of nodes in other roots 353 // into the bounding rect, should it happen that the selection contains 354 // nodes from more than one root. Example: Paragraphs containing annotations 355 rootFilter = document.createRootFilter(firstNode); 356 357 // Now since this function is called a lot of times, 358 // we need to iterate between and not including the 359 // first and last top-level siblings (below the common 360 // ancestor), and grow our rect from their bounding rects. 361 // This is cheap technique, compared to actually iterating 362 // over each node in the range. 363 currentNode = firstSibling.nextSibling; 364 while (currentNode && currentNode !== lastSibling) { 365 currentRect = getRectFromNodeAfterFiltering(currentNode); 366 grownRect = checkAndGrowOrCreateRect(grownRect, currentRect); 367 currentNode = currentNode.nextSibling; 368 } 369 370 // If the first top-level sibling is a paragraph, then use it's 371 // bounding rect for growing. This is actually not very necessary, but 372 // makes our selections look more intuitive and more native-ish. 373 // Case in point: If you draw a selection starting on the last (half-full) line of 374 // text in a paragraph and ending somewhere in the middle of the first line of 375 // the next paragraph, the selection will be only as wide as the distance between 376 // the start and end of the selection. 377 // This is where we'd prefer full-width selections, therefore using the paragraph 378 // width is nicer. 379 // We don't need to look deeper into the node, so this is very cheap. 380 if (odfUtils.isParagraph(firstSibling)) { 381 grownRect = checkAndGrowOrCreateRect(grownRect, domUtils.getBoundingClientRect(firstSibling)); 382 } else if (firstSibling.nodeType === Node.TEXT_NODE) { 383 currentNode = firstSibling; 384 range.setStart(currentNode, firstOffset); 385 range.setEnd(currentNode, currentNode === lastSibling ? lastOffset : /**@type{!Text}*/(currentNode).length); 386 currentRect = range.getBoundingClientRect(); 387 grownRect = checkAndGrowOrCreateRect(grownRect, currentRect); 388 } else { 389 // The first top-level sibling was not a paragraph, so we now need to 390 // Grow the rect in a detailed manner using the selected area *inside* the first sibling. 391 // For that, we start walking over textNodes within the firstSibling, 392 // and grow using the the rects of all textnodes that lie including and after the 393 // firstNode (the startContainer of the original fillerRange), and stop 394 // when either the firstSibling ends or we encounter the lastNode. 395 treeWalker = doc.createTreeWalker(firstSibling, NodeFilter.SHOW_TEXT, acceptNode, false); 396 currentNode = treeWalker.currentNode = firstNode; 397 while (currentNode && currentNode !== lastNode) { 398 range.setStart(currentNode, firstOffset); 399 range.setEnd(currentNode, /**@type{!Text}*/(currentNode).length); 400 401 currentRect = range.getBoundingClientRect(); 402 grownRect = checkAndGrowOrCreateRect(grownRect, currentRect); 403 404 // We keep track of the lastMeasuredNode, so that the next block where 405 // we iterate backwards can know when to stop. 406 lastMeasuredNode = currentNode; 407 firstOffset = 0; 408 currentNode = treeWalker.nextNode(); 409 } 410 } 411 412 // If there was no lastMeasuredNode, it means that even the firstNode 413 // was not iterated over. 414 if (!lastMeasuredNode) { 415 lastMeasuredNode = firstNode; 416 } 417 418 // Just like before, a cheap way to avoid looking deeper into the listSibling 419 // if it is a paragraph. 420 if (odfUtils.isParagraph(lastSibling)) { 421 grownRect = checkAndGrowOrCreateRect(grownRect, domUtils.getBoundingClientRect(lastSibling)); 422 } else if (lastSibling.nodeType === Node.TEXT_NODE) { 423 currentNode = lastSibling; 424 range.setStart(currentNode, currentNode === firstSibling ? firstOffset : 0); 425 range.setEnd(currentNode, lastOffset); 426 currentRect = range.getBoundingClientRect(); 427 grownRect = checkAndGrowOrCreateRect(grownRect, currentRect); 428 } else { 429 // Grow the rect using the selected area inside 430 // the last sibling, iterating backwards from the lastNode 431 // till we reach either the beginning of the lastSibling 432 // or encounter the lastMeasuredNode 433 treeWalker = doc.createTreeWalker(lastSibling, NodeFilter.SHOW_TEXT, acceptNode, false); 434 currentNode = treeWalker.currentNode = lastNode; 435 while (currentNode && currentNode !== lastMeasuredNode) { 436 range.setStart(currentNode, 0); 437 range.setEnd(currentNode, lastOffset); 438 439 currentRect = range.getBoundingClientRect(); 440 grownRect = checkAndGrowOrCreateRect(grownRect, currentRect); 441 442 currentNode = treeWalker.previousNode(); 443 if (currentNode) { 444 lastOffset = /**@type{!Text}*/(currentNode).length; 445 } 446 } 447 } 448 449 return grownRect; 450 } 451 452 /** 453 * Gets the clientRect of a range within a textNode, and 454 * collapses the rect to the left or right edge, and returns it 455 * @param {!Range} range 456 * @param {boolean} useRightEdge 457 * @return {{width:number,top:number,bottom:number,height:number,left:number,right:number}} 458 */ 459 function getCollapsedRectOfTextRange(range, useRightEdge) { 460 var clientRect = range.getBoundingClientRect(), 461 collapsedRect = {}; 462 463 collapsedRect.width = 0; 464 collapsedRect.top = clientRect.top; 465 collapsedRect.bottom = clientRect.bottom; 466 collapsedRect.height = clientRect.height; 467 collapsedRect.left = collapsedRect.right = useRightEdge ? clientRect.right : clientRect.left; 468 return collapsedRect; 469 } 470 471 /** 472 * Resets and grows the polygon from the supplied 473 * points. 474 * @param {!Array.<{x: !number, y: !number}>} points 475 * @return {undefined} 476 */ 477 function setPoints(points) { 478 var pointsString = "", 479 i; 480 481 for (i = 0; i < points.length; i += 1) { 482 pointsString += points[i].x + "," + points[i].y + " "; 483 } 484 polygon.setAttribute('points', pointsString); 485 } 486 487 /** 488 * Repositions overlay over the given selected range of the cursor. If the 489 * selected range has no visible rectangles (as may happen if the selection only 490 * encompasses collapsed whitespace, or does not span any ODT text elements), this 491 * function will return false to indicate the overlay element can be hidden. 492 * 493 * @param {!Range} selectedRange 494 * @return {!boolean} Returns true if the selected range is visible (i.e., height + 495 * width are non-zero), otherwise returns false 496 */ 497 function repositionOverlays(selectedRange) { 498 var rootRect = /**@type{!ClientRect}*/(domUtils.getBoundingClientRect(sizer)), 499 zoomLevel = zoomHelper.getZoomLevel(), 500 extremes = getExtremeRanges(selectedRange), 501 firstRange, 502 lastRange, 503 fillerRange, 504 firstRect, 505 fillerRect, 506 lastRect, 507 left, 508 right, 509 top, 510 bottom; 511 512 // If the range is collapsed (no selection) or no extremes were found, do not show 513 // any virtual selections. 514 if (extremes) { 515 firstRange = extremes.firstRange; 516 lastRange = extremes.lastRange; 517 fillerRange = extremes.fillerRange; 518 519 firstRect = domUtils.translateRect(getCollapsedRectOfTextRange(firstRange, false), rootRect, zoomLevel); 520 lastRect = domUtils.translateRect(getCollapsedRectOfTextRange(lastRange, true), rootRect, zoomLevel); 521 fillerRect = getFillerRect(fillerRange); 522 523 if (!fillerRect) { 524 fillerRect = getBoundingRect(firstRect, lastRect); 525 } else { 526 fillerRect = domUtils.translateRect(fillerRect, rootRect, zoomLevel); 527 } 528 529 // These are the absolute bounding left, right, top, and bottom coordinates of the 530 // entire selection. 531 left = fillerRect.left; 532 right = firstRect.left + Math.max(0, fillerRect.width - (firstRect.left - fillerRect.left)); 533 // We will use the topmost 'top' value, because if lastRect.top lies above 534 // firstRect.top, then both are most likely on the same line, and font sizes 535 // are different, so the selection should be taller. 536 top = Math.min(firstRect.top, lastRect.top); 537 bottom = lastRect.top + lastRect.height; 538 539 // Now we grow the polygon by adding the corners one by one, 540 // and finally we make sure that the last point is the same 541 // as the first. 542 543 setPoints([ 544 { x: firstRect.left, y: top + firstRect.height }, 545 { x: firstRect.left, y: top }, 546 { x: right, y: top }, 547 { x: right, y: bottom - lastRect.height }, 548 { x: lastRect.right, y: bottom - lastRect.height }, 549 { x: lastRect.right, y: bottom }, 550 { x: left, y: bottom }, 551 { x: left, y: top + firstRect.height }, 552 { x: firstRect.left, y: top + firstRect.height } 553 ]); 554 555 handle1.setAttribute('cx', firstRect.left); 556 handle1.setAttribute('cy', top + firstRect.height / 2); 557 handle2.setAttribute('cx', lastRect.right); 558 handle2.setAttribute('cy', bottom - lastRect.height / 2); 559 560 firstRange.detach(); 561 lastRange.detach(); 562 fillerRange.detach(); 563 } 564 return Boolean(extremes); 565 } 566 567 /** 568 * Update the visible selection, or hide if it should no 569 * longer be visible 570 * @return {undefined} 571 */ 572 function rerender() { 573 var range = cursor.getSelectedRange(), 574 shouldShow; 575 shouldShow = isVisible 576 && cursor.getSelectionType() === ops.OdtCursor.RangeSelection 577 && !range.collapsed; 578 if (shouldShow) { 579 addOverlay(); 580 shouldShow = repositionOverlays(range); 581 } 582 if (shouldShow) { 583 overlay.style.display = "block"; 584 } else { 585 overlay.style.display = "none"; 586 } 587 } 588 589 /** 590 * @inheritDoc 591 */ 592 this.rerender = function () { 593 if (isVisible) { 594 renderTask.trigger(); 595 } 596 }; 597 598 /** 599 * @inheritDoc 600 */ 601 this.show = function () { 602 isVisible = true; 603 renderTask.trigger(); 604 }; 605 606 /** 607 * @inheritDoc 608 */ 609 this.hide = function () { 610 isVisible = false; 611 renderTask.trigger(); 612 }; 613 614 /** 615 * @param {!gui.ShadowCursor|ops.OdtCursor} movedCursor 616 * @return {undefined} 617 */ 618 function handleCursorMove(movedCursor) { 619 if (isVisible && movedCursor === cursor) { 620 renderTask.trigger(); 621 } 622 } 623 624 /** 625 * Scale handles to 1/zoomLevel,so they are 626 * finger-friendly at every zoom level. 627 * @param {!number} zoomLevel 628 * @return {undefined} 629 */ 630 function scaleHandles(zoomLevel) { 631 var radius = HANDLE_RADIUS / zoomLevel; 632 633 handle1.setAttribute('r', radius); 634 handle2.setAttribute('r', radius); 635 } 636 637 /** 638 * @param {function(!Object=)} callback 639 */ 640 function destroy(callback) { 641 sizer.removeChild(overlay); 642 sizer.classList.remove('webodf-virtualSelections'); 643 cursor.getDocument().unsubscribe(ops.Document.signalCursorMoved, handleCursorMove); 644 zoomHelper.unsubscribe(gui.ZoomHelper.signalZoomChanged, scaleHandles); 645 callback(); 646 } 647 648 /** 649 * @inheritDoc 650 * @param {function(!Error=)} callback 651 */ 652 this.destroy = function (callback) { 653 core.Async.destroyAll([renderTask.destroy, destroy], callback); 654 }; 655 656 function init() { 657 var editinfons = 'urn:webodf:names:editinfo', 658 memberid = cursor.getMemberId(); 659 660 renderTask = core.Task.createRedrawTask(rerender); 661 addOverlay(); 662 overlay.setAttributeNS(editinfons, 'editinfo:memberid', memberid); 663 sizer.classList.add('webodf-virtualSelections'); 664 cursor.getDocument().subscribe(ops.Document.signalCursorMoved, handleCursorMove); 665 zoomHelper.subscribe(gui.ZoomHelper.signalZoomChanged, scaleHandles); 666 scaleHandles(zoomHelper.getZoomLevel()); 667 } 668 669 init(); 670 }; 671