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 };