annotate.js (51171B)
1 /* 2 ***** BEGIN LICENSE BLOCK ***** 3 4 Copyright © 2009 Center for History and New Media 5 George Mason University, Fairfax, Virginia, USA 6 http://zotero.org 7 8 This file is part of Zotero. 9 10 Zotero is free software: you can redistribute it and/or modify 11 it under the terms of the GNU Affero General Public License as published by 12 the Free Software Foundation, either version 3 of the License, or 13 (at your option) any later version. 14 15 Zotero is distributed in the hope that it will be useful, 16 but WITHOUT ANY WARRANTY; without even the implied warranty of 17 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 GNU Affero General Public License for more details. 19 20 You should have received a copy of the GNU Affero General Public License 21 along with Zotero. If not, see <http://www.gnu.org/licenses/>. 22 23 ***** END LICENSE BLOCK ***** 24 */ 25 26 const TEXT_TYPE = Components.interfaces.nsIDOMNode.TEXT_NODE; 27 28 /** 29 * Globally accessible functions relating to annotations 30 * @namespace 31 */ 32 Zotero.Annotate = new function() { 33 var _annotated = {}; 34 35 this.highlightColor = "#fff580"; 36 this.alternativeHighlightColor = "#555fa9"; 37 38 /** 39 * Gets the pixel offset of an item from the top left of a page 40 * 41 * @param {Node} node DOM node to get the pixel offset of 42 * @param {Integer} offset Text offset 43 * @return {Integer[]} X and Y coordinates 44 */ 45 this.getPixelOffset = function(node, offset) { 46 var x = 0; 47 var y = 0; 48 49 do { 50 x += node.offsetLeft; 51 y += node.offsetTop; 52 node = node.offsetParent; 53 } while(node); 54 55 return [x, y]; 56 } 57 58 /** 59 * Gets the annotation ID from a given URL 60 */ 61 this.getAnnotationIDFromURL = function(url) { 62 const attachmentRe = /^zotero:\/\/attachment\/([0-9]+)\/$/; 63 var m = attachmentRe.exec(url); 64 if (m) { 65 var id = m[1]; 66 var item = Zotero.Items.get(id); 67 var contentType = item.attachmentContentType; 68 var file = item.getFilePath(); 69 var ext = Zotero.File.getExtension(file); 70 if (contentType == 'text/plain' || !Zotero.MIME.hasNativeHandler(contentType, ext)) { 71 return false; 72 } 73 return id; 74 } 75 return false; 76 } 77 78 /** 79 * Parses CSS/HTML color descriptions 80 * 81 * @return {Integer[]} An array of 3 values from 0 to 255 representing R, G, and B components 82 */ 83 this.parseColor = function(color) { 84 const rgbColorRe = /rgb\(([0-9]+), ?([0-9]+), ?([0-9]+)\)/i; 85 86 var colorArray = rgbColorRe.exec(color); 87 if(colorArray) return [parseInt(colorArray[1]), parseInt(colorArray[2]), parseInt(colorArray[3])]; 88 89 if(color[0] == "#") color = color.substr(1); 90 try { 91 colorArray = []; 92 for(var i=0; i<6; i+=2) { 93 colorArray.push(parseInt(color.substr(i, 2), 16)); 94 } 95 return colorArray; 96 } catch(e) { 97 throw "Annotate: parseColor passed invalid color"; 98 } 99 } 100 101 /** 102 * Gets the city block distance between two colors. Accepts colors in the format returned by 103 * Zotero.Annotate.parseColor() 104 * 105 * @param {Integer[]} color1 106 * @param {Integer[]} color2 107 * @return {Integer} The distance 108 */ 109 this.getColorDistance = function(color1, color2) { 110 color1 = this.parseColor(color1); 111 color2 = this.parseColor(color2); 112 113 var distance = 0; 114 for(var i=0; i<3; i++) { 115 distance += Math.abs(color1[i] - color2[i]); 116 } 117 118 return distance; 119 } 120 121 /** 122 * Checks to see if a given item is already open for annotation 123 * 124 * @param {Integer} id An item ID 125 * @return {Boolean} 126 */ 127 this.isAnnotated = function(id) { 128 const XUL_NAMESPACE = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; 129 130 var annotationURL = "zotero://attachment/"+id+"/"; 131 var haveBrowser = false; 132 133 var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] 134 .getService(Components.interfaces.nsIWindowMediator); 135 var enumerator = wm.getEnumerator("navigator:browser"); 136 while(enumerator.hasMoreElements()) { 137 var win = enumerator.getNext(); 138 var tabbrowser = win.document.getElementsByTagNameNS(XUL_NAMESPACE, "tabbrowser"); 139 if(tabbrowser && tabbrowser.length) { 140 var browsers = tabbrowser[0].browsers; 141 } else { 142 var browsers = win.document.getElementsByTagNameNS(XUL_NAMESPACE, "browser"); 143 } 144 for (let browser of browsers) { 145 if(browser.currentURI) { 146 if(browser.currentURI.spec == annotationURL) { 147 if(haveBrowser) { 148 // require two with this URI 149 return true; 150 } else { 151 haveBrowser = true; 152 } 153 } 154 } 155 } 156 } 157 158 return false; 159 } 160 161 /** 162 * Sometimes, Firefox gives us a node offset inside another node, as opposed to a text offset 163 * This function replaces such offsets with references to the nodes themselves 164 * 165 * @param {Node} node DOM node 166 * @param {Integer} offset Node offset 167 * @return {Node} The DOM node after dereferencing has taken place 168 */ 169 this.dereferenceNodeOffset = function(node, offset) { 170 if(offset != 0) { 171 if(offset == node.childNodes.length) { 172 node = node.lastChild; 173 } else if(offset < node.childNodes.length) { 174 node = node.childNodes[offset]; 175 } else { 176 throw "Annotate: dereferenceNodeOffset called with invalid offset "+offset; 177 } 178 if(!node) throw "Annotate: dereferenceNodeOffset resolved to invalid node"; 179 } 180 181 return node; 182 } 183 184 /** 185 * Normalizes a DOM range, resolving it to a range that begins and ends at a text offset and 186 * remains unchanged when serialized to a Zotero.Annotate.Path object 187 * 188 * @param {Range} selectedRange The range to normalize 189 * @param {Function} nsResolver Namespace resolver function 190 * @return {Zotero.Annotate.Path[]} Start and end paths 191 */ 192 this.normalizeRange = function(selectedRange, nsResolver) { 193 var document = selectedRange.startContainer.ownerDocument; 194 195 var container, offset; 196 if(selectedRange.startContainer.nodeType != TEXT_TYPE) { 197 [container, offset] = _getTextNode(selectedRange.startContainer, selectedRange.startOffset, true); 198 selectedRange.setStart(container, offset); 199 } 200 if(selectedRange.endContainer.nodeType != TEXT_TYPE) { 201 [container, offset] = _getTextNode(selectedRange.endContainer, selectedRange.endOffset); 202 selectedRange.setEnd(container, offset); 203 } 204 205 var startPath = new Zotero.Annotate.Path(document, nsResolver); 206 var endPath = new Zotero.Annotate.Path(document, nsResolver); 207 startPath.fromNode(selectedRange.startContainer, selectedRange.startOffset); 208 endPath.fromNode(selectedRange.endContainer, selectedRange.endOffset); 209 210 [container, offset] = startPath.toNode(); 211 selectedRange.setStart(container, offset); 212 [container, offset] = endPath.toNode(); 213 selectedRange.setEnd(container, offset); 214 215 return [startPath, endPath]; 216 } 217 218 /** 219 * Takes a node and finds the relevant text node inside of it 220 * 221 * @private 222 * @param {Node} container Node to get text node of 223 * @param {Integer} offset Node offset (see dereferenceNodeOffset) 224 * @param {Boolean} isStart Whether to treat this node as a start node. We look for the first 225 * text node from the start of start nodes, or the first from the end of end nodes 226 * @return {Array} The node and offset 227 */ 228 function _getTextNode(container, offset, isStart) { 229 var firstTarget = isStart ? "firstChild" : "lastChild"; 230 var secondTarget = isStart ? "nextSibling" : "previousSibling"; 231 232 container = Zotero.Annotate.dereferenceNodeOffset(container, offset); 233 if(container.nodeType == TEXT_TYPE) return [container, 0]; 234 235 var seenArray = new Array(); 236 var node = container; 237 while(node) { 238 if ( !node ) { 239 // uh-oh 240 break; 241 } 242 if(node.nodeType == TEXT_TYPE ) { 243 container = node; 244 break; 245 } 246 if( node[firstTarget] && ! _seen(node[firstTarget],seenArray)) { 247 var node = node[firstTarget]; 248 } else if( node[secondTarget] && ! _seen(node[secondTarget],seenArray)) { 249 var node = node[secondTarget]; 250 } else { 251 var node = node.parentNode; 252 } 253 } 254 return [container, (!isStart && container.nodeType == TEXT_TYPE ? container.nodeValue.length : 0)]; 255 } 256 257 /** 258 * look for a node object in an array. return true if the node 259 * is found in the array. otherwise push the node onto the array 260 * and return false. used by _getTextNode. 261 */ 262 function _seen(node,array) { 263 var seen = false; 264 for (n in array) { 265 if (node === array[n]) { 266 var seen = true; 267 } 268 } 269 if ( !seen ) { 270 array.push(node); 271 } 272 return seen; 273 } 274 } 275 276 /** 277 * Creates a new Zotero.Annotate.Path object from an XPath, text node index, and text offset 278 * 279 * @class A persistent descriptor for a point in the DOM, invariant to modifications of 280 * the DOM produced by highlights and annotations 281 * 282 * @property {String} parent XPath of parent node of referenced text node, or XPath of referenced 283 * element 284 * @property {Integer} textNode Index of referenced text node 285 * @property {Integer} offset Offset of referenced point inside text node 286 * 287 * @constructor 288 * @param {Document} document DOM document this path references 289 * @param {Function} nsResolver Namespace resolver (for XPaths) 290 * @param {String} parent (Optional) XPath of parent node 291 * @param {Integer} textNode (Optional) Text node number 292 * @param {Integer} offset (Optional) Text offset 293 */ 294 Zotero.Annotate.Path = function(document, nsResolver, parent, textNode, offset) { 295 if(parent !== undefined) { 296 this.parent = parent; 297 this.textNode = textNode; 298 this.offset = offset; 299 } 300 this._document = document; 301 this._nsResolver = nsResolver; 302 } 303 304 /** 305 * Converts a DOM node/offset combination to a Zotero.Annotate.Path object 306 * 307 * @param {Node} node The DOM node to reference 308 * @param {Integer} offset The text offset, if the DOM node is a text node 309 */ 310 Zotero.Annotate.Path.prototype.fromNode = function(node, offset) { 311 if(!node) throw "Annotate: Path() called with invalid node"; 312 Zotero.debug("Annotate: Path() called with node "+node.tagName+" offset "+offset); 313 314 this.parent = ""; 315 this.textNode = null; 316 this.offset = (offset === 0 || offset ? offset : null); 317 318 var lastWasTextNode = node.nodeType == TEXT_TYPE; 319 320 if(!lastWasTextNode && offset) { 321 node = Zotero.Annotate.dereferenceNodeOffset(node, offset); 322 offset = 0; 323 lastWasTextNode = node.nodeType == TEXT_TYPE; 324 } 325 326 if(node.parentNode.getAttribute && node.parentNode.getAttribute("zotero")) { 327 // if the selected point is inside a Zotero node node, add offsets of preceding 328 // text nodes 329 var first = false; 330 var sibling = node.previousSibling; 331 while(sibling) { 332 if(sibling.nodeType == TEXT_TYPE) this.offset += sibling.nodeValue.length; 333 sibling = sibling.previousSibling; 334 } 335 336 // use parent node for future purposes 337 node = node.parentNode; 338 } else if(node.getAttribute && node.getAttribute("zotero")) { 339 // if selected point is a Zotero node, move it to last character of the previous node 340 node = node.previousSibling ? node.previousSibling : node.parentNode; 341 if(node.nodeType == TEXT_TYPE) { 342 this.offset = node.nodeValue.length; 343 lastWasTextNode = true; 344 } else { 345 this.offset = 0; 346 } 347 } 348 if(!node) throw "Annotate: Path() handled Zotero <span> inappropriately"; 349 350 lastWasTextNode = lastWasTextNode || node.nodeType == TEXT_TYPE; 351 352 if(lastWasTextNode) { 353 this.textNode = 1; 354 var first = true; 355 356 var sibling = node.previousSibling; 357 while(sibling) { 358 var isZotero = (sibling.getAttribute ? sibling.getAttribute("zotero") : false); 359 360 if(sibling.nodeType == TEXT_TYPE || 361 (isZotero == "highlight")) { 362 // is a text node 363 if(first == true) { 364 // is still part of the first text node 365 if(sibling.getAttribute) { 366 // get offset of all child nodes 367 for (let child of sibling.childNodes) { 368 if(child && child.nodeType == TEXT_TYPE) { 369 this.offset += child.nodeValue.length; 370 } 371 } 372 } else { 373 this.offset += sibling.nodeValue.length; 374 } 375 } else if(!lastWasTextNode) { 376 // is part of another text node 377 this.textNode++; 378 lastWasTextNode = true; 379 } 380 } else if(!isZotero) { // skip over annotation marker nodes 381 // is not a text node 382 lastWasTextNode = first = false; 383 } 384 385 sibling = sibling.previousSibling; 386 } 387 388 node = node.parentNode; 389 } 390 if(!node) throw "Annotate: Path() resolved text offset inappropriately"; 391 392 while(node && node !== this._document) { 393 var number = 1; 394 var sibling = node.previousSibling; 395 while(sibling) { 396 if(sibling.tagName) { 397 if(sibling.tagName == node.tagName && !sibling.hasAttribute("zotero")) number++; 398 } else { 399 if(sibling.nodeType == node.nodeType) number++; 400 } 401 sibling = sibling.previousSibling; 402 } 403 404 // don't add highlight nodes 405 if(node.tagName) { 406 var tag = node.tagName.toLowerCase(); 407 if(tag == "span") { 408 tag += "[not(@zotero)]"; 409 } 410 this.parent = "/"+tag+"["+number+"]"+this.parent; 411 } else if(node.nodeType == Components.interfaces.nsIDOMNode.COMMENT_NODE) { 412 this.parent = "/comment()["+number+"]"; 413 } else if(node.nodeType == Components.interfaces.nsIDOMNode.TEXT_NODE) { 414 Zotero.debug("Annotate: Path() referenced a text node; this should never happen"); 415 this.parent = "/text()["+number+"]"; 416 } else { 417 Zotero.debug("Annotate: Path() encountered unrecognized node type"); 418 } 419 420 node = node.parentNode; 421 } 422 423 Zotero.debug("Annotate: got path "+this.parent+", "+this.textNode+", "+this.offset); 424 } 425 426 /** 427 * Converts a Zotero.Annotate.Path object to a DOM/offset combination 428 * 429 * @return {Array} Node and offset 430 */ 431 Zotero.Annotate.Path.prototype.toNode = function() { 432 Zotero.debug("toNode on "+this.parent+" "+this.textNode+", "+this.offset); 433 434 var offset = 0; 435 436 // try to evaluate parent 437 try { 438 var node = this._document.evaluate(this.parent, this._document, this._nsResolver, 439 Components.interfaces.nsIDOMXPathResult.ANY_TYPE, null).iterateNext(); 440 } catch(e) { 441 Zotero.debug("Annotate: could not find XPath "+this.parent+" in Path.toNode()"); 442 return [false, false]; 443 } 444 445 // don't do further processing if this path does not refer to a text node 446 if(!this.textNode) return [node, offset]; 447 448 // parent node must have children if we have a text node index 449 if(!node.hasChildNodes()) { 450 Zotero.debug("Annotate: Parent node has no child nodes, but a text node was specified"); 451 return [false, false]; 452 } 453 454 node = node.firstChild; 455 offset = this.offset; 456 var lastWasTextNode = false; 457 var number = 0; 458 459 // find text node 460 while(true) { 461 var isZotero = undefined; 462 if(node.getAttribute) isZotero = node.getAttribute("zotero"); 463 464 if(node.nodeType == TEXT_TYPE || 465 isZotero == "highlight") { 466 if(!lastWasTextNode) { 467 number++; 468 469 // if we found the node we're looking for, break 470 if(number == this.textNode) break; 471 472 lastWasTextNode = true; 473 } 474 } else if(!isZotero) { 475 lastWasTextNode = false; 476 } 477 478 node = node.nextSibling; 479 // if there's no node, this point is invalid 480 if(!node) { 481 Zotero.debug("Annotate: reached end of node list while searching for text node "+this.textNode+" of "+this.parent); 482 return [false, false]; 483 } 484 } 485 486 // find offset 487 while(true) { 488 // get length of enclosed text node 489 if(node.getAttribute) { 490 // this is a highlighted node; loop through and subtract all 491 // offsets, breaking if we reach the end 492 var parentNode = node; 493 node = node.firstChild; 494 while(node) { 495 if(node.nodeType == TEXT_TYPE) { 496 // break if end condition reached 497 if(node.nodeValue.length >= offset) return [node, offset]; 498 // otherwise, continue subtracting offsets 499 offset -= node.nodeValue.length; 500 } 501 node = node.nextSibling; 502 } 503 // restore parent node 504 node = parentNode; 505 } else { 506 // this is not a highlighted node; use simple node length 507 if(node.nodeValue.length >= offset) return [node, offset]; 508 offset -= node.nodeValue.length; 509 } 510 511 // get next node 512 node = node.nextSibling; 513 // if next node does not exist or is not a text node, this 514 // point is invalid 515 if(!node || (node.nodeType != TEXT_TYPE && (!node.getAttribute || !node.getAttribute("zotero")))) { 516 Zotero.debug("Annotate: could not find offset "+this.offset+" for text node "+this.textNode+" of "+this.parent); 517 return [false, false]; 518 } 519 } 520 } 521 522 /** 523 * Creates a new Zotero.Annotations object 524 * @class Manages all annotations and highlights for a given item 525 * 526 * @constructor 527 * @param {Zotero_Browser} Zotero_Browser object for the tab in which this item is loaded 528 * @param {Browser} Mozilla Browser object 529 * @param {Integer} itemID ID of the item to be annotated/highlighted 530 */ 531 Zotero.Annotations = function(Zotero_Browser, browser, itemID) { 532 this.Zotero_Browser = Zotero_Browser; 533 this.browser = browser; 534 this.document = browser.contentDocument; 535 this.window = browser.contentWindow; 536 this.nsResolver = this.document.createNSResolver(this.document.documentElement); 537 538 this.itemID = itemID; 539 540 this.annotations = new Array(); 541 this.highlights = new Array(); 542 543 this.zIndex = 9999; 544 } 545 546 /** 547 * Creates a new annotation at the cursor position 548 * @return {Zotero.Annotation} 549 */ 550 Zotero.Annotations.prototype.createAnnotation = function() { 551 var annotation = new Zotero.Annotation(this); 552 this.annotations.push(annotation); 553 return annotation; 554 } 555 556 /** 557 * Highlights text 558 * 559 * @param {Range} selectedRange Range to highlight 560 * @return {Zotero.Highlight} 561 */ 562 Zotero.Annotations.prototype.highlight = function(selectedRange) { 563 var startPath, endPath; 564 [startPath, endPath] = Zotero.Annotate.normalizeRange(selectedRange, this.nsResolver); 565 566 var deleteHighlights = new Array(); 567 var startIn = false, endIn = false; 568 569 // first, see if part of this range is already 570 for(var i in this.highlights) { 571 var compareHighlight = this.highlights[i]; 572 var compareRange = compareHighlight.getRange(); 573 574 var startToStart = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.START_TO_START, selectedRange); 575 var endToEnd = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.END_TO_END, selectedRange); 576 if(startToStart != 1 && endToEnd != -1) { 577 // if the selected range is inside this one 578 return compareHighlight; 579 } else if(startToStart != -1 && endToEnd != 1) { 580 // if this range is inside selected range, delete 581 delete this.highlights[i]; 582 } else { 583 var endToStart = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.END_TO_START, selectedRange); 584 if(endToStart != 1 && endToEnd != -1) { 585 // if the end of the selected range is between the start and 586 // end of this range 587 var endIn = i; 588 } else { 589 var startToEnd = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.START_TO_END, selectedRange); 590 if(startToEnd != -1 && startToStart != 1) { 591 // if the start of the selected range is between the 592 // start and end of this range 593 var startIn = i; 594 } 595 } 596 } 597 } 598 599 if(startIn !== false || endIn !== false) { 600 // starts in and ends in existing highlights 601 if(startIn !== false) { 602 var highlight = this.highlights[startIn]; 603 startRange = highlight.getRange(); 604 selectedRange.setStart(startRange.startContainer, startRange.startOffset); 605 startPath = highlight.startPath; 606 } else { 607 var highlight = this.highlights[endIn]; 608 } 609 610 if(endIn !== false) { 611 endRange = this.highlights[endIn].getRange(); 612 selectedRange.setEnd(endRange.endContainer, endRange.endOffset); 613 endPath = this.highlights[endIn].endPath; 614 } 615 616 // if bridging ranges, delete end range 617 if(startIn !== false && endIn !== false) { 618 delete this.highlights[endIn]; 619 } 620 } else { 621 // need to create a new highlight 622 var highlight = new Zotero.Highlight(this); 623 this.highlights.push(highlight); 624 } 625 626 // actually generate ranges 627 highlight.initWithRange(selectedRange, startPath, endPath); 628 629 //for(var i in this.highlights) Zotero.debug(i+" = "+this.highlights[i].startPath.offset+" to "+this.highlights[i].endPath.offset+" ("+this.highlights[i].startPath.parent+" to "+this.highlights[i].endPath.parent+")"); 630 return highlight; 631 } 632 633 /** 634 * Unhighlights text 635 * 636 * @param {Range} selectedRange Range to unhighlight 637 */ 638 Zotero.Annotations.prototype.unhighlight = function(selectedRange) { 639 var startPath, endPath, node, offset; 640 [startPath, endPath] = Zotero.Annotate.normalizeRange(selectedRange, this.nsResolver); 641 642 // first, see if part of this range is already highlighted 643 for(var i in this.highlights) { 644 var updateStart = false; 645 var updateEnd = false; 646 647 var compareHighlight = this.highlights[i]; 648 var compareRange = compareHighlight.getRange(); 649 650 var startToStart = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.START_TO_START, selectedRange); 651 var endToEnd = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.END_TO_END, selectedRange); 652 653 if(startToStart == -1 && endToEnd == 1) { 654 // need to split range into two highlights 655 var compareEndPath = compareHighlight.endPath; 656 657 // this will unhighlight the entire end 658 compareHighlight.unhighlight(selectedRange.startContainer, selectedRange.startOffset, 659 startPath, Zotero.Highlight.UNHIGHLIGHT_FROM_POINT); 660 var newRange = this.document.createRange(); 661 662 // need to use point references because they disregard highlights 663 [node, offset] = endPath.toNode(); 664 newRange.setStart(node, offset); 665 [node, offset] = compareEndPath.toNode(); 666 newRange.setEnd(node, offset); 667 668 // create new node 669 var highlight = new Zotero.Highlight(this); 670 highlight.initWithRange(newRange, endPath, compareEndPath); 671 this.highlights.push(highlight); 672 break; 673 } else if(startToStart != -1 && endToEnd != 1) { 674 // if this range is inside selected range, delete 675 compareHighlight.unhighlight(null, null, null, Zotero.Highlight.UNHIGHLIGHT_ALL); 676 delete this.highlights[i]; 677 updateEnd = updateStart = true; 678 } else if(startToStart == -1) { 679 var startToEnd = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.START_TO_END, selectedRange); 680 if(startToEnd != -1) { 681 // if the start of the selected range is between the start and end of this range 682 compareHighlight.unhighlight(selectedRange.startContainer, selectedRange.startOffset, 683 startPath, Zotero.Highlight.UNHIGHLIGHT_FROM_POINT); 684 updateEnd = true; 685 } 686 } else { 687 var endToStart = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.END_TO_START, selectedRange); 688 if(endToStart != 1) { 689 // if the end of the selected range is between the start and end of this range 690 compareHighlight.unhighlight(selectedRange.endContainer, selectedRange.endOffset, 691 endPath, Zotero.Highlight.UNHIGHLIGHT_TO_POINT); 692 updateStart = true; 693 } 694 } 695 696 // need to update start and end parts of ranges if spans have shifted around 697 if(updateStart) { 698 [node, offset] = startPath.toNode(); 699 selectedRange.setStart(node, offset); 700 } 701 if(updateEnd) { 702 [node, offset] = endPath.toNode(); 703 selectedRange.setEnd(node, offset); 704 } 705 } 706 707 //for(var i in this.highlights) Zotero.debug(i+" = "+this.highlights[i].startPath.offset+" to "+this.highlights[i].endPath.offset+" ("+this.highlights[i].startPath.parent+" to "+this.highlights[i].endPath.parent+")"); 708 } 709 710 /** 711 * Refereshes display of annotations (useful if page is reloaded) 712 */ 713 Zotero.Annotations.prototype.refresh = function() { 714 for (let annotation of this.annotations) { 715 annotation.display(); 716 } 717 } 718 719 /** 720 * Saves annotations to DB 721 */ 722 Zotero.Annotations.prototype.save = function() { 723 Zotero.DB.beginTransaction(); 724 try { 725 Zotero.DB.query("DELETE FROM highlights WHERE itemID = ?", [this.itemID]); 726 727 // save highlights 728 for (let highlight of this.highlights) { 729 if(highlight) highlight.save(); 730 } 731 732 // save annotations 733 for (let annotation of this.annotations) { 734 // Don't drop all annotations if one is broken (due to ~3.0 glitch) 735 try { 736 annotation.save(); 737 } 738 catch(e) { 739 Zotero.debug(e); 740 continue; 741 } 742 } 743 Zotero.DB.commitTransaction(); 744 } catch(e) { 745 Zotero.debug(e); 746 Zotero.DB.rollbackTransaction(); 747 throw(e); 748 } 749 } 750 751 /** 752 * Loads annotations from DB 753 */ 754 Zotero.Annotations.prototype.load = Zotero.Promise.coroutine(function* () { 755 // load annotations 756 var rows = yield Zotero.DB.queryAsync("SELECT * FROM annotations WHERE itemID = ?", [this.itemID]); 757 for (let row of rows) { 758 var annotation = this.createAnnotation(); 759 annotation.initWithDBRow(row); 760 } 761 762 // load highlights 763 var rows = yield Zotero.DB.queryAsync("SELECT * FROM highlights WHERE itemID = ?", [this.itemID]); 764 for (let row of rows) { 765 try { 766 var highlight = new Zotero.Highlight(this); 767 highlight.initWithDBRow(row); 768 this.highlights.push(highlight); 769 } catch(e) { 770 Zotero.debug("Annotate: could not load highlight"); 771 } 772 } 773 }); 774 775 /** 776 * Expands annotations if any are collapsed, or collapses highlights if all are expanded 777 */ 778 Zotero.Annotations.prototype.toggleCollapsed = function() { 779 // look to see if there are any collapsed annotations 780 var status = true; 781 for (let annotation of this.annotations) { 782 if(annotation.collapsed) { 783 status = false; 784 break; 785 } 786 } 787 788 // set status on all annotations 789 for (let annotation of this.annotations) { 790 annotation.setCollapsed(status); 791 } 792 } 793 794 /** 795 * @class Represents an individual annotation 796 * 797 * @constructor 798 * @property {Boolean} collapsed Whether this annotation is collapsed (minimized) 799 * @param {Zotero.Annotations} annotationsObj The Zotero.Annotations object corresponding to the 800 * page this annotation is on 801 */ 802 Zotero.Annotation = function(annotationsObj) { 803 this.annotationsObj = annotationsObj; 804 this.window = annotationsObj.browser.contentWindow; 805 this.document = annotationsObj.browser.contentDocument; 806 this.nsResolver = annotationsObj.nsResolver; 807 this.cols = 30; 808 this.rows = 5; 809 } 810 811 /** 812 * Generates annotation from a click event 813 * 814 * @param {Event} e The DOM click event 815 */ 816 Zotero.Annotation.prototype.initWithEvent = function(e) { 817 var maxOffset = false; 818 819 try { 820 var range = this.window.getSelection().getRangeAt(0); 821 this.node = range.startContainer; 822 var offset = range.startOffset; 823 if(this.node.nodeValue) maxOffset = this.node.nodeValue.length; 824 } catch(err) { 825 this.node = e.target; 826 var offset = 0; 827 } 828 829 var clickX = this.window.pageXOffset + e.clientX; 830 var clickY = this.window.pageYOffset + e.clientY; 831 832 var isTextNode = (this.node.nodeType == Components.interfaces.nsIDOMNode.TEXT_NODE); 833 834 if(offset == 0 || !isTextNode) { 835 // tag by this.offset from parent this.node, rather than text 836 if(isTextNode) this.node = this.node.parentNode; 837 offset = 0; 838 } 839 840 if(offset) this._generateMarker(offset); 841 842 var pixelOffset = Zotero.Annotate.getPixelOffset(this.node); 843 this.x = clickX - pixelOffset[0]; 844 this.y = clickY - pixelOffset[1]; 845 this.collapsed = false; 846 847 Zotero.debug("Annotate: added new annotation"); 848 849 this.displayWithAbsoluteCoordinates(clickX, clickY, true); 850 } 851 852 /** 853 * Generates annotation from a DB row 854 * 855 * @param {Object} row The DB row 856 */ 857 Zotero.Annotation.prototype.initWithDBRow = function(row) { 858 var path = new Zotero.Annotate.Path(this.document, this.nsResolver, row.parent, row.textNode, row.offset); 859 [node, offset] = path.toNode(); 860 if(!node) { 861 Zotero.debug("Annotate: could not load annotation "+row.annotationID+" from DB"); 862 return; 863 } 864 this.node = node; 865 if(offset) this._generateMarker(offset); 866 867 this.x = row.x; 868 this.y = row.y; 869 this.cols = row.cols; 870 this.rows = row.rows; 871 this.annotationID = row.annotationID; 872 this.collapsed = !!row.collapsed; 873 874 this.display(); 875 876 var me = this; 877 this.iframe.addEventListener("load", function() { me.textarea.value = row.text }, false); 878 } 879 880 /** 881 * Saves annotation to DB 882 */ 883 Zotero.Annotation.prototype.save = function() { 884 var text = this.textarea.value; 885 886 // fetch marker location 887 if(this.node.getAttribute && this.node.getAttribute("zotero") == "annotation-marker") { 888 var node = this.node.previousSibling; 889 890 if(node.nodeType != Components.interfaces.nsIDOMNode.TEXT_NODE) { 891 // someone added a highlight around this annotation 892 node = node.lastChild; 893 } 894 var offset = node.nodeValue.length; 895 } else { 896 var node = this.node; 897 var offset = 0; 898 } 899 900 // fetch path to node 901 var path = new Zotero.Annotate.Path(this.document, this.nsResolver); 902 path.fromNode(node, offset); 903 904 var parameters = [ 905 this.annotationsObj.itemID, // itemID 906 path.parent, // parent 907 path.textNode, // textNode 908 path.offset, // offset 909 this.x, // x 910 this.y, // y 911 this.cols, // cols 912 this.rows, // rows 913 text, // text 914 (this.collapsed ? 1 : 0) // collapsed 915 ]; 916 917 if(this.annotationID) { 918 var query = "INSERT OR REPLACE INTO annotations VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATETIME('now'))"; 919 parameters.unshift(this.annotationID); 920 } else { 921 var query = "INSERT INTO annotations VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATETIME('now'))"; 922 } 923 924 Zotero.DB.query(query, parameters); 925 } 926 927 /** 928 * Displays annotation 929 */ 930 Zotero.Annotation.prototype.display = function() { 931 if(!this.node) throw "Annotation not initialized!"; 932 933 var x = 0, y = 0; 934 935 // first fetch the coordinates 936 var pixelOffset = Zotero.Annotate.getPixelOffset(this.node); 937 938 var x = pixelOffset[0] + this.x; 939 var y = pixelOffset[1] + this.y; 940 941 // then display 942 this.displayWithAbsoluteCoordinates(x, y); 943 } 944 945 /** 946 * Displays annotation given absolute coordinates for its position 947 */ 948 Zotero.Annotation.prototype.displayWithAbsoluteCoordinates = function(absX, absY, select) { 949 if(!this.node) throw "Annotation not initialized!"; 950 951 var startScroll = this.window.scrollMaxX; 952 953 if(!this.iframe) { 954 var me = this; 955 var body = this.document.getElementsByTagName("body")[0]; 956 957 const style = "position: absolute; margin: 0; padding: 0; border: none; overflow: hidden; "; 958 959 // generate regular div 960 this.iframe = this.document.createElement("iframe"); 961 this.iframe.setAttribute("zotero", "annotation"); 962 this.iframe.setAttribute("style", style+" -moz-opacity: 0.9;"); 963 this.iframe.setAttribute("src", "zotero://attachment/annotation.html"); 964 body.appendChild(this.iframe); 965 this.iframe.addEventListener("load", function() { 966 me._addChildElements(select); 967 me.iframe.style.display = (me.collapsed ? "none" : "block"); 968 }, false); 969 970 // generate pushpin image 971 this.pushpinDiv = this.document.createElement("img"); 972 this.pushpinDiv.setAttribute("style", style+" cursor: pointer;"); 973 this.pushpinDiv.setAttribute("src", "zotero://attachment/annotation-hidden.gif"); 974 this.pushpinDiv.setAttribute("title", Zotero.getString("annotations.expand.tooltip")); 975 body.appendChild(this.pushpinDiv); 976 this.pushpinDiv.style.display = (this.collapsed ? "block" : "none"); 977 this.pushpinDiv.addEventListener("click", function() { me.setCollapsed(false) }, false); 978 } 979 this.iframe.style.left = this.pushpinDiv.style.left = absX+"px"; 980 this.iframeX = absX; 981 this.iframe.style.top = this.pushpinDiv.style.top = absY+"px"; 982 this.iframeY = absY; 983 this.pushpinDiv.style.zIndex = this.iframe.style.zIndex = this.annotationsObj.zIndex; 984 985 // move to the left if we're making things scroll 986 if(absX + this.iframe.scrollWidth > this.window.innerWidth) { 987 this.iframe.style.left = (absX-this.iframe.scrollWidth)+"px"; 988 this.iframeX = absX-this.iframe.scrollWidth; 989 } 990 } 991 992 /** 993 * Collapses or uncollapses annotation 994 * 995 * @param {Boolean} status True to collapse, false to uncollapse 996 */ 997 Zotero.Annotation.prototype.setCollapsed = function(status) { 998 if(status == true) { // hide iframe 999 this.iframe.style.display = "none"; 1000 this.pushpinDiv.style.display = "block"; 1001 this.collapsed = true; 1002 } else { // hide pushpin div 1003 this.pushpinDiv.style.display = "none"; 1004 this.iframe.style.display = "block"; 1005 this.collapsed = false; 1006 } 1007 } 1008 1009 /** 1010 * Generates a marker within a paragraph for this annotation. Such markers will remain in place 1011 * even if the DOM is changed, e.g., by highlighting 1012 * 1013 * @param {Integer} offset Text offset within parent node 1014 * @private 1015 */ 1016 Zotero.Annotation.prototype._generateMarker = function(offset) { 1017 // first, we create a new span at the correct offset in the node 1018 var range = this.document.createRange(); 1019 range.setStart(this.node, offset); 1020 range.setEnd(this.node, offset); 1021 1022 // next, we delete the old node, if there is one 1023 if(this.node && this.node.getAttribute && this.node.getAttribute("zotero") == "annotation-marker") { 1024 this.node.parentNode.removeChild(this.node); 1025 this.node = undefined; 1026 } 1027 1028 // next, we insert a span 1029 this.node = this.document.createElement("span"); 1030 this.node.setAttribute("zotero", "annotation-marker"); 1031 range.insertNode(this.node); 1032 } 1033 1034 /** 1035 * Prepare iframe representing this annotation 1036 * 1037 * @param {Boolean} select Whether to select the textarea once iframe is prepared 1038 * @private 1039 */ 1040 Zotero.Annotation.prototype._addChildElements = function(select) { 1041 var me = this; 1042 this.iframeDoc = this.iframe.contentDocument; 1043 1044 // close 1045 var img = this.iframeDoc.getElementById("close"); 1046 img.title = Zotero.getString("annotations.close.tooltip"); 1047 img.addEventListener("click", function(e) { me._confirmDelete(e) }, false); 1048 1049 // move 1050 this.moveImg = this.iframeDoc.getElementById("move"); 1051 this.moveImg.title = Zotero.getString("annotations.move.tooltip"); 1052 this.moveImg.addEventListener("click", function(e) { me._startMove(e) }, false); 1053 1054 // hide 1055 img = this.iframeDoc.getElementById("collapse"); 1056 img.title = Zotero.getString("annotations.collapse.tooltip"); 1057 img.addEventListener("click", function(e) { me.setCollapsed(true) }, false); 1058 1059 // collapse 1060 this.grippyDiv = this.iframeDoc.getElementById("grippy"); 1061 this.grippyDiv.addEventListener("mousedown", function(e) { me._startDrag(e) }, false); 1062 1063 // text area 1064 this.textarea = this.iframeDoc.getElementById("text"); 1065 this.textarea.setAttribute("zotero", "annotation"); 1066 this.textarea.cols = this.cols; 1067 this.textarea.rows = this.rows; 1068 1069 this.iframe.style.width = (6+this.textarea.offsetWidth)+"px"; 1070 this.iframe.style.height = this.iframeDoc.body.offsetHeight+"px"; 1071 this.iframeDoc.addEventListener("click", function() { me._click() }, false); 1072 1073 if(select) this.textarea.select(); 1074 } 1075 1076 /** 1077 * Brings annotation to the foreground 1078 * @private 1079 */ 1080 Zotero.Annotation.prototype._click = function() { 1081 // clear current action 1082 this.annotationsObj.Zotero_Browser.toggleMode(null); 1083 1084 // alter z-index 1085 this.annotationsObj.zIndex++ 1086 this.iframe.style.zIndex = this.pushpinDiv.style.zIndex = this.annotationsObj.zIndex; 1087 } 1088 1089 /** 1090 * Asks user to confirm deletion of this annotation 1091 * @private 1092 */ 1093 Zotero.Annotation.prototype._confirmDelete = function(event) { 1094 if (this.textarea.value == '' || !Zotero.Prefs.get('annotations.warnOnClose')) { 1095 var del = true; 1096 } else { 1097 var promptService = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] 1098 .getService(Components.interfaces.nsIPromptService); 1099 1100 var dontShowAgain = { value: false }; 1101 var del = promptService.confirmCheck( 1102 this.window, 1103 Zotero.getString('annotations.confirmClose.title'), 1104 Zotero.getString('annotations.confirmClose.body'), 1105 Zotero.getString('general.dontShowWarningAgain'), 1106 dontShowAgain 1107 ); 1108 1109 if (dontShowAgain.value) { 1110 Zotero.Prefs.set('annotations.warnOnClose', false); 1111 } 1112 } 1113 1114 if(del) this._delete(); 1115 } 1116 1117 /** 1118 * Deletes this annotation 1119 * @private 1120 */ 1121 Zotero.Annotation.prototype._delete = function() { 1122 if(this.annotationID) { 1123 Zotero.DB.query("DELETE FROM annotations WHERE annotationID = ?", [this.annotationID]); 1124 } 1125 1126 // hide div 1127 this.iframe.parentNode.removeChild(this.iframe); 1128 // delete from list 1129 for(var i in this.annotationsObj.annotations) { 1130 if(this.annotationsObj.annotations[i] == this) { 1131 this.annotationsObj.annotations.splice(i, 1); 1132 } 1133 } 1134 } 1135 1136 /** 1137 * Called to begin resizing the annotation 1138 * 1139 * @param {Event} e DOM event corresponding to click on the grippy 1140 * @private 1141 */ 1142 Zotero.Annotation.prototype._startDrag = function(e) { 1143 var me = this; 1144 1145 this.clickStartX = e.screenX; 1146 this.clickStartY = e.screenY; 1147 this.clickStartCols = this.textarea.cols; 1148 this.clickStartRows = this.textarea.rows; 1149 1150 /** 1151 * Listener to handle mouse moves 1152 * @inner 1153 */ 1154 var handleDrag = function(e) { me._doDrag(e); }; 1155 this.iframeDoc.addEventListener("mousemove", handleDrag, false); 1156 this.document.addEventListener("mousemove", handleDrag, false); 1157 1158 /** 1159 * Listener to call when mouse is let up 1160 * @inner 1161 */ 1162 var endDrag = function() { 1163 me.iframeDoc.removeEventListener("mousemove", handleDrag, false); 1164 me.document.removeEventListener("mousemove", handleDrag, false); 1165 me.iframeDoc.removeEventListener("mouseup", endDrag, false); 1166 me.document.removeEventListener("mouseup", endDrag, false); 1167 me.dragging = false; 1168 } 1169 this.iframeDoc.addEventListener("mouseup", endDrag, false); 1170 this.document.addEventListener("mouseup", endDrag, false); 1171 1172 // stop propagation 1173 e.stopPropagation(); 1174 e.preventDefault(); 1175 } 1176 1177 /** 1178 * Called when mouse is moved while annotation is being resized 1179 * 1180 * @param {Event} e DOM event corresponding to mouse move 1181 * @private 1182 */ 1183 Zotero.Annotation.prototype._doDrag = function(e) { 1184 var x = e.screenX - this.clickStartX; 1185 var y = e.screenY - this.clickStartY; 1186 1187 // update sizes 1188 var colSize = this.textarea.clientWidth/this.textarea.cols; 1189 var rowSize = this.textarea.clientHeight/this.textarea.rows; 1190 1191 // update cols and rows 1192 var cols = this.clickStartCols+Math.floor(x/colSize); 1193 cols = (cols > 5 ? cols : 5); 1194 this.textarea.cols = this.cols = cols; 1195 1196 var rows = this.clickStartRows+Math.floor(y/rowSize); 1197 rows = (rows > 2 ? rows : 2); 1198 this.textarea.rows = this.rows = rows; 1199 1200 this.iframe.style.width = (6+this.textarea.offsetWidth)+"px"; 1201 this.iframe.style.height = this.iframe.contentDocument.body.offsetHeight+"px"; 1202 } 1203 1204 /** 1205 * Called to begin moving the annotation 1206 * 1207 * @param {Event} e DOM event corresponding to click on the grippy 1208 * @private 1209 */ 1210 Zotero.Annotation.prototype._startMove = function(e) { 1211 // stop propagation 1212 e.stopPropagation(); 1213 e.preventDefault(); 1214 1215 var body = this.document.getElementsByTagName("body")[0]; 1216 1217 // deactivate current action 1218 this.annotationsObj.Zotero_Browser.toggleMode(null); 1219 1220 var me = this; 1221 // set the handler required to deactivate 1222 1223 /** 1224 * Callback to end move action 1225 * @inner 1226 */ 1227 this.annotationsObj.clearAction = function() { 1228 me.document.removeEventListener("click", me._handleMove, false); 1229 body.style.cursor = "auto"; 1230 me.moveImg.src = "zotero://attachment/annotation-move.png"; 1231 me.annotationsObj.clearAction = undefined; 1232 } 1233 1234 /** 1235 * Listener to handle mouse moves on main page 1236 * @inner 1237 */ 1238 var handleMoveMouse1 = function(e) { 1239 me.displayWithAbsoluteCoordinates(e.pageX + 1, e.pageY + 1); 1240 }; 1241 /** 1242 * Listener to handle mouse moves in iframe 1243 * @inner 1244 */ 1245 var handleMoveMouse2 = function(e) { 1246 me.displayWithAbsoluteCoordinates(e.pageX + me.iframeX + 1, e.pageY + me.iframeY + 1); 1247 }; 1248 this.document.addEventListener("mousemove", handleMoveMouse1, false); 1249 this.iframeDoc.addEventListener("mousemove", handleMoveMouse2, false); 1250 1251 /** 1252 * Listener to finish off move when a click is made 1253 * @inner 1254 */ 1255 var handleMove = function(e) { 1256 me.document.removeEventListener("mousemove", handleMoveMouse1, false); 1257 me.iframeDoc.removeEventListener("mousemove", handleMoveMouse2, false); 1258 me.document.removeEventListener("click", handleMove, false); 1259 1260 me.initWithEvent(e); 1261 me.annotationsObj.clearAction(); 1262 1263 // stop propagation 1264 e.stopPropagation(); 1265 e.preventDefault(); 1266 }; 1267 this.document.addEventListener("click", handleMove, false); 1268 1269 body.style.cursor = "pointer"; 1270 this.moveImg.src = "zotero://attachment/annotation-move-selected.png"; 1271 } 1272 1273 /** 1274 * @class Represents an individual highlighted range 1275 * 1276 * @constructor 1277 * @param {Zotero.Annotations} annotationsObj The Zotero.Annotations object corresponding to the 1278 * page this highlight is on 1279 */ 1280 Zotero.Highlight = function(annotationsObj) { 1281 this.annotationsObj = annotationsObj; 1282 this.window = annotationsObj.browser.contentWindow; 1283 this.document = annotationsObj.browser.contentDocument; 1284 this.nsResolver = annotationsObj.nsResolver; 1285 1286 this.spans = new Array(); 1287 } 1288 1289 /** 1290 * Gets the highlighted DOM range 1291 * @return {Range} DOM range 1292 */ 1293 Zotero.Highlight.prototype.getRange = function() { 1294 this.range = this.document.createRange(); 1295 var startContainer, startOffset, endContainer, endOffset; 1296 [startContainer, startOffset] = this.startPath.toNode(); 1297 [endContainer, endOffset] = this.endPath.toNode(); 1298 1299 if(!startContainer || !endContainer) { 1300 throw("Annotate: PATH ERROR in highlight module!"); 1301 } 1302 1303 this.range.setStart(startContainer, startOffset); 1304 this.range.setEnd(endContainer, endOffset); 1305 return this.range; 1306 } 1307 1308 /** 1309 * Generates a highlight representing the given DB row 1310 */ 1311 Zotero.Highlight.prototype.initWithDBRow = function(row) { 1312 this.startPath = new Zotero.Annotate.Path(this.document, this.nsResolver, row.startParent, 1313 row.startTextNode, row.startOffset); 1314 this.endPath = new Zotero.Annotate.Path(this.document, this.nsResolver, row.endParent, 1315 row.endTextNode, row.endOffset); 1316 this.getRange(); 1317 this._highlight(); 1318 } 1319 1320 /** 1321 * Generates a highlight representing given a DOM range 1322 * 1323 * @param {Range} range DOM range 1324 * @param {Zotero.Annotate.Path} startPath Path representing start of range 1325 * @param {Zotero.Annotate.Path} endPath Path representing end of range 1326 */ 1327 Zotero.Highlight.prototype.initWithRange = function(range, startPath, endPath) { 1328 this.startPath = startPath; 1329 this.endPath = endPath; 1330 this.range = range; 1331 this._highlight(); 1332 } 1333 1334 /** 1335 * Saves this highlight to the DB 1336 */ 1337 Zotero.Highlight.prototype.save = function() { 1338 // don't save defective highlights 1339 if(this.startPath.parent == this.endPath.parent 1340 && this.startPath.textNode == this.endPath.textNode 1341 && this.startPath.offset == this.endPath.offset) { 1342 return false; 1343 } 1344 1345 var query = "INSERT INTO highlights VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, DATETIME('now'))"; 1346 var parameters = [ 1347 this.annotationsObj.itemID, // itemID 1348 this.startPath.parent, // startParent 1349 (this.startPath.textNode ? this.startPath.textNode : null), // startTextNode 1350 (this.startPath.offset || this.startPath.offset === 0 ? this.startPath.offset : null), // startOffset 1351 this.endPath.parent, // endParent 1352 (this.endPath.textNode ? this.endPath.textNode : null), // endTextNode 1353 (this.endPath.offset || this.endPath.offset === 0 ? this.endPath.offset: null) // endOffset 1354 ]; 1355 1356 Zotero.DB.query(query, parameters); 1357 } 1358 1359 Zotero.Highlight.UNHIGHLIGHT_ALL = 0; 1360 Zotero.Highlight.UNHIGHLIGHT_TO_POINT = 1; 1361 Zotero.Highlight.UNHIGHLIGHT_FROM_POINT = 2; 1362 1363 /** 1364 * Un-highlights a range 1365 * 1366 * @param {Node} container Node to highlight/unhighlight from, or null if mode == UNHIGHLIGHT_ALL 1367 * @param {Integer} offset Text offset, or null if mode == UNHIGHLIGHT_ALL 1368 * @param {Zotero.Annotate.Path} path Path representing node, offset combination, or null 1369 * if mode == UNHIGHLIGHT_ALL 1370 * @param {Integer} mode Unhighlight mode 1371 */ 1372 Zotero.Highlight.prototype.unhighlight = function(container, offset, path, mode) { 1373 this.getRange(); 1374 1375 if(mode == 1) { 1376 this.range.setStart(container, offset); 1377 this.startPath = path; 1378 } else if(mode == 2) { 1379 this.range.setEnd(container, offset); 1380 this.endPath = path; 1381 } 1382 1383 var length = this.spans.length; 1384 for(var i=0; i<length; i++) { 1385 var span = this.spans[i]; 1386 if(!span) continue; 1387 var parentNode = span.parentNode; 1388 1389 if(mode != 0 && span === container.parentNode && offset != 0) { 1390 if(mode == 1) { 1391 // split text node 1392 var textNode = container.splitText(offset); 1393 this.range.setStart(container, offset); 1394 1395 // loop through, removing nodes 1396 var node = span.firstChild; 1397 1398 while(span.firstChild && span.firstChild !== textNode) { 1399 parentNode.insertBefore(span.removeChild(span.firstChild), span); 1400 } 1401 } else if(mode == 2) { 1402 // split text node 1403 var textNode = container.splitText(offset); 1404 1405 // loop through, removing nodes 1406 var node = textNode; 1407 var nextNode = span.nextSibling ? span.nextSibling : null; 1408 var child; 1409 while(node) { 1410 child = node; 1411 node = node.nextSibling; 1412 parentNode.insertBefore(span.removeChild(child), nextNode); 1413 } 1414 1415 this.range.setEnd(span.lastChild, span.lastChild.nodeValue.length); 1416 } 1417 } else if((mode == 0 || !this.range.isPointInRange(span, 0)) && parentNode) { 1418 // attach child nodes before 1419 while(span.hasChildNodes()) { 1420 parentNode.insertBefore(span.removeChild(span.firstChild), span); 1421 } 1422 1423 // remove span from DOM 1424 parentNode.removeChild(span); 1425 1426 // remove span from list 1427 this.spans.splice(i, 1); 1428 i--; 1429 } 1430 } 1431 1432 this.document.normalize(); 1433 } 1434 1435 /** 1436 * Actually highlights the range this object refers to 1437 * @private 1438 */ 1439 Zotero.Highlight.prototype._highlight = function() { 1440 var endUpdated = false; 1441 var startNode = this.range.startContainer; 1442 var endNode = this.range.endContainer; 1443 1444 var ancestor = this.range.commonAncestorContainer; 1445 1446 var onlyOneNode = startNode === endNode; 1447 1448 if(!onlyOneNode && startNode !== ancestor && endNode !== ancestor) { 1449 // highlight nodes after start node in the DOM hierarchy not at ancestor level 1450 while(startNode.parentNode && startNode.parentNode !== ancestor) { 1451 if(startNode.nextSibling) { 1452 this._highlightSpaceBetween(startNode.nextSibling, startNode.parentNode.lastChild); 1453 } 1454 startNode = startNode.parentNode 1455 } 1456 // highlight nodes after end node in the DOM hierarchy not at ancestor level 1457 while(endNode.parentNode && endNode.parentNode !== ancestor) { 1458 if(endNode.previousSibling) { 1459 this._highlightSpaceBetween(endNode.parentNode.firstChild, endNode.previousSibling); 1460 } 1461 endNode = endNode.parentNode 1462 } 1463 // highlight nodes between start node and end node at ancestor level 1464 if(startNode !== endNode.previousSibling) { 1465 this._highlightSpaceBetween(startNode.nextSibling, endNode.previousSibling); 1466 } 1467 } 1468 1469 // split the end off the existing node 1470 if(this.range.endContainer.nodeType == Components.interfaces.nsIDOMNode.TEXT_NODE && this.range.endOffset != 0) { 1471 if(this.range.endOffset != this.range.endContainer.nodeValue.length) { 1472 var textNode = this.range.endContainer.splitText(this.range.endOffset); 1473 } 1474 if(!onlyOneNode) { 1475 var span = this._highlightTextNode(this.range.endContainer); 1476 this.range.setEnd(span.lastChild, span.lastChild.nodeValue.length); 1477 endUpdated = true; 1478 } else if(textNode) { 1479 this.range.setEnd(textNode, 0); 1480 endUpdated = true; 1481 } 1482 } 1483 1484 // split the start off of the first node 1485 if(this.range.startContainer.nodeType == Components.interfaces.nsIDOMNode.TEXT_NODE) { 1486 if(!this.range.startOffset) { 1487 var highlightNode = this.range.startContainer; 1488 } else { 1489 var highlightNode = this.range.startContainer.splitText(this.range.startOffset); 1490 } 1491 var span = this._highlightTextNode(highlightNode); 1492 } else { 1493 var span = this._highlightSpaceBetween(this.range.startContainer, this.range.endContainer); 1494 } 1495 1496 this.range.setStart(span.firstChild, 0); 1497 if(onlyOneNode && !endUpdated) { 1498 this.range.setEnd(span.lastChild, span.lastChild.nodeValue.length); 1499 } 1500 1501 this.document.normalize(); 1502 } 1503 1504 /** 1505 * Highlights a single text node 1506 * 1507 * @param {Node} textNode 1508 * @return {Node} Span including the highlighted text 1509 * @private 1510 */ 1511 Zotero.Highlight.prototype._highlightTextNode = function(textNode) { 1512 if(!textNode) return; 1513 var parent = textNode.parentNode; 1514 1515 var span = false; 1516 var saveSpan = true; 1517 1518 var alreadyHighlighted = parent.getAttribute("zotero") == "highlight"; 1519 1520 var nextSibling = (alreadyHighlighted ? textNode.parentNode.nextSibling : textNode.nextSibling); 1521 var previousSibling = (alreadyHighlighted ? textNode.parentNode.previousSibling : textNode.previousSibling); 1522 var previousSiblingHighlighted = previousSibling && previousSibling.getAttribute && 1523 previousSibling.getAttribute("zotero") == "highlight"; 1524 var nextSiblingHighlighted = nextSibling && nextSibling.getAttribute && 1525 nextSibling.getAttribute("zotero") == "highlight"; 1526 1527 if(alreadyHighlighted) { 1528 if(previousSiblingHighlighted || nextSiblingHighlighted) { 1529 // merge with previous sibling 1530 while(parent.firstChild) { 1531 if(previousSiblingHighlighted) { 1532 previousSibling.appendChild(parent.removeChild(parent.firstChild)); 1533 } else { 1534 nextSibling.insertBefore(parent.removeChild(parent.firstChild), 1535 (nextSibling.firstChild ? nextSibling.firstChild : null)); 1536 } 1537 } 1538 parent.parentNode.removeChild(parent); 1539 // look for span in this.spans and delete it if it's there 1540 var span = previousSiblingHighlighted ? previousSibling : nextSibling; 1541 for(var i=0; i<this.spans.length; i++) { 1542 if(parent === this.spans[i]) { 1543 this.spans.splice(i, 1); 1544 i--; 1545 } else if(span === this.spans[i]) { 1546 saveSpan = false; 1547 } 1548 } 1549 } else { 1550 span = parent; 1551 } 1552 } else if(previousSiblingHighlighted) { 1553 previousSibling.appendChild(parent.removeChild(textNode)); 1554 1555 var span = previousSibling; 1556 for(var i=0; i<this.spans.length; i++) { 1557 if(span === this.spans[i]) saveSpan = false; 1558 } 1559 } else if(nextSiblingHighlighted) { 1560 nextSibling.insertBefore(parent.removeChild(textNode), nextSibling.firstChild); 1561 1562 var span = nextSibling; 1563 for(var i=0; i<this.spans.length; i++) { 1564 if(span === this.spans[i]) saveSpan = false; 1565 } 1566 } else { 1567 var previousSibling = textNode.previousSibling; 1568 1569 var span = this.document.createElement("span"); 1570 span.setAttribute("zotero", "highlight"); 1571 span.style.display = "inline"; 1572 span.style.backgroundColor = Zotero.Annotate.highlightColor; 1573 1574 var computedColor = this.document.defaultView.getComputedStyle(parent, null).color; 1575 if(computedColor) { 1576 var distance1 = Zotero.Annotate.getColorDistance(computedColor, Zotero.Annotate.highlightColor) 1577 if(distance1 <= 180) { 1578 var distance2 = Zotero.Annotate.getColorDistance(computedColor, Zotero.Annotate.alternativeHighlightColor); 1579 if(distance2 > distance1) { 1580 span.style.backgroundColor = Zotero.Annotate.alternativeHighlightColor; 1581 } 1582 } 1583 } 1584 1585 span.appendChild(parent.removeChild(textNode)); 1586 parent.insertBefore(span, (nextSibling ? nextSibling : null)); 1587 } 1588 1589 if(span && saveSpan) this.spans.push(span); 1590 return span; 1591 } 1592 1593 /** 1594 * Highlights the space between two nodes at the same level 1595 * 1596 * @param {Node} start 1597 * @param {Node} end 1598 * @return {Node} Span containing the first block of highlighted text 1599 * @private 1600 */ 1601 Zotero.Highlight.prototype._highlightSpaceBetween = function(start, end) { 1602 var firstSpan = false; 1603 var node = start; 1604 var text; 1605 1606 while(node) { 1607 // process nodes 1608 if(node.nodeType == Components.interfaces.nsIDOMNode.TEXT_NODE) { 1609 var textArray = [node]; 1610 } else { 1611 var texts = this.document.evaluate('.//text()', node, this.nsResolver, 1612 Components.interfaces.nsIDOMXPathResult.ORDERED_NODE_ITERATOR_TYPE, null); 1613 var textArray = new Array() 1614 while(text = texts.iterateNext()) textArray.push(text); 1615 } 1616 1617 // do this in the middle, after we're finished with node but before we add any spans 1618 if(node === end) { 1619 node = false; 1620 } else { 1621 node = node.nextSibling; 1622 } 1623 1624 for (let textNode of textArray) { 1625 if(firstSpan) { 1626 this._highlightTextNode(textNode); 1627 } else { 1628 firstSpan = this._highlightTextNode(textNode); 1629 } 1630 } 1631 } 1632 1633 return firstSpan; 1634 }