www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

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 }