www

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

libraryTreeView.js (15657B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2013 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 Zotero.LibraryTreeView = function () {
     27 	this._initialized = false;
     28 	this._listeners = {};
     29 	this._rows = [];
     30 	this._rowMap = {};
     31 	
     32 	this.id = Zotero.Utilities.randomString();
     33 	Zotero.debug("Creating " + this.type + "s view with id " + this.id);
     34 	
     35 	//
     36 	// Create .on(Load|Select|Refresh).addListener() methods
     37 	//
     38 	var _createEventBinding = function (event, alwaysOnce) {
     39 		return alwaysOnce
     40 			? {
     41 				addListener: listener => this._addListener(event, listener, true)
     42 			}
     43 			: {
     44 				addListener: (listener, once) => this._addListener(event, listener, once)
     45 			};
     46 	}.bind(this);
     47 	
     48 	this.onLoad = _createEventBinding('load', true);
     49 	this.onSelect = _createEventBinding('select');
     50 	this.onRefresh = _createEventBinding('refresh');
     51 };
     52 
     53 Zotero.LibraryTreeView.prototype = {
     54 	get initialized() {
     55 		return this._initialized;
     56 	},
     57 	
     58 	
     59 	addEventListener: function (event, listener) {
     60 		Zotero.logError("Zotero.LibraryTreeView::addEventListener() is deprecated");
     61 		this.addListener(event, listener);
     62 	},
     63 	
     64 	
     65 	waitForLoad: function () {
     66 		return this._waitForEvent('load');
     67 	},
     68 	
     69 	
     70 	waitForSelect: function () {
     71 		return this._waitForEvent('select');
     72 	},
     73 	
     74 	
     75 	runListeners: Zotero.Promise.coroutine(function* (event) {
     76 		//Zotero.debug(`Calling ${event} listeners on ${this.type} tree ${this.id}`);
     77 		if (!this._listeners[event]) return;
     78 		for (let [listener, once] of this._listeners[event].entries()) {
     79 			yield Zotero.Promise.resolve(listener.call(this));
     80 			if (once) {
     81 				this._listeners[event].delete(listener);
     82 			}
     83 		}
     84 	}),
     85 	
     86 	
     87 	_addListener: function(event, listener, once) {
     88 		// If already initialized run now
     89 		if (event == 'load' && this._initialized) {
     90 			listener.call(this);
     91 		}
     92 		else {
     93 			if (!this._listeners[event]) {
     94 				this._listeners[event] = new Map();
     95 			}
     96 			this._listeners[event].set(listener, once);
     97 		}
     98 	},
     99 	
    100 	
    101 	_waitForEvent: Zotero.Promise.coroutine(function* (event) {
    102 		if (event == 'load' && this._initialized) {
    103 			return;
    104 		}
    105 		return new Zotero.Promise((resolve, reject) => {
    106 			this._addListener(event, () => resolve(), true);
    107 		});
    108 	}),
    109 	
    110 	
    111 	/**
    112 	 * Return a reference to the tree row at a given row
    113 	 *
    114 	 * @return {Zotero.CollectionTreeRow|Zotero.ItemTreeRow}
    115 	 */
    116 	getRow: function(row) {
    117 		return this._rows[row];
    118 	},
    119 	
    120 	
    121 	/**
    122 	 * Return the index of the row with a given ID (e.g., "C123" for collection 123)
    123 	 *
    124 	 * @param {String} - Row id
    125 	 * @return {Integer|false}
    126 	 */
    127 	getRowIndexByID: function (id) {
    128 		var type = "";
    129 		if (this.type != 'item') {
    130 			var type = id[0];
    131 			id = ('' + id).substr(1);
    132 		}
    133 		return this._rowMap[type + id] !== undefined ? this._rowMap[type + id] : false;
    134 	},
    135 	
    136 	
    137 	/**
    138 	 * Return an object describing the current scroll position to restore after changes
    139 	 *
    140 	 * @return {Object|Boolean} - Object with .id (a treeViewID) and .offset, or false if no rows
    141 	 */
    142 	_saveScrollPosition: function() {
    143 		var treebox = this._treebox;
    144 		var first = treebox.getFirstVisibleRow();
    145 		if (!first) {
    146 			return false;
    147 		}
    148 		var last = treebox.getLastVisibleRow();
    149 		var firstSelected = null;
    150 		for (let i = first; i <= last; i++) {
    151 			// If an object is selected, keep the first selected one in position
    152 			if (this.selection.isSelected(i)) {
    153 				return {
    154 					id: this.getRow(i).ref.treeViewID,
    155 					offset: i - first
    156 				};
    157 			}
    158 		}
    159 		
    160 		// Otherwise keep the first visible row in position
    161 		return {
    162 			id: this.getRow(first).ref.treeViewID,
    163 			offset: 0
    164 		};
    165 	},
    166 	
    167 	
    168 	/**
    169 	 * Restore a scroll position returned from _saveScrollPosition()
    170 	 */
    171 	_rememberScrollPosition: function (scrollPosition) {
    172 		if (!scrollPosition || !scrollPosition.id) {
    173 			return;
    174 		}
    175 		var row = this.getRowIndexByID(scrollPosition.id);
    176 		if (row === false) {
    177 			return;
    178 		}
    179 		this._treebox.scrollToRow(Math.max(row - scrollPosition.offset, 0));
    180 	},
    181 	
    182 	
    183 	runSelectListeners: function () {
    184 		return this._runListeners('select');
    185 	},
    186 	
    187 	
    188 	/**
    189 	 * Add a tree row to the main array, update the row count, tell the treebox that the row
    190 	 * count changed, and update the row map
    191 	 *
    192 	 * @param {Array} newRows - Array to operate on
    193 	 * @param {Zotero.ItemTreeRow} itemTreeRow
    194 	 * @param {Number} [beforeRow] - Row index to insert new row before
    195 	 */
    196 	_addRow: function (treeRow, beforeRow, skipRowMapRefresh) {
    197 		this._addRowToArray(this._rows, treeRow, beforeRow);
    198 		this.rowCount++;
    199 		this._treebox.rowCountChanged(beforeRow, 1);
    200 		if (!skipRowMapRefresh) {
    201 			// Increment all rows in map at or above insertion point
    202 			for (let i in this._rowMap) {
    203 				if (this._rowMap[i] >= beforeRow) {
    204 					this._rowMap[i]++
    205 				}
    206 			}
    207 			// Add new row to map
    208 			this._rowMap[treeRow.id] = beforeRow;
    209 		}
    210 	},
    211 	
    212 	
    213 	/**
    214 	 * Add a tree row into a given array
    215 	 *
    216 	 * @param {Array} array - Array to operate on
    217 	 * @param {Zotero.CollectionTreeRow|ItemTreeRow} treeRow
    218 	 * @param {Number} beforeRow - Row index to insert new row before
    219 	 */
    220 	_addRowToArray: function (array, treeRow, beforeRow) {
    221 		array.splice(beforeRow, 0, treeRow);
    222 	},
    223 	
    224 	
    225 	/**
    226 	* Remove a row from the main array, decrement the row count, tell the treebox that the row
    227 	* count changed, update the parent isOpen if necessary, delete the row from the map, and
    228 	* optionally update all rows above it in the map
    229 	*/
    230 	_removeRow: function (row, skipMapUpdate) {
    231 		var id = this._rows[row].id;
    232 		var level = this.getLevel(row);
    233 		
    234 		var lastRow = row == this.rowCount - 1;
    235 		if (lastRow && this.selection.isSelected(row)) {
    236 			// Deselect removed row
    237 			this.selection.toggleSelect(row);
    238 			// If no other rows selected, select first selectable row before
    239 			if (this.selection.count == 0 && row !== 0) {
    240 				let previous = row;
    241 				while (true) {
    242 					previous--;
    243 					// Should ever happen
    244 					if (previous < 0) {
    245 						break;
    246 					}
    247 					if (!this.isSelectable(previous)) {
    248 						continue;
    249 					}
    250 					
    251 					this.selection.toggleSelect(previous);
    252 					break;
    253 				}
    254 			}
    255 		}
    256 		
    257 		this._rows.splice(row, 1);
    258 		this.rowCount--;
    259 		// According to the example on https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsITreeBoxObject#rowCountChanged
    260 		// this should start at row + 1 ("rowCountChanged(rowIndex+1, -1);"), but that appears to
    261 		// just be wrong. A negative count indicates removed rows, but the index should still
    262 		// start at the place where the removals begin, not after it going backward.
    263 		this._treebox.rowCountChanged(row, -1);
    264 		// Update isOpen if parent and no siblings
    265 		if (row != 0
    266 				&& this.getLevel(row - 1) < level
    267 				&& (!this._rows[row] || this.getLevel(row) != level)) {
    268 			this._rows[row - 1].isOpen = false;
    269 			this._treebox.invalidateRow(row - 1);
    270 		}
    271 		delete this._rowMap[id];
    272 		if (!skipMapUpdate) {
    273 			for (let i in this._rowMap) {
    274 				if (this._rowMap[i] > row) {
    275 					this._rowMap[i]--;
    276 				}
    277 			}
    278 		}
    279 	},
    280 	
    281 	
    282 	_removeRows: function (rows) {
    283 		rows = Zotero.Utilities.arrayUnique(rows);
    284 		rows.sort((a, b) => a - b);
    285 		for (let i = rows.length - 1; i >= 0; i--) {
    286 			this._removeRow(rows[i]);
    287 		}
    288 	},
    289 	
    290 	
    291 	getLevel: function (row) {
    292 		return this._rows[row].level;
    293 	},
    294 	
    295 	
    296 	isContainerOpen: function(row) {
    297 		return this._rows[row].isOpen;
    298 	},
    299 	
    300 	
    301 	/**
    302 	 *  Called while a drag is over the tree
    303 	 */
    304 	canDrop: function(row, orient, dataTransfer) {
    305 		// onDragOver() calls the view's canDropCheck() and sets the
    306 		// dropEffect, which we check here. Setting the dropEffect on the
    307 		// dataTransfer here seems to have no effect.
    308 		
    309 		// ondragover doesn't have access to the orientation on its own,
    310 		// so we stuff it in Zotero.DragDrop
    311 		Zotero.DragDrop.currentOrientation = orient;
    312 		
    313 		return dataTransfer.dropEffect && dataTransfer.dropEffect != "none";
    314 	},
    315 	
    316 	
    317 	/*
    318 	 * Called by HTML 5 Drag and Drop when dragging over the tree
    319 	 */
    320 	onDragEnter: function (event) {
    321 		Zotero.DragDrop.currentEvent = event;
    322 		return false;
    323 	},
    324 	
    325 	
    326 	/**
    327 	 * Called by HTML 5 Drag and Drop when dragging over the tree
    328 	 *
    329 	 * We use this to set the drag action, which is used by view.canDrop(),
    330 	 * based on the view's canDropCheck() and modifier keys.
    331 	 */
    332 	onDragOver: function (event) {
    333 		// Prevent modifier keys from doing their normal things
    334 		event.preventDefault();
    335 		
    336 		Zotero.DragDrop.currentEvent = event;
    337 		
    338 		var target = event.target;
    339 		if (target.tagName != 'treechildren') {
    340 			let doc = target.ownerDocument;
    341 			// Consider a drop on the items pane message box (e.g., when showing the welcome text)
    342 			// a drop on the items tree
    343 			let msgBox = doc.getElementById('zotero-items-pane-message-box');
    344 			if (msgBox.contains(target) && msgBox.firstChild.hasAttribute('allowdrop')) {
    345 				target = doc.querySelector('#zotero-items-tree treechildren');
    346 			}
    347 			else {
    348 				this._setDropEffect(event, "none");
    349 				return false;
    350 			}
    351 		}
    352 		var tree = target.parentNode;
    353 		let row = {}, col = {}, obj = {};
    354 		tree.treeBoxObject.getCellAt(event.clientX, event.clientY, row, col, obj);
    355 		if (tree.id == 'zotero-collections-tree') {
    356 			var view = tree.ownerDocument.defaultView.ZoteroPane.collectionsView;
    357 		}
    358 		else if (tree.id == 'zotero-items-tree') {
    359 			var view = tree.ownerDocument.defaultView.ZoteroPane.itemsView;
    360 		}
    361 		else {
    362 			throw new Error("Invalid tree id '" + tree.id + "'");
    363 		}
    364 		
    365 		if (!view.canDropCheck(row.value, Zotero.DragDrop.currentOrientation, event.dataTransfer)) {
    366 			this._setDropEffect(event, "none");
    367 			return;
    368 		}
    369 		
    370 		if (event.dataTransfer.getData("zotero/item")) {
    371 			var sourceCollectionTreeRow = Zotero.DragDrop.getDragSource(event.dataTransfer);
    372 			if (sourceCollectionTreeRow) {
    373 				if (this.type == 'collection') {
    374 					var targetCollectionTreeRow = Zotero.DragDrop.getDragTarget(event);
    375 				}
    376 				else if (this.type == 'item') {
    377 					var targetCollectionTreeRow = this.collectionTreeRow;
    378 				}
    379 				else {
    380 					throw new Error("Invalid type '" + this.type + "'");
    381 				}
    382 				
    383 				if (!targetCollectionTreeRow) {
    384 					this._setDropEffect(event, "none");
    385 					return false;
    386 				}
    387 				
    388 				if (sourceCollectionTreeRow.id == targetCollectionTreeRow.id) {
    389 					// Ignore drag into the same collection
    390 					if (this.type == 'collection') {
    391 						this._setDropEffect(event, "none");
    392 					}
    393 					// If dragging from the same source, do a move
    394 					else {
    395 						this._setDropEffect(event, "move");
    396 					}
    397 					return false;
    398 				}
    399 				// If the source isn't a collection, the action has to be a copy
    400 				if (!sourceCollectionTreeRow.isCollection()) {
    401 					this._setDropEffect(event, "copy");
    402 					return false;
    403 				}
    404 				// For now, all cross-library drags are copies
    405 				if (sourceCollectionTreeRow.ref.libraryID != targetCollectionTreeRow.ref.libraryID) {
    406 					this._setDropEffect(event, "copy");
    407 					return false;
    408 				}
    409 			}
    410 			
    411 			if ((Zotero.isMac && event.metaKey) || (!Zotero.isMac && event.shiftKey)) {
    412 				this._setDropEffect(event, "move");
    413 			}
    414 			else {
    415 				this._setDropEffect(event, "copy");
    416 			}
    417 		}
    418 		else if (event.dataTransfer.getData("zotero/collection")) {
    419 			let collectionID = Zotero.DragDrop.getDataFromDataTransfer(event.dataTransfer).data[0];
    420 			let { libraryID: sourceLibraryID } = Zotero.Collections.getLibraryAndKeyFromID(collectionID);
    421 			
    422 			if (this.type == 'collection') {
    423 				var targetCollectionTreeRow = Zotero.DragDrop.getDragTarget(event);
    424 			}
    425 			else {
    426 				throw new Error("Invalid type '" + this.type + "'");
    427 			}
    428 			
    429 			// For now, all cross-library drags are copies
    430 			if (sourceLibraryID != targetCollectionTreeRow.ref.libraryID) {
    431 				/*if ((Zotero.isMac && event.metaKey) || (!Zotero.isMac && event.shiftKey)) {
    432 					this._setDropEffect(event, "move");
    433 				}
    434 				else {
    435 					this._setDropEffect(event, "copy");
    436 				}*/
    437 				this._setDropEffect(event, "copy");
    438 				return false;
    439 			}
    440 			
    441 			// And everything else is a move
    442 			this._setDropEffect(event, "move");
    443 		}
    444 		else if (event.dataTransfer.types.contains("application/x-moz-file")) {
    445 			// As of Aug. 2013 nightlies:
    446 			//
    447 			// - Setting the dropEffect only works on Linux and OS X.
    448 			//
    449 			// - Modifier keys don't show up in the drag event on OS X until the
    450 			//   drop (https://bugzilla.mozilla.org/show_bug.cgi?id=911918),
    451 			//   so since we can't show a correct effect, we leave it at
    452 			//   the default 'move', the least misleading option, and set it
    453 			//   below in onDrop().
    454 			//
    455 			// - The cursor effect gets set by the system on Windows 7 and can't
    456 			//   be overridden.
    457 			if (!Zotero.isMac) {
    458 				if (event.shiftKey) {
    459 					if (event.ctrlKey) {
    460 						event.dataTransfer.dropEffect = "link";
    461 					}
    462 					else {
    463 						event.dataTransfer.dropEffect = "move";
    464 					}
    465 				}
    466 				else {
    467 					event.dataTransfer.dropEffect = "copy";
    468 				}
    469 			}
    470 		}
    471 		return false;
    472 	},
    473 	
    474 	
    475 	/*
    476 	 * Called by HTML 5 Drag and Drop when dropping onto the tree
    477 	 */
    478 	onDrop: function (event) {
    479 		// See note above
    480 		if (event.dataTransfer.types.contains("application/x-moz-file")) {
    481 			if (Zotero.isMac) {
    482 				Zotero.DragDrop.currentEvent = event;
    483 				if (event.metaKey) {
    484 					if (event.altKey) {
    485 						event.dataTransfer.dropEffect = 'link';
    486 					}
    487 					else {
    488 						event.dataTransfer.dropEffect = 'move';
    489 					}
    490 				}
    491 				else {
    492 					event.dataTransfer.dropEffect = 'copy';
    493 				}
    494 			}
    495 		}
    496 		return false;
    497 	},
    498 	
    499 	
    500 	onDragExit: function (event) {
    501 		//Zotero.debug("Clearing drag data");
    502 		Zotero.DragDrop.currentEvent = null;
    503 	},
    504 	
    505 	
    506 	_setDropEffect: function (event, effect) {
    507 		// On Windows (in Fx26), Firefox uses 'move' for unmodified drags
    508 		// and 'copy'/'link' for drags with system-default modifier keys
    509 		// as long as the actions are allowed by the initial effectAllowed set
    510 		// in onDragStart, regardless of the effectAllowed or dropEffect set
    511 		// in onDragOver. It doesn't seem to be possible to use 'copy' for
    512 		// the default and 'move' for modified, as we need to in the collections
    513 		// tree. To prevent inaccurate cursor feedback, we set effectAllowed to
    514 		// 'copy' in onDragStart, which locks the cursor at 'copy'. ('none' still
    515 		// changes the cursor, but 'move'/'link' do not.) It'd be better to use
    516 		// the unadorned 'move', but we use 'copy' instead because with 'move' text
    517 		// can't be dragged to some external programs (e.g., Chrome, Notepad++),
    518 		// which seems worse than always showing 'copy' feedback.
    519 		//
    520 		// However, since effectAllowed is enforced, leaving it at 'copy'
    521 		// would prevent our modified 'move' in the collections tree from working,
    522 		// so we also have to set effectAllowed here (called from onDragOver) to
    523 		// the same action as the dropEffect. This allows the dropEffect setting
    524 		// (which we use in the tree's canDrop() and drop() to determine the desired
    525 		// action) to be changed, even if the cursor doesn't reflect the new setting.
    526 		if (Zotero.isWin || Zotero.isLinux) {
    527 			event.dataTransfer.effectAllowed = effect;
    528 		}
    529 		event.dataTransfer.dropEffect = effect;
    530 	}
    531 };