www

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

quickFormat.js (43643B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2011 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 Components.utils.import("resource://gre/modules/Services.jsm");
     26 
     27 var Zotero_QuickFormat = new function () {
     28 	const pixelRe = /^([0-9]+)px$/
     29 	const specifiedLocatorRe = /^(?:,? *(p{0,2})(?:\. *| +)|:)([0-9\-]+) *$/;
     30 	const yearRe = /,? *([0-9]+) *(B[. ]*C[. ]*(?:E[. ]*)?|A[. ]*D[. ]*|C[. ]*E[. ]*)?$/i;
     31 	const locatorRe = /(?:,? *(p{0,2})\.?|(\:)) *([0-9\-–]+)$/i;
     32 	const creatorSplitRe = /(?:,| *(?:and|\&)) +/;
     33 	const charRe = /[\w\u007F-\uFFFF]/;
     34 	const numRe = /^[0-9\-–]+$/;
     35 	
     36 	var initialized, io, qfs, qfi, qfiWindow, qfiDocument, qfe, qfb, qfbHeight, qfGuidance,
     37 		keepSorted,  showEditor, referencePanel, referenceBox, referenceHeight = 0,
     38 		separatorHeight = 0, currentLocator, currentLocatorLabel, currentSearchTime, dragging,
     39 		panel, panelPrefix, panelSuffix, panelSuppressAuthor, panelLocatorLabel, panelLocator,
     40 		panelLibraryLink, panelInfo, panelRefersToBubble, panelFrameHeight = 0, accepted = false;
     41 	var _searchPromise;
     42 	
     43 	const SEARCH_TIMEOUT = 250;
     44 	const SHOWN_REFERENCES = 7;
     45 	
     46 	/**
     47 	 * Pre-initialization, when the dialog has loaded but has not yet appeared
     48 	 */
     49 	this.onDOMContentLoaded = function(event) {
     50 		if(event.target === document) {
     51 			initialized = true;
     52 			io = window.arguments[0].wrappedJSObject;
     53 			
     54 			// Only hide chrome on Windows or Mac
     55 			if(Zotero.isMac) {
     56 				document.documentElement.setAttribute("drawintitlebar", true);
     57 			} else if(Zotero.isWin) {
     58 				document.documentElement.setAttribute("hidechrome", true);
     59 			}
     60 			
     61 			// Include a different key combo in message on Mac
     62 			if(Zotero.isMac) {
     63 				var qf = document.getElementById('quick-format-guidance');
     64 				qf.setAttribute('about', qf.getAttribute('about') + "Mac");
     65 			}
     66 			
     67 			new WindowDraggingElement(document.getElementById("quick-format-dialog"), window);
     68 			
     69 			qfs = document.getElementById("quick-format-search");
     70 			qfi = document.getElementById("quick-format-iframe");
     71 			qfb = document.getElementById("quick-format-entry");
     72 			qfbHeight = qfb.scrollHeight;
     73 			referencePanel = document.getElementById("quick-format-reference-panel");
     74 			referenceBox = document.getElementById("quick-format-reference-list");
     75 			
     76 			if(Zotero.isWin && Zotero.Prefs.get('integration.keepAddCitationDialogRaised')) {
     77 				qfb.setAttribute("square", "true");
     78 			}
     79 			
     80 			// add labels to popup
     81 			var locators = Zotero.Cite.labels;
     82 			var menu = document.getElementById("locator-label");
     83 			var labelList = document.getElementById("locator-label-popup");
     84 			for(var locator of locators) {
     85 				var locatorLabel = Zotero.getString('citation.locator.'+locator.replace(/\s/g,''));
     86 				
     87 				// add to list of labels
     88 				var child = document.createElement("menuitem");
     89 				child.setAttribute("value", locator);
     90 				child.setAttribute("label", locatorLabel);
     91 				labelList.appendChild(child);
     92 			}
     93 			menu.selectedIndex = 0;
     94 			
     95 			keepSorted = document.getElementById("keep-sorted");
     96 			showEditor = document.getElementById("show-editor");
     97 			if(io.sortable) {
     98 				keepSorted.hidden = false;
     99 				if(!io.citation.properties.unsorted) {
    100 					keepSorted.setAttribute("checked", "true");
    101 				}
    102 			}
    103 			
    104 			// Nodes for citation properties panel
    105 			panel = document.getElementById("citation-properties");
    106 			panelPrefix = document.getElementById("prefix");
    107 			panelSuffix = document.getElementById("suffix");
    108 			panelSuppressAuthor = document.getElementById("suppress-author");
    109 			panelLocatorLabel = document.getElementById("locator-label");
    110 			panelLocator = document.getElementById("locator");
    111 			panelInfo = document.getElementById("citation-properties-info");
    112 			panelLibraryLink = document.getElementById("citation-properties-library-link");
    113 			
    114 			// Don't need to set noautohide dynamically on these platforms, so do it now
    115 			if(Zotero.isMac || Zotero.isWin) {
    116 				referencePanel.setAttribute("noautohide", true);
    117 			}
    118 		} else if(event.target === qfi.contentDocument) {			
    119 			qfiWindow = qfi.contentWindow;
    120 			qfiDocument = qfi.contentDocument;
    121 			qfb.addEventListener("keypress", _onQuickSearchKeyPress, false);
    122 			qfe = qfiDocument.getElementById("quick-format-editor");
    123 			qfe.addEventListener("drop", _onBubbleDrop, false);
    124 			qfe.addEventListener("paste", _onPaste, false);
    125 		}
    126 	}
    127 	
    128 	/**
    129 	 * Initialize add citation dialog
    130 	 */
    131 	this.onLoad = function(event) {
    132 		if(event.target !== document) return;		
    133 		// make sure we are visible
    134 		window.setTimeout(function() {
    135 			window.resizeTo(window.outerWidth, qfb.clientHeight);
    136 			var screenX = window.screenX;
    137 			var screenY = window.screenY;
    138 			var xRange = [window.screen.availLeft, window.screen.width-window.outerWidth];
    139 			var yRange = [window.screen.availTop, window.screen.height-window.outerHeight];
    140 			if(screenX < xRange[0] || screenX > xRange[1] || screenY < yRange[0] || screenY > yRange[1]) {
    141 				var targetX = Math.max(Math.min(screenX, xRange[1]), xRange[0]);
    142 				var targetY = Math.max(Math.min(screenY, yRange[1]), yRange[0]);
    143 				Zotero.debug("Moving window to "+targetX+", "+targetY);
    144 				window.moveTo(targetX, targetY);
    145 			}
    146 			qfGuidance = document.getElementById('quick-format-guidance');
    147 			qfGuidance.show();
    148 			_refocusQfe();
    149 		}, 0);
    150 		
    151 		window.focus();
    152 		qfe.focus();
    153 		
    154 		// load citation data
    155 		if(io.citation.citationItems.length) {
    156 			// hack to get spacing right
    157 			var evt = qfiDocument.createEvent("KeyboardEvent");
    158 			evt.initKeyEvent("keypress", true, true, qfiWindow,
    159 				0, 0, 0, 0,
    160 				0, " ".charCodeAt(0))
    161 			qfe.dispatchEvent(evt);
    162 			window.setTimeout(function() {				
    163 				var node = qfe.firstChild;
    164 				node.nodeValue = "";
    165 				_showCitation(node);
    166 				_resize();
    167 			}, 1);
    168 		}
    169 	};
    170 	
    171 	function _refocusQfe() {
    172 		referencePanel.blur();
    173 		window.focus();
    174 		qfe.focus();
    175 	}
    176 	
    177 	/**
    178 	 * Gets the content of the text node that the cursor is currently within
    179 	 */
    180 	function _getCurrentEditorTextNode() {
    181 		var selection = qfiWindow.getSelection();
    182 		if (!selection) return false;
    183 		var range = selection.getRangeAt(0);
    184 		
    185 		var node = range.startContainer;
    186 		if(node !== range.endContainer) return false;
    187 		if(node.nodeType === Node.TEXT_NODE) return node;
    188 
    189 		// Range could be referenced to the body element
    190 		if(node === qfe) {
    191 			var offset = range.startOffset;
    192 			if(offset !== range.endOffset) return false;
    193 			node = qfe.childNodes[Math.min(qfe.childNodes.length-1, offset)];
    194 			if(node.nodeType === Node.TEXT_NODE) return node;
    195 		}
    196 		return false;
    197 	}
    198 	
    199 	/**
    200 	 * Gets text within the currently selected node
    201 	 * @param {Boolean} [clear] If true, also remove these nodes
    202 	 */
    203 	function _getEditorContent(clear) {
    204 		var node = _getCurrentEditorTextNode();
    205 		return node ? node.wholeText : false;
    206 	}
    207 	
    208 	/**
    209 	 * Does the dirty work of figuring out what the user meant to type
    210 	 */
    211 	var _quickFormat = Zotero.Promise.coroutine(function* () {
    212 		var str = _getEditorContent();
    213 		var haveConditions = false;
    214 		
    215 		const etAl = " et al.";
    216 		
    217 		var m,
    218 			year = false,
    219 			isBC = false,
    220 			dateID = false;
    221 		
    222 		currentLocator = false;
    223 		currentLocatorLabel = false;
    224 		
    225 		// check for adding a number onto a previous page number
    226 		if(numRe.test(str)) {
    227 			// add to previous cite
    228 			var node = _getCurrentEditorTextNode();
    229 			var prevNode = node.previousSibling;
    230 			if(prevNode && prevNode.citationItem && prevNode.citationItem.locator) {
    231 				prevNode.citationItem.locator += str;
    232 				prevNode.textContent = _buildBubbleString(prevNode.citationItem);
    233 				node.nodeValue = "";
    234 				_clearEntryList();
    235 				return;
    236 			}
    237 		}
    238 		
    239 		if(str && str.length > 1) {
    240 			// check for specified locator
    241 			m = specifiedLocatorRe.exec(str);
    242 			if(m) {
    243 				if(m.index === 0) {
    244 					// add to previous cite
    245 					var node = _getCurrentEditorTextNode();
    246 					var prevNode = node.previousSibling;
    247 					if(prevNode && prevNode.citationItem) {
    248 						prevNode.citationItem.locator = m[2];
    249 						prevNode.textContent = _buildBubbleString(prevNode.citationItem);
    250 						node.nodeValue = "";
    251 						_clearEntryList();
    252 						return;
    253 					}
    254 				}
    255 				
    256 				// TODO support types other than page
    257 				currentLocator = m[2];
    258 				str = str.substring(0, m.index);
    259 			}
    260 			
    261 			// check for year and pages
    262 			str = _updateLocator(str);
    263 			m = yearRe.exec(str);
    264 			if(m) {
    265 				year = parseInt(m[1]);
    266 				isBC = m[2] && m[2][0] === "B";
    267 				str = str.substr(0, m.index)+str.substring(m.index+m[0].length);
    268 			}
    269 			if(year) str += " "+year;
    270 			
    271 			var s = new Zotero.Search();
    272 			str = str.replace(/ (?:&|and) /g, " ", "g");
    273 			if(charRe.test(str)) {
    274 				Zotero.debug("QuickFormat: QuickSearch: "+str);
    275 				// Exclude feeds
    276 				Zotero.Feeds.getAll()
    277 					.forEach(feed => s.addCondition("libraryID", "isNot", feed.libraryID));
    278 				s.addCondition("quicksearch-titleCreatorYear", "contains", str);
    279 				s.addCondition("itemType", "isNot", "attachment");
    280 				haveConditions = true;
    281 			}
    282 		}
    283 		
    284 		if(haveConditions) {		
    285 			var searchResultIDs = (haveConditions ? (yield s.search()) : []);
    286 			
    287 			// Show items list without cited items to start
    288 			yield _updateItemList(false, false, str, searchResultIDs);
    289 			
    290 			// Check to see which search results match items already in the document
    291 			var citedItems, completed = false, isAsync = false;
    292 			// Save current search time so that when we get items, we know whether it's too late to
    293 			// process them or not
    294 			var lastSearchTime = currentSearchTime = Date.now();
    295 			// This may or may not be synchronous
    296 			io.getItems().then(function(citedItems) {
    297 				// Don't do anything if panel is already closed
    298 				if(isAsync &&
    299 						((referencePanel.state !== "open" && referencePanel.state !== "showing")
    300 						|| lastSearchTime !== currentSearchTime)) return;
    301 				
    302 				completed = true;
    303 				
    304 				if(str.toLowerCase() === Zotero.getString("integration.ibid").toLowerCase()) {
    305 					// If "ibid" is entered, show all cited items
    306 					citedItemsMatchingSearch = citedItems;
    307 				} else {
    308 					Zotero.debug("Searching cited items");
    309 					// Search against items. We do this here because it's possible that some of these
    310 					// items are only in the doc, and not in the DB.
    311 					var splits = Zotero.Fulltext.semanticSplitter(str),
    312 						citedItemsMatchingSearch = [];
    313 					for(var i=0, iCount=citedItems.length; i<iCount; i++) {
    314 						// Generate a string to search for each item
    315 						let item = citedItems[i];
    316 						let itemStr = item.getCreators()
    317 							.map(creator => creator.firstName + " " + creator.lastName)
    318 							.concat([item.getField("title"), item.getField("date", true, true).substr(0, 4)])
    319 							.join(" ");
    320 						
    321 						// See if words match
    322 						for(var j=0, jCount=splits.length; j<jCount; j++) {
    323 							var split = splits[j];
    324 							if(itemStr.toLowerCase().indexOf(split) === -1) break;
    325 						}
    326 						
    327 						// If matched, add to citedItemsMatchingSearch
    328 						if(j === jCount) citedItemsMatchingSearch.push(item);
    329 					}
    330 					Zotero.debug("Searched cited items");
    331 				}
    332 				
    333 				_updateItemList(citedItems, citedItemsMatchingSearch, str, searchResultIDs, isAsync);
    334 			});
    335 			
    336 			if(!completed) {
    337 				// We are going to have to wait until items have been retrieved from the document.
    338 				Zotero.debug("Getting cited items asynchronously");
    339 				isAsync = true;
    340 			} else {
    341 				Zotero.debug("Got cited items synchronously");
    342 			}
    343 		} else {
    344 			// No search conditions, so just clear the box
    345 			_updateItemList([], [], "", []);
    346 		}
    347 	});
    348 	
    349 	/**
    350 	 * Updates currentLocator based on a string
    351 	 * @param {String} str String to search for locator
    352 	 * @return {String} str without locator
    353 	 */
    354 	function _updateLocator(str) {
    355 		m = locatorRe.exec(str);
    356 		if(m && (m[1] || m[2] || m[3].length !== 4)) {
    357 			currentLocator = m[3];
    358 			str = str.substr(0, m.index)+str.substring(m.index+m[0].length);
    359 		}
    360 		return str;
    361 	}
    362 	
    363 	/**
    364 	 * Updates the item list
    365 	 */
    366 	var _updateItemList = Zotero.Promise.coroutine(function* (citedItems, citedItemsMatchingSearch,
    367 			searchString, searchResultIDs, preserveSelection) {
    368 		var selectedIndex = 1, previousItemID;
    369 		
    370 		// Do this so we can preserve the selected item after cited items have been loaded
    371 		if(preserveSelection && referenceBox.selectedIndex !== -1 && referenceBox.selectedIndex !== 2) {
    372 			previousItemID = parseInt(referenceBox.selectedItem.getAttribute("zotero-item"), 10);
    373 		}
    374 		
    375 		while(referenceBox.hasChildNodes()) referenceBox.removeChild(referenceBox.firstChild);
    376 		
    377 		var nCitedItemsFromLibrary = {};
    378 		if(!citedItems) {
    379 			// We don't know whether or not we have cited items, because we are waiting for document
    380 			// data
    381 			referenceBox.appendChild(_buildListSeparator(Zotero.getString("integration.cited.loading")));
    382 			selectedIndex = 2;
    383 		} else if(citedItems.length) {
    384 			// We have cited items
    385 			for(var i=0, n=citedItems.length; i<n; i++) {
    386 				var citedItem = citedItems[i];
    387 				// Tabulate number of items in document for each library
    388 				if(!citedItem.cslItemID) {
    389 					var libraryID = citedItem.libraryID;
    390 					if(libraryID in nCitedItemsFromLibrary) {
    391 						nCitedItemsFromLibrary[libraryID]++;
    392 					} else {
    393 						nCitedItemsFromLibrary[libraryID] = 1;
    394 					}
    395 				}
    396 			}
    397 			
    398 			if(citedItemsMatchingSearch && citedItemsMatchingSearch.length) {
    399 				referenceBox.appendChild(_buildListSeparator(Zotero.getString("integration.cited")));
    400 				for(var i=0; i<Math.min(citedItemsMatchingSearch.length, 50); i++) {
    401 					var citedItem = citedItemsMatchingSearch[i];
    402 					referenceBox.appendChild(_buildListItem(citedItem));
    403 				}
    404 			}
    405 		}
    406 		
    407 		// Also take into account items cited in this citation. This means that the sorting isn't
    408 		// exactly by # of items cited from each library, but maybe it's better this way.
    409 		_updateCitationObject();
    410 		for(var citationItem of io.citation.citationItems) {
    411 			var citedItem = Zotero.Cite.getItem(citationItem.id);
    412 			if(!citedItem.cslItemID) {
    413 				var libraryID = citedItem.libraryID;
    414 				if(libraryID in nCitedItemsFromLibrary) {
    415 					nCitedItemsFromLibrary[libraryID]++;
    416 				} else {
    417 					nCitedItemsFromLibrary[libraryID] = 1;
    418 				}
    419 			}
    420 		}
    421 
    422 		if(searchResultIDs.length && (!citedItemsMatchingSearch || citedItemsMatchingSearch.length < 50)) {
    423 			// Search results might be in an unloaded library, so get items asynchronously and load
    424 			// necessary data
    425 			var items = yield Zotero.Items.getAsync(searchResultIDs);
    426 			yield Zotero.Items.loadDataTypes(items);
    427 			
    428 			searchString = searchString.toLowerCase();
    429 			var collation = Zotero.getLocaleCollation();
    430 			
    431 			items.sort(function _itemSort(a, b) {
    432 				var firstCreatorA = a.firstCreator, firstCreatorB = b.firstCreator;
    433 				
    434 				// Favor left-bound name matches (e.g., "Baum" < "Appelbaum"),
    435 				// using last name of first author
    436 				if (firstCreatorA && firstCreatorB) {
    437 					let caStartsWith = firstCreatorA.toLowerCase().indexOf(searchString) == 0;
    438 					let cbStartsWith = firstCreatorB.toLowerCase().indexOf(searchString) == 0;
    439 					if (caStartsWith && !cbStartsWith) {
    440 						return -1;
    441 					}
    442 					else if (!caStartsWith && cbStartsWith) {
    443 						return 1;
    444 					}
    445 				}
    446 				
    447 				var libA = a.libraryID, libB = b.libraryID;
    448 				if(libA !== libB) {
    449 					// Sort by number of cites for library
    450 					if(nCitedItemsFromLibrary[libA] && !nCitedItemsFromLibrary[libB]) {
    451 						return -1;
    452 					}
    453 					if(!nCitedItemsFromLibrary[libA] && nCitedItemsFromLibrary[libB]) {
    454 						return 1;
    455 					}
    456 					if(nCitedItemsFromLibrary[libA] !== nCitedItemsFromLibrary[libB]) {
    457 						return nCitedItemsFromLibrary[libB] - nCitedItemsFromLibrary[libA];
    458 					}
    459 					
    460 					// Sort by ID even if number of cites is equal
    461 					return libA - libB;
    462 				}
    463 			
    464 				// Sort by last name of first author
    465 				if (firstCreatorA !== "" && firstCreatorB === "") {
    466 					return -1;
    467 				} else if (firstCreatorA === "" && firstCreatorB !== "") {
    468 					return 1
    469 				} else if (firstCreatorA) {
    470 					return collation.compareString(1, firstCreatorA, firstCreatorB);
    471 				}
    472 				
    473 				// Sort by date
    474 				var yearA = a.getField("date", true, true).substr(0, 4),
    475 					yearB = b.getField("date", true, true).substr(0, 4);
    476 				return yearA - yearB;
    477 			});
    478 			
    479 			var previousLibrary = -1;
    480 			for(var i=0, n=Math.min(items.length, citedItemsMatchingSearch ? 50-citedItemsMatchingSearch.length : 50); i<n; i++) {
    481 				var item = items[i], libraryID = item.libraryID;
    482 				
    483 				if(previousLibrary != libraryID) {
    484 					var libraryName = libraryID ? Zotero.Libraries.getName(libraryID)
    485 						: Zotero.getString('pane.collections.library');
    486 					referenceBox.appendChild(_buildListSeparator(libraryName));
    487 				}
    488 
    489 				referenceBox.appendChild(_buildListItem(item));
    490 				previousLibrary = libraryID;
    491 				
    492 				if(preserveSelection && (item.cslItemID ? item.cslItemID : item.id) === previousItemID) {
    493 					selectedIndex = referenceBox.childNodes.length-1;
    494 				}
    495 			}
    496 		}
    497 		
    498 		_resize();
    499 		if((citedItemsMatchingSearch && citedItemsMatchingSearch.length) || searchResultIDs.length) {
    500 			referenceBox.selectedIndex = selectedIndex;
    501 			referenceBox.ensureIndexIsVisible(selectedIndex);
    502 		}
    503 	});
    504 	
    505 	/**
    506 	 * Builds a string describing an item. We avoid CSL here for speed.
    507 	 */
    508 	function _buildItemDescription(item, infoHbox) {
    509 		var nodes = [];
    510 		
    511 		var author, authorDate = "";
    512 		if(item.firstCreator) author = authorDate = item.firstCreator;
    513 		var date = item.getField("date", true, true);
    514 		if(date && (date = date.substr(0, 4)) !== "0000") {
    515 			authorDate += " ("+date+")";
    516 		}
    517 		authorDate = authorDate.trim();
    518 		if(authorDate) nodes.push(authorDate);
    519 		
    520 		var publicationTitle = item.getField("publicationTitle", false, true);
    521 		if(publicationTitle) {
    522 			var label = document.createElement("label");
    523 			label.setAttribute("value", publicationTitle);
    524 			label.setAttribute("crop", "end");
    525 			label.style.fontStyle = "italic";
    526 			nodes.push(label);
    527 		}
    528 		
    529 		var volumeIssue = item.getField("volume");
    530 		var issue = item.getField("issue");
    531 		if(issue) volumeIssue += "("+issue+")";
    532 		if(volumeIssue) nodes.push(volumeIssue);
    533 		
    534 		var publisherPlace = [], field;
    535 		if((field = item.getField("publisher"))) publisherPlace.push(field);
    536 		if((field = item.getField("place"))) publisherPlace.push(field);
    537 		if(publisherPlace.length) nodes.push(publisherPlace.join(": "));
    538 		
    539 		var pages = item.getField("pages");
    540 		if(pages) nodes.push(pages);
    541 		
    542 		if(!nodes.length) {
    543 			var url = item.getField("url");
    544 			if(url) nodes.push(url);
    545 		}
    546 		
    547 		// compile everything together
    548 		var str = "";
    549 		for(var i=0, n=nodes.length; i<n; i++) {
    550 			var node = nodes[i];
    551 			
    552 			if(i != 0) str += ", ";
    553 			
    554 			if(typeof node === "object") {
    555 				var label = document.createElement("label");
    556 				label.setAttribute("value", str);
    557 				label.setAttribute("crop", "end");
    558 				infoHbox.appendChild(label);
    559 				infoHbox.appendChild(node);
    560 				str = "";
    561 			} else {
    562 				str += node;
    563 			}
    564 		}
    565 		
    566 		if(nodes.length && (!str.length || str[str.length-1] !== ".")) str += ".";
    567 		var label = document.createElement("label");
    568 		label.setAttribute("value", str);
    569 		label.setAttribute("crop", "end");
    570 		label.setAttribute("flex", "1");
    571 		infoHbox.appendChild(label);
    572 	}
    573 	
    574 	/**
    575 	 * Creates an item to be added to the item list
    576 	 */
    577 	function _buildListItem(item) {
    578 		var titleNode = document.createElement("label");
    579 		titleNode.setAttribute("class", "quick-format-title");
    580 		titleNode.setAttribute("flex", "1");
    581 		titleNode.setAttribute("crop", "end");
    582 		titleNode.setAttribute("value", item.getDisplayTitle());
    583 		
    584 		var infoNode = document.createElement("hbox");
    585 		infoNode.setAttribute("class", "quick-format-info");
    586 		_buildItemDescription(item, infoNode);
    587 		
    588 		// add to rich list item
    589 		var rll = document.createElement("richlistitem");
    590 		rll.setAttribute("orient", "vertical");
    591 		rll.setAttribute("class", "quick-format-item");
    592 		rll.setAttribute("zotero-item", item.cslItemID ? item.cslItemID : item.id);
    593 		rll.appendChild(titleNode);
    594 		rll.appendChild(infoNode);
    595 		rll.addEventListener("click", _bubbleizeSelected, false);
    596 		
    597 		return rll;
    598 	}
    599 
    600 	/**
    601 	 * Creates a list separator to be added to the item list
    602 	 */
    603 	function _buildListSeparator(labelText, loading) {
    604 		var titleNode = document.createElement("label");
    605 		titleNode.setAttribute("class", "quick-format-separator-title");
    606 		titleNode.setAttribute("flex", "1");
    607 		titleNode.setAttribute("crop", "end");
    608 		titleNode.setAttribute("value", labelText);
    609 		
    610 		// add to rich list item
    611 		var rll = document.createElement("richlistitem");
    612 		rll.setAttribute("orient", "vertical");
    613 		rll.setAttribute("disabled", true);
    614 		rll.setAttribute("class", loading ? "quick-format-loading" : "quick-format-separator");
    615 		rll.appendChild(titleNode);
    616 		rll.addEventListener("mousedown", _ignoreClick, true);
    617 		rll.addEventListener("click", _ignoreClick, true);
    618 		
    619 		return rll;
    620 	}
    621 	
    622 	/**
    623 	 * Builds the string to go inside a bubble
    624 	 */
    625 	function _buildBubbleString(citationItem) {
    626 		var item = Zotero.Cite.getItem(citationItem.id);
    627 		// create text for bubble
    628 		
    629 		// Creator
    630 		var title, delimiter;
    631 		var str = item.getField("firstCreator");
    632 		
    633 		// Title, if no creator (getDisplayTitle in order to get case, e-mail, statute which don't have a title field)
    634  		if(!str) {
    635 			str = Zotero.getString("punctuation.openingQMark") + item.getDisplayTitle() + Zotero.getString("punctuation.closingQMark");
    636 		}
    637 		
    638 		// Date
    639 		var date = item.getField("date", true, true);
    640 		if(date && (date = date.substr(0, 4)) !== "0000") {
    641 			str += ", "+date;
    642 		}
    643 		
    644 		// Locator
    645 		if(citationItem.locator) {
    646 			if(citationItem.label) {
    647 				// TODO localize and use short forms
    648 				var label = citationItem.label;
    649 			} else if(/[\-–,]/.test(citationItem.locator)) {
    650 				var label = "pp.";
    651 			} else {
    652 				var label = "p."
    653 			}
    654 			
    655 			str += ", "+label+" "+citationItem.locator;
    656 		}
    657 		
    658 		// Prefix
    659 		if(citationItem.prefix && Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP) {
    660 			str = citationItem.prefix
    661 				+(Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP.test(citationItem.prefix) ? " " : "")
    662 				+str;
    663 		}
    664 		
    665 		// Suffix
    666 		if(citationItem.suffix && Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP) {
    667 			str += (Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP.test(citationItem.suffix) ? " " : "")
    668 				+citationItem.suffix;
    669 		}
    670 		
    671 		return str;
    672 	}
    673 	
    674 	/**
    675 	 * Insert a bubble into the DOM at a specified position
    676 	 */
    677 	function _insertBubble(citationItem, nextNode) {
    678 		var str = _buildBubbleString(citationItem);
    679 		
    680 		// It's entirely unintuitive why, but after trying a bunch of things, it looks like using
    681 		// a XUL label for these things works best. A regular span causes issues with moving the
    682 		// cursor.
    683 		var bubble = qfiDocument.createElement("span");
    684 		bubble.setAttribute("class", "quick-format-bubble");
    685 		bubble.setAttribute("draggable", "true");
    686 		bubble.textContent = str;
    687 		bubble.addEventListener("click", _onBubbleClick, false);
    688 		bubble.addEventListener("dragstart", _onBubbleDrag, false);
    689 		bubble.citationItem = citationItem;
    690 		if(nextNode && nextNode instanceof Range) {
    691 			nextNode.insertNode(bubble);
    692 		} else {
    693 			qfe.insertBefore(bubble, (nextNode ? nextNode : null));
    694 		}
    695 		
    696 		// make sure that there are no rogue <br>s
    697 		var elements = qfe.getElementsByTagName("br");
    698 		while(elements.length) {
    699 			elements[0].parentNode.removeChild(elements[0]);
    700 		}
    701 		return bubble;
    702 	}
    703 	
    704 	/**
    705 	 * Clear list of bubbles
    706 	 */
    707 	function _clearEntryList() {
    708 		while(referenceBox.hasChildNodes()) referenceBox.removeChild(referenceBox.firstChild);
    709 		_resize();
    710 	}
    711 	
    712 	/**
    713 	 * Converts the selected item to a bubble
    714 	 */
    715 	var _bubbleizeSelected = Zotero.Promise.coroutine(function* () {
    716 		if(!referenceBox.hasChildNodes() || !referenceBox.selectedItem) return false;
    717 		
    718 		var citationItem = {"id":referenceBox.selectedItem.getAttribute("zotero-item")};
    719 		if(typeof citationItem.id === "string" && citationItem.id.indexOf("/") !== -1) {
    720 			var item = Zotero.Cite.getItem(citationItem.id);
    721 			citationItem.uris = item.cslURIs;
    722 			citationItem.itemData = item.cslItemData;
    723 		}
    724 		
    725 		_updateLocator(_getEditorContent());
    726 		if(currentLocator) {
    727 			 citationItem["locator"] = currentLocator;
    728 			if(currentLocatorLabel) {
    729 				citationItem["label"] = currentLocatorLabel;
    730 			}
    731 		}
    732 		
    733 		// get next node and clear this one
    734 		var node = _getCurrentEditorTextNode();
    735 		node.nodeValue = "";
    736 		var bubble = _insertBubble(citationItem, node);
    737 		_clearEntryList();
    738 		yield _previewAndSort();
    739 		_refocusQfe();
    740 		
    741 		return true;
    742 	});
    743 	
    744 	/**
    745 	 * Ignores clicks (for use on separators in the rich list box)
    746 	 */
    747 	function _ignoreClick(e) {
    748 		e.stopPropagation();
    749 		e.preventDefault();
    750 	}
    751 	
    752 	/**
    753 	 * Resizes window to fit content
    754 	 */
    755 	function _resize() {
    756 		var childNodes = referenceBox.childNodes, numReferences = 0, numSeparators = 0,
    757 			firstReference, firstSeparator, height;
    758 		for(var i=0, n=childNodes.length; i<n && numReferences < SHOWN_REFERENCES; i++) {
    759 			if(childNodes[i].className === "quick-format-item") {
    760 				numReferences++;
    761 				if(!firstReference) {
    762 					firstReference = childNodes[i];
    763 					if(referenceBox.selectedIndex === -1) referenceBox.selectedIndex = i;
    764 				}
    765 			} else if(childNodes[i].className === "quick-format-separator") {
    766 				numSeparators++;
    767 				if(!firstSeparator) firstSeparator = childNodes[i];
    768 			}
    769 		}
    770 		
    771 		if(qfe.scrollHeight > 30) {
    772 			qfe.setAttribute("multiline", true);
    773 			qfs.setAttribute("multiline", true);
    774 			qfs.style.height = ((Zotero.isMac ? 6 : 4)+qfe.scrollHeight)+"px";
    775 			window.sizeToContent();
    776 		} else {
    777 			delete qfs.style.height;
    778 			qfe.removeAttribute("multiline");
    779 			qfs.removeAttribute("multiline");
    780 			window.sizeToContent();
    781 		}
    782 		var panelShowing = referencePanel.state === "open" || referencePanel.state === "showing";
    783 		
    784 		if(numReferences || numSeparators) {
    785 			if(((!referenceHeight && firstReference) || (!separatorHeight && firstSeparator)
    786 					|| !panelFrameHeight) && !panelShowing) {
    787 				_openReferencePanel();
    788 				panelShowing = true;
    789 			}
    790 		
    791 			if(!referenceHeight && firstReference) {
    792 				referenceHeight = firstReference.scrollHeight + 1;
    793 			}
    794 			
    795 			if(!separatorHeight && firstSeparator) {
    796 				separatorHeight = firstSeparator.scrollHeight + 1;
    797 			}
    798 			
    799 			if(!panelFrameHeight) {
    800 				panelFrameHeight = referencePanel.boxObject.height - referencePanel.clientHeight;
    801 				var computedStyle = window.getComputedStyle(referenceBox, null);
    802 				for(var attr of ["border-top-width", "border-bottom-width"]) {
    803 					var val = computedStyle.getPropertyValue(attr);
    804 					if(val) {
    805 						var m = pixelRe.exec(val);
    806 						if(m) panelFrameHeight += parseInt(m[1], 10);
    807 					}
    808 				}
    809 			}
    810 			
    811 			referencePanel.sizeTo(window.outerWidth-30,
    812 				numReferences*referenceHeight+numSeparators*separatorHeight+panelFrameHeight);
    813 			if(!panelShowing) _openReferencePanel();
    814 		} else if(panelShowing) {
    815 			referencePanel.hidePopup();
    816 			referencePanel.sizeTo(window.outerWidth-30, 0);
    817 			_refocusQfe();
    818 		}
    819 	}
    820 	
    821 	/**
    822 	 * Opens the reference panel and potentially refocuses the main text box
    823 	 */
    824 	function _openReferencePanel() {
    825 		if(!Zotero.isMac && !Zotero.isWin) {
    826 			// noautohide and noautofocus are incompatible on Linux
    827 			// https://bugzilla.mozilla.org/show_bug.cgi?id=545265
    828 			referencePanel.setAttribute("noautohide", "false");
    829 		}
    830 		
    831 		referencePanel.openPopup(document.documentElement, "after_start", 15,
    832 			qfb.clientHeight-window.clientHeight, false, false, null);
    833 		
    834 		if(!Zotero.isMac && !Zotero.isWin) {
    835 			// reinstate noautohide after the window is shown
    836 			referencePanel.addEventListener("popupshowing", function() {
    837 				referencePanel.removeEventListener("popupshowing", arguments.callee, false);
    838 				referencePanel.setAttribute("noautohide", "true");
    839 			}, false);
    840 		}
    841 	}
    842 	
    843 	/**
    844 	 * Clears all citations
    845 	 */
    846 	function _clearCitation() {
    847 		var citations = qfe.getElementsByClassName("quick-format-bubble");
    848 		while(citations.length) {
    849 			citations[0].parentNode.removeChild(citations[0]);
    850 		}
    851 	}
    852 	
    853 	/**
    854 	 * Shows citations in the citation object
    855 	 */
    856 	function _showCitation(insertBefore) {
    857 		if(!io.citation.properties.unsorted
    858 				&& keepSorted.hasAttribute("checked")
    859 				&& io.citation.sortedItems
    860 				&& io.citation.sortedItems.length) {
    861 			for(var i=0, n=io.citation.sortedItems.length; i<n; i++) {
    862 				_insertBubble(io.citation.sortedItems[i][1], insertBefore);
    863 			}
    864 		} else {
    865 			for(var i=0, n=io.citation.citationItems.length; i<n; i++) {
    866 				_insertBubble(io.citation.citationItems[i], insertBefore);
    867 			}
    868 		}
    869 	}
    870 	
    871 	/**
    872 	 * Populates the citation object
    873 	 */
    874 	function _updateCitationObject() {
    875 		var nodes = qfe.childNodes;
    876 		io.citation.citationItems = [];
    877 		for(var i=0, n=nodes.length; i<n; i++) {
    878 			if(nodes[i].citationItem) io.citation.citationItems.push(nodes[i].citationItem);
    879 		}
    880 		
    881 		if(io.sortable) {
    882 			if(keepSorted.hasAttribute("checked")) {
    883 				delete io.citation.properties.unsorted;
    884 			} else {
    885 				io.citation.properties.unsorted = true;
    886 			}
    887 		}
    888 	}
    889 	
    890 	/**
    891 	 * Move cursor to end of the textbox
    892 	 */
    893 	function _moveCursorToEnd() {
    894 		var nodeRange = qfiDocument.createRange();
    895 		nodeRange.selectNode(qfe.lastChild);
    896 		nodeRange.collapse(false);
    897 		
    898 		var selection = qfiWindow.getSelection();
    899 		selection.removeAllRanges();
    900 		selection.addRange(nodeRange);
    901 	}
    902 	
    903 	/**
    904 	 * Generates the preview and sorts citations
    905 	 */
    906 	var _previewAndSort = Zotero.Promise.coroutine(function* () {
    907 		var shouldKeepSorted = keepSorted.hasAttribute("checked"),
    908 			editorShowing = showEditor.hasAttribute("checked");
    909 		if(!shouldKeepSorted && !editorShowing) return;
    910 		
    911 		_updateCitationObject();
    912 		yield io.sort();
    913 		if(shouldKeepSorted) {
    914 			// means we need to resort citations
    915 			_clearCitation();
    916 			_showCitation();
    917 			
    918 			// select past last citation
    919 			var lastBubble = qfe.getElementsByClassName("quick-format-bubble");
    920 			lastBubble = lastBubble[lastBubble.length-1];
    921 			
    922 			_moveCursorToEnd();
    923 		}
    924 	});
    925 	
    926 	/**
    927 	 * Shows the citation properties panel for a given bubble
    928 	 */
    929 	function _showCitationProperties(target) {
    930 		panelRefersToBubble = target;
    931 		panelPrefix.value = target.citationItem["prefix"] ? target.citationItem["prefix"] : "";
    932 		panelSuffix.value = target.citationItem["suffix"] ? target.citationItem["suffix"] : "";
    933 		if(target.citationItem["label"]) {
    934 			var option = panelLocatorLabel.getElementsByAttribute("value", target.citationItem["label"]);
    935 			if(option.length) {
    936 				panelLocatorLabel.selectedItem = option[0];
    937 			} else {
    938 				panelLocatorLabel.selectedIndex = 0;
    939 			}
    940 		} else {
    941 			panelLocatorLabel.selectedIndex = 0;
    942 		}
    943 		panelLocator.value = target.citationItem["locator"] ? target.citationItem["locator"] : "";
    944 		panelSuppressAuthor.checked = !!target.citationItem["suppress-author"];
    945 		
    946 		Zotero.Cite.getItem(panelRefersToBubble.citationItem.id).key;
    947 
    948 		var item = Zotero.Cite.getItem(target.citationItem.id);
    949 		document.getElementById("citation-properties-title").textContent = item.getDisplayTitle();
    950 		while(panelInfo.hasChildNodes()) panelInfo.removeChild(panelInfo.firstChild);
    951 		_buildItemDescription(item, panelInfo);
    952 		
    953 		panelLibraryLink.hidden = !item.id;
    954 		if(item.id) {
    955 			var libraryName = item.libraryID ? Zotero.Libraries.getName(item.libraryID)
    956 							: Zotero.getString('pane.collections.library');
    957 			panelLibraryLink.label = Zotero.getString("integration.openInLibrary", libraryName);
    958 		}
    959 
    960 		target.setAttribute("selected", "true");
    961 		panel.openPopup(target, "after_start",
    962 			target.clientWidth/2, 0, false, false, null);
    963 		panelLocator.focus();
    964 	}
    965 	
    966 	/**
    967 	 * Called when progress changes
    968 	 */
    969 	function _onProgress(percent) {
    970 		var meter = document.getElementById("quick-format-progress-meter");
    971 		if(percent === null) {
    972 			meter.mode = "undetermined";
    973 		} else {
    974 			meter.mode = "determined";
    975 			meter.value = Math.round(percent);
    976 		}
    977 	}
    978 	
    979 	/**
    980 	 * Accepts current selection and adds citation
    981 	 */
    982 	function _accept() {
    983 		if(accepted) return;
    984 		accepted = true;
    985 		try {
    986 			_updateCitationObject();
    987 			document.getElementById("quick-format-deck").selectedIndex = 1;
    988 			io.accept(_onProgress);
    989 		} catch(e) {
    990 			Zotero.debug(e);
    991 		}
    992 	}
    993 	
    994 	/**
    995 	 * Handles windows closed with the close box
    996 	 */
    997 	this.onUnload = function() {
    998 		if(accepted) return;
    999 		accepted = true;
   1000 		io.citation.citationItems = [];
   1001 		io.accept();
   1002 	}
   1003 	
   1004 	/**
   1005 	 * Handle escape for entire window
   1006 	 */
   1007 	this.onKeyPress = function(event) {
   1008 		var keyCode = event.keyCode;
   1009 		if(keyCode === event.DOM_VK_ESCAPE && !accepted) {
   1010 			accepted = true;
   1011 			io.citation.citationItems = [];
   1012 			io.accept();
   1013 		}
   1014 	}
   1015 
   1016 	/**
   1017 	 * Get bubbles within the current selection
   1018 	 */
   1019 	function _getSelectedBubble(right) {
   1020 		var selection = qfiWindow.getSelection(),
   1021 			range = selection.getRangeAt(0);
   1022 		qfe.normalize();
   1023 		
   1024 		// Check whether the bubble is selected
   1025 		// Not sure whether this ever happens anymore
   1026 		var container = range.startContainer;
   1027 		if(container !== qfe) {
   1028 			if(container.citationItem) {
   1029 				return container;
   1030 			} else if(container.nodeType === Node.TEXT_NODE && container.wholeText == "") {
   1031 				if(container.parentNode === qfe) {
   1032 					var node = container;
   1033 					while((node = container.previousSibling)) {
   1034 						if(node.citationItem) {
   1035 							return node;
   1036 						}
   1037 					}
   1038 				}
   1039 			}
   1040 			return null;
   1041 		}
   1042 
   1043 		// Check whether there is a bubble anywhere to the left of this one
   1044 		var offset = range.startOffset,
   1045 			childNodes = qfe.childNodes,
   1046 			node = childNodes[offset-(right ? 0 : 1)];
   1047 		if(node && node.citationItem) return node;
   1048 		return null;
   1049 	}
   1050 
   1051 	/**
   1052 	 * Reset timer that controls when search takes place. We use this to avoid searching after each
   1053 	 * keypress, since searches can be slow.
   1054 	 */
   1055 	function _resetSearchTimer() {
   1056 		// Show spinner
   1057 		var spinner = document.getElementById('quick-format-spinner');
   1058 		spinner.style.visibility = '';
   1059 		// Cancel current search if active
   1060 		if (_searchPromise && _searchPromise.isPending()) {
   1061 			_searchPromise.cancel();
   1062 		}
   1063 		// Start new search
   1064 		_searchPromise = Zotero.Promise.delay(SEARCH_TIMEOUT)
   1065 			.then(() => _quickFormat())
   1066 			.then(() => {
   1067 				_searchPromise = null;
   1068 				spinner.style.visibility = 'hidden';
   1069 			});
   1070 	}
   1071 	
   1072 	/**
   1073 	 * Handle return or escape
   1074 	 */
   1075 	var _onQuickSearchKeyPress = Zotero.Promise.coroutine(function* (event) {
   1076 		// Prevent hang if another key is pressed after Enter
   1077 		// https://forums.zotero.org/discussion/59157/
   1078 		if (accepted) {
   1079 			event.preventDefault();
   1080 			return;
   1081 		}
   1082 		if(qfGuidance) qfGuidance.hide();
   1083 		
   1084 		var keyCode = event.keyCode;
   1085 		if (keyCode === event.DOM_VK_RETURN) {
   1086 			event.preventDefault();
   1087 			if(!(yield _bubbleizeSelected()) && !_getEditorContent()) {
   1088 				_accept();
   1089 			}
   1090 		} else if(keyCode === event.DOM_VK_TAB || event.charCode === 59 /* ; */) {
   1091 			event.preventDefault();
   1092 			_bubbleizeSelected();
   1093 		} else if(keyCode === event.DOM_VK_BACK_SPACE || keyCode === event.DOM_VK_DELETE) {
   1094 			var bubble = _getSelectedBubble(keyCode === event.DOM_VK_DELETE);
   1095 
   1096 			if(bubble) {
   1097 				event.preventDefault();
   1098 				bubble.parentNode.removeChild(bubble);
   1099 			}
   1100 
   1101 			_resize();
   1102 			_resetSearchTimer();
   1103 		} else if(keyCode === event.DOM_VK_LEFT || keyCode === event.DOM_VK_RIGHT) {
   1104 			var right = keyCode === event.DOM_VK_RIGHT,
   1105 				bubble = _getSelectedBubble(right);
   1106 			if(bubble) {
   1107 				event.preventDefault();
   1108 
   1109 				var nodeRange = qfiDocument.createRange();
   1110 				nodeRange.selectNode(bubble);
   1111 				nodeRange.collapse(!right);
   1112 
   1113 				var selection = qfiWindow.getSelection();
   1114 				selection.removeAllRanges();
   1115 				selection.addRange(nodeRange);
   1116 			}
   1117 
   1118 		} else if(keyCode === event.DOM_VK_UP && referencePanel.state === "open") {
   1119 			var selectedItem = referenceBox.selectedItem;
   1120 
   1121 			var previousSibling;
   1122 			
   1123 			// Seek the closet previous sibling that is not disabled
   1124 			while((previousSibling = selectedItem.previousSibling) && previousSibling.hasAttribute("disabled")) {
   1125 				selectedItem = previousSibling;
   1126 			}
   1127 			// If found, change to that
   1128 			if(previousSibling) {
   1129 				referenceBox.selectedItem = previousSibling;
   1130 				
   1131 				// If there are separators before this item, ensure that they are visible
   1132 				var visibleItem = previousSibling;
   1133 
   1134 				while(visibleItem.previousSibling && visibleItem.previousSibling.hasAttribute("disabled")) {
   1135 					visibleItem = visibleItem.previousSibling;
   1136 				}
   1137 				referenceBox.ensureElementIsVisible(visibleItem);
   1138 			};
   1139 			event.preventDefault();
   1140 		} else if(keyCode === event.DOM_VK_DOWN) {
   1141 			if((Zotero.isMac ? event.metaKey : event.ctrlKey)) {
   1142 				// If meta key is held down, show the citation properties panel
   1143 				var bubble = _getSelectedBubble();
   1144 
   1145 				if(bubble) _showCitationProperties(bubble);
   1146 				event.preventDefault();
   1147 			} else if (referencePanel.state === "open") {
   1148 				var selectedItem = referenceBox.selectedItem;
   1149 				var nextSibling;
   1150 				
   1151 				// Seek the closet next sibling that is not disabled
   1152 				while((nextSibling = selectedItem.nextSibling) && nextSibling.hasAttribute("disabled")) {
   1153 					selectedItem = nextSibling;
   1154 				}
   1155 				
   1156 				// If found, change to that
   1157 				if(nextSibling){
   1158 					referenceBox.selectedItem = nextSibling;
   1159 					referenceBox.ensureElementIsVisible(nextSibling);
   1160 				};
   1161 				event.preventDefault();
   1162 			}
   1163 		} else {
   1164 			_resetSearchTimer();
   1165 		}
   1166 	});
   1167 	
   1168 	/**
   1169 	 * Adds a dummy element to make dragging work
   1170 	 */
   1171 	function _onBubbleDrag(event) {
   1172 		dragging = event.currentTarget;
   1173 		event.dataTransfer.setData("text/plain", '<span id="zotero-drag"/>');
   1174 		event.stopPropagation();
   1175 	}
   1176 
   1177 	/**
   1178 	 * Get index of bubble in citations
   1179 	 */
   1180 	function _getBubbleIndex(bubble) {
   1181 		var nodes = qfe.childNodes, oldPosition = -1, index = 0;
   1182 		for(var i=0, n=nodes.length; i<n; i++) {
   1183 			if(nodes[i].citationItem) {
   1184 				if(nodes[i] == bubble) return index;
   1185 				index++;
   1186 			}
   1187 		}
   1188 		return -1;
   1189 	}
   1190 	
   1191 	/**
   1192 	 * Replaces the dummy element with a node to make dropping work
   1193 	 */
   1194 	var _onBubbleDrop = Zotero.Promise.coroutine(function* (event) {
   1195 		event.preventDefault();
   1196 		event.stopPropagation();
   1197 
   1198 		var range = document.createRange();
   1199 
   1200 		// Find old position in list
   1201 		var oldPosition = _getBubbleIndex(dragging);
   1202 		range.setStart(event.rangeParent, event.rangeOffset);
   1203 		dragging.parentNode.removeChild(dragging);
   1204 		var bubble = _insertBubble(dragging.citationItem, range);
   1205 
   1206 		// If moved out of order, turn off "Keep Sources Sorted"
   1207 		if(io.sortable && keepSorted.hasAttribute("checked") && oldPosition !== -1 &&
   1208 				oldPosition != _getBubbleIndex(bubble)) {
   1209 			keepSorted.removeAttribute("checked");
   1210 		}
   1211 
   1212 		yield _previewAndSort();
   1213 		_moveCursorToEnd();
   1214 	});
   1215 	
   1216 	/**
   1217 	 * Handle a click on a bubble
   1218 	 */
   1219 	function _onBubbleClick(event) {
   1220 		_moveCursorToEnd();
   1221 		_showCitationProperties(event.currentTarget);
   1222 	}
   1223 
   1224 	/**
   1225 	 * Called when the user attempts to paste
   1226 	 */
   1227 	function _onPaste(event) {
   1228 		event.stopPropagation();
   1229 		event.preventDefault();
   1230 
   1231 		var str = Zotero.Utilities.Internal.getClipboard("text/unicode");
   1232 		if(str) {
   1233 			var selection = qfiWindow.getSelection();
   1234 			var range = selection.getRangeAt(0);
   1235 			range.deleteContents();
   1236 			range.insertNode(document.createTextNode(str.replace(/[\r\n]/g, " ").trim()));
   1237 			range.collapse(false);
   1238 			_resetSearchTimer();
   1239 		}
   1240 	}
   1241 	
   1242 	/**
   1243 	 * Handle changes to citation properties
   1244 	 */
   1245 	this.onCitationPropertiesChanged = function(event) {
   1246 		if(panelPrefix.value) {
   1247 			panelRefersToBubble.citationItem["prefix"] = panelPrefix.value;
   1248 		} else {
   1249 			delete panelRefersToBubble.citationItem["prefix"];
   1250 		}
   1251 		if(panelSuffix.value) {
   1252 			panelRefersToBubble.citationItem["suffix"] = panelSuffix.value;
   1253 		} else {
   1254 			delete panelRefersToBubble.citationItem["suffix"];
   1255 		}
   1256 		if(panelLocatorLabel.selectedIndex !== 0) {
   1257 			panelRefersToBubble.citationItem["label"] = panelLocatorLabel.selectedItem.value;
   1258 		} else {
   1259 			delete panelRefersToBubble.citationItem["label"];
   1260 		}
   1261 		if(panelLocator.value) {
   1262 			panelRefersToBubble.citationItem["locator"] = panelLocator.value;
   1263 		} else {
   1264 			delete panelRefersToBubble.citationItem["locator"];
   1265 		}
   1266 		if(panelSuppressAuthor.checked) {
   1267 			panelRefersToBubble.citationItem["suppress-author"] = true;
   1268 		} else {
   1269 			delete panelRefersToBubble.citationItem["suppress-author"];
   1270 		}
   1271 		panelRefersToBubble.textContent = _buildBubbleString(panelRefersToBubble.citationItem);
   1272 	};
   1273 	
   1274 	/**
   1275 	 * Handle closing citation properties panel
   1276 	 */
   1277 	this.onCitationPropertiesClosed = function(event) {
   1278 		panelRefersToBubble.removeAttribute("selected");
   1279 		Zotero_QuickFormat.onCitationPropertiesChanged();
   1280 	}
   1281 	
   1282 	/**
   1283 	 * Makes "Enter" work in the panel
   1284 	 */
   1285 	this.onPanelKeyPress = function(event) {
   1286 		var keyCode = event.keyCode;
   1287 		if (keyCode === event.DOM_VK_RETURN) {
   1288 			document.getElementById("citation-properties").hidePopup();
   1289 		}
   1290 	};
   1291 	
   1292 	/**
   1293 	 * Handle checking/unchecking "Keep Citations Sorted"
   1294 	 */
   1295 	this.onKeepSortedCommand = function(event) {
   1296 		_previewAndSort();
   1297 	};
   1298 	
   1299 	/**
   1300 	 * Open classic Add Citation window
   1301 	 */
   1302 	this.onClassicViewCommand = function(event) {
   1303 		_updateCitationObject();
   1304 		var newWindow = window.newWindow = Components.classes["@mozilla.org/embedcomp/window-watcher;1"]
   1305 			.getService(Components.interfaces.nsIWindowWatcher)
   1306 			.openWindow(null, 'chrome://zotero/content/integration/addCitationDialog.xul',
   1307 			'', 'chrome,centerscreen,resizable', io);
   1308 		newWindow.addEventListener("focus", function() {
   1309 			newWindow.removeEventListener("focus", arguments.callee, true);
   1310 			window.close();
   1311 		}, true);
   1312 		accepted = true;
   1313 	}
   1314 	
   1315 	/**
   1316 	 * Show an item in the library it came from
   1317 	 */
   1318 	this.showInLibrary = async function() {
   1319 		var id = panelRefersToBubble.citationItem.id;
   1320 		var pane = Zotero.getActiveZoteroPane();
   1321 		// Open main window if it's not open (Mac)
   1322 		if (!pane) {
   1323 			let win = Zotero.openMainWindow();
   1324 			await new Zotero.Promise((resolve) => {
   1325 				let onOpen = function () {
   1326 					win.removeEventListener('load', onOpen);
   1327 					resolve();
   1328 				};
   1329 				win.addEventListener('load', onOpen);
   1330 			});
   1331 			pane = win.ZoteroPane;
   1332 		}
   1333 		pane.show();
   1334 		pane.selectItem(id);
   1335 		
   1336 		// Pull window to foreground
   1337 		Zotero.Utilities.Internal.activate(pane.document.defaultView);
   1338 	}
   1339 	
   1340 	/**
   1341 	 * Resizes windows
   1342 	 * @constructor
   1343 	 */
   1344 	var Resizer = function(panel, targetWidth, targetHeight, pixelsPerStep, stepsPerSecond) {
   1345 		this.panel = panel;
   1346 		this.curWidth = panel.clientWidth;
   1347 		this.curHeight = panel.clientHeight;
   1348 		this.difX = (targetWidth ? targetWidth - this.curWidth : 0);
   1349 		this.difY = (targetHeight ? targetHeight - this.curHeight : 0);
   1350 		this.step = 0;
   1351 		this.steps = Math.ceil(Math.max(Math.abs(this.difX), Math.abs(this.difY))/pixelsPerStep);
   1352 		this.timeout = (1000/stepsPerSecond);
   1353 		
   1354 		var me = this;
   1355 		this._animateCallback = function() { me.animate() };
   1356 	};
   1357 	
   1358 	/**
   1359 	 * Performs a step of the animation
   1360 	 */
   1361 	Resizer.prototype.animate = function() {
   1362 		if(this.stopped) return;
   1363 		this.step++;
   1364 		this.panel.sizeTo(this.curWidth+Math.round(this.step*this.difX/this.steps),
   1365 			this.curHeight+Math.round(this.step*this.difY/this.steps));
   1366 		if(this.step !== this.steps) {
   1367 			window.setTimeout(this._animateCallback, this.timeout);
   1368 		}
   1369 	};
   1370 	
   1371 	/**
   1372 	 * Halts resizing
   1373 	 */
   1374 	Resizer.prototype.stop = function() {
   1375 		this.stopped = true;
   1376 	};
   1377 }
   1378 
   1379 window.addEventListener("DOMContentLoaded", Zotero_QuickFormat.onDOMContentLoaded, false);
   1380 window.addEventListener("load", Zotero_QuickFormat.onLoad, false);