@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at recaptime-dev/main 2874 lines 78 kB view raw
1/** 2 * @requires javelin-install 3 * javelin-behavior-device 4 * javelin-dom 5 * javelin-external-editor-link-engine 6 * javelin-magical-init 7 * javelin-stratcom 8 * javelin-uri 9 * javelin-util 10 * javelin-vector 11 * javelin-workflow 12 * phuix-action-view 13 * phuix-action-list-view 14 * phuix-button-view 15 * phuix-dropdown-menu 16 * phuix-icon-view 17 * phabricator-diff-changeset 18 * phabricator-diff-tree-view 19 * phabricator-keyboard-shortcut 20 * phabricator-notification 21 * @provides phabricator-diff-changeset-list 22 * 23 * @javelin-installs JX.DiffChangesetList 24 * 25 * @javelin 26 */ 27 28JX.install('DiffChangesetList', { 29 30 construct: function() { 31 this._changesets = []; 32 33 var onload = JX.bind(this, this._ifawake, this._onload); 34 JX.Stratcom.listen('click', 'differential-load', onload); 35 36 var onmore = JX.bind(this, this._ifawake, this._onmore); 37 JX.Stratcom.listen('click', 'show-more', onmore); 38 39 var onmenu = JX.bind(this, this._ifawake, this._onmenu); 40 JX.Stratcom.listen('click', 'differential-view-options', onmenu); 41 42 var onexpand = JX.bind(this, this._ifawake, this._oncollapse, false); 43 JX.Stratcom.listen('click', 'reveal-inline', onexpand); 44 45 var onresize = JX.bind(this, this._ifawake, this._onresize); 46 JX.Stratcom.listen('resize', null, onresize); 47 48 var onscroll = JX.bind(this, this._ifawake, this._onscroll); 49 JX.Stratcom.listen('scroll', null, onscroll); 50 51 JX.enableDispatch(window, 'selectstart'); 52 53 var onselect = JX.bind(this, this._ifawake, this._onselect); 54 JX.Stratcom.listen( 55 ['mousedown', 'selectstart'], 56 ['differential-inline-comment', 'differential-inline-header'], 57 onselect); 58 59 var onhover = JX.bind(this, this._ifawake, this._onhover); 60 JX.Stratcom.listen( 61 ['mouseover', 'mouseout'], 62 'differential-inline-comment', 63 onhover); 64 65 var onrangedown = JX.bind(this, this._ifawake, this._onrangedown); 66 JX.Stratcom.listen( 67 'mousedown', 68 ['differential-changeset', 'tag:td'], 69 onrangedown); 70 71 var onrangemove = JX.bind(this, this._ifawake, this._onrangemove); 72 JX.Stratcom.listen( 73 ['mouseover', 'mouseout'], 74 ['differential-changeset', 'tag:td'], 75 onrangemove); 76 77 var onrangeup = JX.bind(this, this._ifawake, this._onrangeup); 78 JX.Stratcom.listen( 79 'mouseup', 80 null, 81 onrangeup); 82 83 var onrange = JX.bind(this, this._ifawake, this._onSelectRange); 84 JX.enableDispatch(window, 'selectionchange'); 85 JX.Stratcom.listen('selectionchange', null, onrange); 86 87 this._setupInlineCommentListeners(); 88 }, 89 90 properties: { 91 translations: null, 92 inlineURI: null, 93 inlineListURI: null, 94 isStandalone: false, 95 formationView: null 96 }, 97 98 members: { 99 _initialized: false, 100 _asleep: true, 101 _changesets: null, 102 103 _cursorItem: null, 104 105 _focusNode: null, 106 _focusStart: null, 107 _focusEnd: null, 108 109 _hoverInline: null, 110 _hoverOrigin: null, 111 _hoverTarget: null, 112 113 _rangeActive: false, 114 _rangeOrigin: null, 115 _rangeTarget: null, 116 117 _bannerNode: null, 118 _unsavedButton: null, 119 _unsubmittedButton: null, 120 _doneButton: null, 121 _doneMode: null, 122 123 _dropdownMenu: null, 124 _menuButton: null, 125 _menuItems: null, 126 _selectedChangeset: null, 127 128 sleep: function() { 129 this._asleep = true; 130 131 this._redrawFocus(); 132 this._redrawSelection(); 133 this.resetHover(); 134 135 this._bannerChangeset = null; 136 this._redrawBanner(); 137 }, 138 139 wake: function() { 140 this._asleep = false; 141 142 this._redrawFocus(); 143 this._redrawSelection(); 144 145 this._bannerChangeset = null; 146 this._redrawBanner(); 147 148 this._redrawFiletree(); 149 150 if (this._initialized) { 151 return; 152 } 153 154 this._initialized = true; 155 var pht = this.getTranslations(); 156 157 // We may be viewing the normal "/D123" view (with all the changesets) 158 // or the standalone view (with just one changeset). In the standalone 159 // view, some options (like jumping to next or previous file) do not 160 // make sense and do not function. 161 var standalone = this.getIsStandalone(); 162 163 var label; 164 165 if (!standalone) { 166 label = pht('Jump to the table of contents.'); 167 this._installKey('t', 'diff-nav', label, this._ontoc); 168 169 label = pht('Jump to the comment area.'); 170 this._installKey('x', 'diff-nav', label, this._oncomments); 171 } 172 173 label = pht('Jump to next change.'); 174 this._installJumpKey('j', label, 1); 175 176 label = pht('Jump to previous change.'); 177 this._installJumpKey('k', label, -1); 178 179 if (!standalone) { 180 label = pht('Jump to next file.'); 181 this._installJumpKey('J', label, 1, 'file'); 182 183 label = pht('Jump to previous file.'); 184 this._installJumpKey('K', label, -1, 'file'); 185 } 186 187 label = pht('Jump to next inline comment.'); 188 this._installJumpKey('n', label, 1, 'comment'); 189 190 label = pht('Jump to previous inline comment.'); 191 this._installJumpKey('p', label, -1, 'comment'); 192 193 label = pht('Jump to next inline comment, including collapsed comments.'); 194 this._installJumpKey('N', label, 1, 'comment', true); 195 196 label = pht( 197 'Jump to previous inline comment, including collapsed comments.'); 198 this._installJumpKey('P', label, -1, 'comment', true); 199 200 var formation = this.getFormationView(); 201 if (formation) { 202 var filetree = formation.getColumn(0); 203 var toggletree = JX.bind(filetree, filetree.toggleVisibility); 204 label = pht('Hide or show the paths panel.'); 205 this._installKey('f', 'diff-vis', label, toggletree); 206 } 207 208 if (!standalone) { 209 label = pht('Hide or show the current changeset.'); 210 this._installKey('h', 'diff-vis', label, this._onkeytogglefile); 211 } 212 213 label = pht('Reply to selected inline comment or change.'); 214 this._installKey('r', 'inline', label, 215 JX.bind(this, this._onkeyreply, false)); 216 217 label = pht('Reply and quote selected inline comment.'); 218 this._installKey('R', 'inline', label, 219 JX.bind(this, this._onkeyreply, true)); 220 221 label = pht('Add new inline comment on selected source text.'); 222 this._installKey('c', 'inline', label, 223 JX.bind(this, this._onKeyCreate)); 224 225 label = pht('Edit selected inline comment.'); 226 this._installKey('e', 'inline', label, this._onkeyedit); 227 228 label = pht('Mark or unmark selected inline comment as done.'); 229 this._installKey('w', 'inline', label, this._onkeydone); 230 231 label = pht('Collapse or expand inline comment.'); 232 this._installKey('q', 'diff-vis', label, this._onkeycollapse); 233 234 label = pht('Hide or show all inline comments.'); 235 this._installKey('A', 'diff-vis', label, this._onkeyhideall); 236 237 label = pht('Show path in repository.'); 238 this._installKey('d', 'diff-nav', label, this._onkeyshowpath); 239 240 label = pht('Show directory in repository.'); 241 this._installKey('D', 'diff-nav', label, this._onkeyshowdirectory); 242 243 label = pht('Open file in external editor.'); 244 this._installKey('\\', 'diff-nav', label, this._onkeyopeneditor); 245 }, 246 247 isAsleep: function() { 248 return this._asleep; 249 }, 250 251 newChangesetForNode: function(node) { 252 var changeset = JX.DiffChangeset.getForNode(node); 253 254 this._changesets.push(changeset); 255 changeset.setChangesetList(this); 256 257 return changeset; 258 }, 259 260 getChangesetForNode: function(node) { 261 return JX.DiffChangeset.getForNode(node); 262 }, 263 264 getInlineByID: function(id) { 265 var inline = null; 266 267 for (var ii = 0; ii < this._changesets.length; ii++) { 268 inline = this._changesets[ii].getInlineByID(id); 269 if (inline) { 270 break; 271 } 272 } 273 274 return inline; 275 }, 276 277 _ifawake: function(f) { 278 // This function takes another function and only calls it if the 279 // changeset list is awake, so we basically just ignore events when we 280 // are asleep. This may move up the stack at some point as we do more 281 // with Quicksand/Sheets. 282 283 if (this.isAsleep()) { 284 return; 285 } 286 287 return f.apply(this, [].slice.call(arguments, 1)); 288 }, 289 290 _onload: function(e) { 291 var data = e.getNodeData('differential-load'); 292 293 // NOTE: We can trigger a load from either an explicit "Load" link on 294 // the changeset, or by clicking a link in the table of contents. If 295 // the event was a table of contents link, we let the anchor behavior 296 // run normally. 297 if (data.kill) { 298 e.kill(); 299 } 300 301 var node = JX.$(data.id); 302 var changeset = this.getChangesetForNode(node); 303 304 changeset.load(); 305 306 // TODO: Move this into Changeset. 307 var routable = changeset.getRoutable(); 308 if (routable) { 309 routable.setPriority(2000); 310 } 311 }, 312 313 _installKey: function(key, group, label, handler) { 314 handler = JX.bind(this, this._ifawake, handler); 315 316 return new JX.KeyboardShortcut(key, label) 317 .setHandler(handler) 318 .setGroup(group) 319 .register(); 320 }, 321 322 _installJumpKey: function(key, label, delta, filter, show_collapsed) { 323 filter = filter || null; 324 325 var options = { 326 filter: filter, 327 collapsed: show_collapsed 328 }; 329 330 var handler = JX.bind(this, this._onjumpkey, delta, options); 331 return this._installKey(key, 'diff-nav', label, handler); 332 }, 333 334 _ontoc: function(manager) { 335 var toc = JX.$('toc'); 336 manager.scrollTo(toc); 337 }, 338 339 _oncomments: function(manager) { 340 var reply = JX.$('reply'); 341 manager.scrollTo(reply); 342 }, 343 344 getSelectedInline: function() { 345 var cursor = this._cursorItem; 346 347 if (cursor) { 348 if (cursor.type == 'comment') { 349 return cursor.target; 350 } 351 } 352 353 return null; 354 }, 355 356 _onkeyreply: function(is_quote) { 357 var cursor = this._cursorItem; 358 359 if (cursor) { 360 if (cursor.type == 'comment') { 361 var inline = cursor.target; 362 if (inline.canReply()) { 363 this.setFocus(null); 364 inline.reply(is_quote); 365 return; 366 } 367 } 368 369 // If the keyboard cursor is selecting a range of lines, we may have 370 // a mixture of old and new changes on the selected rows. It is not 371 // entirely unambiguous what the user means when they say they want 372 // to reply to this, but we use this logic: reply on the new file if 373 // there are any new lines. Otherwise (if there are only removed 374 // lines) reply on the old file. 375 376 if (cursor.type == 'change') { 377 var cells = this._getLineNumberCellsForChangeBlock( 378 cursor.nodes.begin, 379 cursor.nodes.end); 380 381 cursor.changeset.newInlineForRange(cells.src, cells.dst); 382 383 this.setFocus(null); 384 return; 385 } 386 } 387 388 var pht = this.getTranslations(); 389 this._warnUser(pht('You must select a comment or change to reply to.')); 390 }, 391 392 _getLineNumberCellsForChangeBlock: function(origin, target) { 393 // The "origin" and "target" are entire rows, but we need to find 394 // a range of cell nodes to actually create an inline, so go 395 // fishing. 396 397 var old_list = []; 398 var new_list = []; 399 400 var row = origin; 401 while (row) { 402 var header = row.firstChild; 403 while (header) { 404 if (this.getLineNumberFromHeader(header)) { 405 if (header.className.indexOf('old') !== -1) { 406 old_list.push(header); 407 } else if (header.className.indexOf('new') !== -1) { 408 new_list.push(header); 409 } 410 } 411 header = header.nextSibling; 412 } 413 414 if (row == target) { 415 break; 416 } 417 418 row = row.nextSibling; 419 } 420 421 var use_list; 422 if (new_list.length) { 423 use_list = new_list; 424 } else { 425 use_list = old_list; 426 } 427 428 var src = use_list[0]; 429 var dst = use_list[use_list.length - 1]; 430 431 return { 432 src: src, 433 dst: dst 434 }; 435 }, 436 437 _onkeyedit: function() { 438 var cursor = this._cursorItem; 439 440 if (cursor) { 441 if (cursor.type == 'comment') { 442 var inline = cursor.target; 443 if (inline.canEdit()) { 444 this.setFocus(null); 445 446 inline.edit(); 447 return; 448 } 449 } 450 } 451 452 var pht = this.getTranslations(); 453 this._warnUser(pht('You must select a comment to edit.')); 454 }, 455 456 _onKeyCreate: function() { 457 var start = this._sourceSelectionStart; 458 var end = this._sourceSelectionEnd; 459 460 if (!this._sourceSelectionStart) { 461 var pht = this.getTranslations(); 462 this._warnUser( 463 pht( 464 'You must select source text to create a new inline comment.')); 465 return; 466 } 467 468 this._setSourceSelection(null, null); 469 470 var changeset = start.changeset; 471 472 var config = {}; 473 if (changeset.getResponseDocumentEngineKey() === null) { 474 // If the changeset is using a document renderer, we ignore the 475 // selection range and just treat this as a comment from the first 476 // block to the last block. 477 478 // If we don't discard the range, we later render a bogus highlight 479 // if the block content is complex (like a Jupyter notebook cell 480 // with images). 481 482 config.startOffset = start.offset; 483 config.endOffset = end.offset; 484 } 485 486 changeset.newInlineForRange(start.targetNode, end.targetNode, config); 487 }, 488 489 _onkeydone: function() { 490 var cursor = this._cursorItem; 491 492 if (cursor) { 493 if (cursor.type == 'comment') { 494 var inline = cursor.target; 495 if (inline.canDone()) { 496 this.setFocus(null); 497 498 inline.toggleDone(); 499 return; 500 } 501 } 502 } 503 504 var pht = this.getTranslations(); 505 this._warnUser(pht('You must select a comment to mark done.')); 506 }, 507 508 _onkeytogglefile: function() { 509 var pht = this.getTranslations(); 510 var changeset = this._getChangesetForKeyCommand(); 511 512 if (!changeset) { 513 this._warnUser(pht('You must select a file to hide or show.')); 514 return; 515 } 516 517 changeset.toggleVisibility(); 518 }, 519 520 _getChangesetForKeyCommand: function() { 521 var cursor = this._cursorItem; 522 523 var changeset; 524 if (cursor) { 525 changeset = cursor.changeset; 526 } 527 528 if (!changeset) { 529 changeset = this._getVisibleChangeset(); 530 } 531 532 return changeset; 533 }, 534 535 _onkeyopeneditor: function(e) { 536 var pht = this.getTranslations(); 537 var changeset = this._getChangesetForKeyCommand(); 538 539 if (!changeset) { 540 this._warnUser(pht('You must select a file to edit.')); 541 return; 542 } 543 544 this._openEditor(changeset); 545 }, 546 547 _openEditor: function(changeset) { 548 var pht = this.getTranslations(); 549 550 var editor_template = changeset.getEditorURITemplate(); 551 if (editor_template === null) { 552 this._warnUser(pht('No external editor is configured.')); 553 return; 554 } 555 556 var line = null; 557 558 // See PHI1749. We aren't exactly sure what the user intends when they 559 // use the keyboard to select a change block and then activate the 560 // "Open in Editor" function: they might mean to open the old or new 561 // offset, and may have the old or new state (or some other state) in 562 // their working copy. 563 564 // For now, pick: the new state line number if one exists; or the old 565 // state line number if one does not. If nothing else, this behavior is 566 // simple. 567 568 // If there's a document engine, just open the file to the first line. 569 // We currently can not map display blocks to source lines. 570 571 // If there's an inline, open the file to that line. 572 573 if (changeset.getResponseDocumentEngineKey() === null) { 574 var cursor = this._cursorItem; 575 if (cursor && (cursor.changeset === changeset)) { 576 if (cursor.type == 'change') { 577 var cells = this._getLineNumberCellsForChangeBlock( 578 cursor.nodes.begin, 579 cursor.nodes.end); 580 line = this.getLineNumberFromHeader(cells.src); 581 } 582 583 if (cursor.type === 'comment') { 584 var inline = cursor.target; 585 line = inline.getLineNumber(); 586 } 587 } 588 } 589 590 var variables = { 591 l: line || 1 592 }; 593 594 var editor_uri = new JX.ExternalEditorLinkEngine() 595 .setTemplate(editor_template) 596 .setVariables(variables) 597 .newURI(); 598 599 JX.$U(editor_uri).go(); 600 }, 601 602 _onkeyshowpath: function() { 603 this._onrepositorykey(false); 604 }, 605 606 _onkeyshowdirectory: function() { 607 this._onrepositorykey(true); 608 }, 609 610 _onrepositorykey: function(is_directory) { 611 var pht = this.getTranslations(); 612 var changeset = this._getChangesetForKeyCommand(); 613 614 if (!changeset) { 615 this._warnUser(pht('You must select a file to open.')); 616 return; 617 } 618 619 var show_uri; 620 if (is_directory) { 621 show_uri = changeset.getShowDirectoryURI(); 622 } else { 623 show_uri = changeset.getShowPathURI(); 624 } 625 626 if (show_uri === null) { 627 return; 628 } 629 630 window.open(show_uri); 631 }, 632 633 _onkeycollapse: function() { 634 var cursor = this._cursorItem; 635 636 if (cursor) { 637 if (cursor.type == 'comment') { 638 var inline = cursor.target; 639 if (inline.canCollapse()) { 640 this.setFocus(null); 641 642 inline.setCollapsed(!inline.isCollapsed()); 643 return; 644 } 645 } 646 } 647 648 var pht = this.getTranslations(); 649 this._warnUser(pht('You must select a comment to hide.')); 650 }, 651 652 _onkeyhideall: function() { 653 var inlines = this._getInlinesByType(); 654 if (inlines.visible.length) { 655 this._toggleInlines('all'); 656 } else { 657 this._toggleInlines('show'); 658 } 659 }, 660 661 _warnUser: function(message) { 662 new JX.Notification() 663 .setContent(message) 664 .alterClassName('jx-notification-alert', true) 665 .setDuration(3000) 666 .show(); 667 }, 668 669 _onjumpkey: function(delta, options) { 670 var state = this._getSelectionState(); 671 672 var filter = options.filter || null; 673 var collapsed = options.collapsed || false; 674 var wrap = options.wrap || false; 675 var attribute = options.attribute || null; 676 var show = options.show || false; 677 678 var cursor = state.cursor; 679 var items = state.items; 680 681 // If there's currently no selection and the user tries to go back, 682 // don't do anything. 683 if ((cursor === null) && (delta < 0)) { 684 return; 685 } 686 687 var did_wrap = false; 688 while (true) { 689 if (cursor === null) { 690 cursor = 0; 691 } else { 692 cursor = cursor + delta; 693 } 694 695 // If we've gone backward past the first change, bail out. 696 if (cursor < 0) { 697 return; 698 } 699 700 // If we've gone forward off the end of the list, figure out where we 701 // should end up. 702 if (cursor >= items.length) { 703 if (!wrap) { 704 // If we aren't wrapping around, we're done. 705 return; 706 } 707 708 if (did_wrap) { 709 // If we're already wrapped around, we're done. 710 return; 711 } 712 713 // Otherwise, wrap the cursor back to the top. 714 cursor = 0; 715 did_wrap = true; 716 } 717 718 // If we're selecting things of a particular type (like only files) 719 // and the next item isn't of that type, move past it. 720 if (filter !== null) { 721 if (items[cursor].type !== filter) { 722 continue; 723 } 724 } 725 726 // If the item is collapsed, don't select it when iterating with jump 727 // keys. It can still potentially be selected in other ways. 728 if (!collapsed) { 729 if (items[cursor].collapsed) { 730 continue; 731 } 732 } 733 734 // If the item has been deleted, don't select it when iterating. The 735 // cursor may remain on it until it is removed. 736 if (items[cursor].deleted) { 737 continue; 738 } 739 740 // If we're selecting things with a particular attribute, like 741 // "unsaved", skip items without the attribute. 742 if (attribute !== null) { 743 if (!(items[cursor].attributes || {})[attribute]) { 744 continue; 745 } 746 } 747 748 // If this item is a hidden inline but we're clicking a button which 749 // selects inlines of a particular type, make it visible again. 750 if (items[cursor].hidden) { 751 if (!show) { 752 continue; 753 } 754 items[cursor].target.setHidden(false); 755 } 756 757 // Otherwise, we've found a valid item to select. 758 break; 759 } 760 761 this._setSelectionState(items[cursor], true); 762 }, 763 764 _getSelectionState: function() { 765 var items = this._getSelectableItems(); 766 767 var cursor = null; 768 if (this._cursorItem !== null) { 769 for (var ii = 0; ii < items.length; ii++) { 770 var item = items[ii]; 771 if (this._cursorItem.target === item.target) { 772 cursor = ii; 773 break; 774 } 775 } 776 } 777 778 return { 779 cursor: cursor, 780 items: items 781 }; 782 }, 783 784 selectChangeset: function(changeset, scroll) { 785 var items = this._getSelectableItems(); 786 787 var cursor = null; 788 for (var ii = 0; ii < items.length; ii++) { 789 var item = items[ii]; 790 if (changeset === item.target) { 791 cursor = ii; 792 break; 793 } 794 } 795 796 if (cursor !== null) { 797 this._setSelectionState(items[cursor], scroll); 798 } else { 799 this._setSelectionState(null, false); 800 } 801 802 return this; 803 }, 804 805 _setSelectionState: function(item, scroll) { 806 var old = this._cursorItem; 807 808 if (old) { 809 if (old.type === 'comment') { 810 old.target.setIsSelected(false); 811 } 812 } 813 814 this._cursorItem = item; 815 816 if (item) { 817 if (item.type === 'comment') { 818 item.target.setIsSelected(true); 819 } 820 } 821 822 this._redrawSelection(scroll); 823 824 return this; 825 }, 826 827 _redrawSelection: function(scroll) { 828 var cursor = this._cursorItem; 829 if (!cursor) { 830 this.setFocus(null); 831 return; 832 } 833 834 // If this item has been removed from the document (for example: create 835 // a new empty comment, then use the "Unsaved" button to select it, then 836 // cancel it), we can still keep the cursor here but do not want to show 837 // a selection reticle over an invisible node. 838 if (cursor.deleted) { 839 this.setFocus(null); 840 return; 841 } 842 843 var changeset = cursor.changeset; 844 845 var tree = this._getTreeView(); 846 if (changeset) { 847 tree.setSelectedPath(cursor.changeset.getPathView()); 848 } else { 849 tree.setSelectedPath(null); 850 } 851 852 this._selectChangeset(changeset); 853 854 this.setFocus(cursor.nodes.begin, cursor.nodes.end); 855 856 if (scroll) { 857 var pos = JX.$V(cursor.nodes.begin); 858 JX.DOM.scrollToPosition(0, pos.y - 60); 859 } 860 861 return this; 862 }, 863 864 redrawCursor: function() { 865 // NOTE: This is setting the cursor to the current cursor. Usually, this 866 // would have no effect. 867 868 // However, if the old cursor pointed at an inline and the inline has 869 // been edited so the rows have changed, this updates the cursor to point 870 // at the new inline with the proper rows for the current state, and 871 // redraws the reticle correctly. 872 873 var state = this._getSelectionState(); 874 if (state.cursor !== null) { 875 this._setSelectionState(state.items[state.cursor], false); 876 } 877 }, 878 879 _getSelectableItems: function() { 880 var result = []; 881 882 for (var ii = 0; ii < this._changesets.length; ii++) { 883 var items = this._changesets[ii].getSelectableItems(); 884 for (var jj = 0; jj < items.length; jj++) { 885 result.push(items[jj]); 886 } 887 } 888 889 return result; 890 }, 891 892 _onhover: function(e) { 893 if (e.getIsTouchEvent()) { 894 return; 895 } 896 897 var inline; 898 if (e.getType() == 'mouseout') { 899 inline = null; 900 } else { 901 inline = this._getInlineForEvent(e); 902 } 903 904 this._setHoverInline(inline); 905 }, 906 907 _onmore: function(e) { 908 e.kill(); 909 910 var node = e.getNode('differential-changeset'); 911 var changeset = this.getChangesetForNode(node); 912 913 var data = e.getNodeData('show-more'); 914 var target = e.getNode('context-target'); 915 916 changeset.loadContext(data.range, target); 917 }, 918 919 _onmenu: function(e) { 920 var button = e.getNode('differential-view-options'); 921 922 var data = JX.Stratcom.getData(button); 923 if (data.menu) { 924 // We've already built this menu, so we can let the menu itself handle 925 // the event. 926 return; 927 } 928 929 e.prevent(); 930 931 var pht = this.getTranslations(); 932 933 var node = JX.DOM.findAbove( 934 button, 935 'div', 936 'differential-changeset'); 937 938 var changeset_list = this; 939 var changeset = this.getChangesetForNode(node); 940 941 var menu = new JX.PHUIXDropdownMenu(button) 942 .setWidth(240); 943 var list = new JX.PHUIXActionListView(); 944 945 var add_link = function(icon, name, href, local) { 946 var link = new JX.PHUIXActionView() 947 .setIcon(icon) 948 .setName(name) 949 .setHandler(function(e) { 950 if (local) { 951 window.location.assign(href); 952 } else { 953 window.open(href); 954 } 955 menu.close(); 956 e.prevent(); 957 }); 958 959 if (href) { 960 link.setHref(href); 961 } else { 962 link 963 .setDisabled(true) 964 .setUnresponsive(true); 965 } 966 967 list.addItem(link); 968 return link; 969 }; 970 971 var visible_item = new JX.PHUIXActionView() 972 .setKeyCommand('h') 973 .setHandler(function(e) { 974 e.prevent(); 975 menu.close(); 976 977 changeset.select(false); 978 changeset.toggleVisibility(); 979 }); 980 list.addItem(visible_item); 981 982 var reveal_item = new JX.PHUIXActionView() 983 .setIcon('fa-eye'); 984 list.addItem(reveal_item); 985 986 list.addItem( 987 new JX.PHUIXActionView() 988 .setDivider(true)); 989 990 var up_item = new JX.PHUIXActionView() 991 .setHandler(function(e) { 992 if (changeset.isLoaded()) { 993 994 // Don't let the user swap display modes if a comment is being 995 // edited, since they might lose their work. See PHI180. 996 var inlines = changeset.getInlines(); 997 for (var ii = 0; ii < inlines.length; ii++) { 998 if (inlines[ii].isEditing()) { 999 changeset_list._warnUser( 1000 pht( 1001 'Finish editing inline comments before changing display ' + 1002 'modes.')); 1003 e.prevent(); 1004 menu.close(); 1005 return; 1006 } 1007 } 1008 1009 var renderer = changeset.getRendererKey(); 1010 if (renderer == '1up') { 1011 renderer = '2up'; 1012 } else { 1013 renderer = '1up'; 1014 } 1015 changeset.reload({renderer: renderer}); 1016 } else { 1017 changeset.reload(); 1018 } 1019 1020 e.prevent(); 1021 menu.close(); 1022 }); 1023 list.addItem(up_item); 1024 1025 var encoding_item = new JX.PHUIXActionView() 1026 .setIcon('fa-font') 1027 .setName(pht('Change Text Encoding...')) 1028 .setHandler(function(e) { 1029 var params = { 1030 encoding: changeset.getCharacterEncoding() 1031 }; 1032 1033 new JX.Workflow('/services/encoding/', params) 1034 .setHandler(function(r) { 1035 changeset.reload({encoding: r.encoding}); 1036 }) 1037 .start(); 1038 1039 e.prevent(); 1040 menu.close(); 1041 }); 1042 list.addItem(encoding_item); 1043 1044 var highlight_item = new JX.PHUIXActionView() 1045 .setIcon('fa-sun-o') 1046 .setName(pht('Highlight As...')) 1047 .setHandler(function(e) { 1048 var params = { 1049 highlight: changeset.getHighlight() 1050 }; 1051 1052 new JX.Workflow('/services/highlight/', params) 1053 .setHandler(function(r) { 1054 changeset.reload({highlight: r.highlight}); 1055 }) 1056 .start(); 1057 1058 e.prevent(); 1059 menu.close(); 1060 }); 1061 list.addItem(highlight_item); 1062 1063 var engine_item = new JX.PHUIXActionView() 1064 .setIcon('fa-file-image-o') 1065 .setName(pht('View As Document Type...')) 1066 .setHandler(function(e) { 1067 var options = changeset.getAvailableDocumentEngineKeys() || []; 1068 options = options.join(','); 1069 1070 var params = { 1071 engine: changeset.getResponseDocumentEngineKey(), 1072 options: options 1073 }; 1074 1075 new JX.Workflow('/services/viewas/', params) 1076 .setHandler(function(r) { 1077 changeset.reload({engine: r.engine}); 1078 }) 1079 .start(); 1080 1081 e.prevent(); 1082 menu.close(); 1083 }); 1084 list.addItem(engine_item); 1085 1086 list.addItem( 1087 new JX.PHUIXActionView() 1088 .setDivider(true)); 1089 1090 add_link('fa-external-link', pht('View Standalone'), data.standaloneURI); 1091 1092 add_link('fa-arrow-left', pht('Show Raw File (Left)'), data.leftURI); 1093 add_link('fa-arrow-right', pht('Show Raw File (Right)'), data.rightURI); 1094 1095 add_link( 1096 'fa-folder-open-o', 1097 pht('Show Directory in Repository'), 1098 changeset.getShowDirectoryURI()) 1099 .setKeyCommand('D'); 1100 1101 add_link( 1102 'fa-file-text-o', 1103 pht('Show Path in Repository'), 1104 changeset.getShowPathURI()) 1105 .setKeyCommand('d'); 1106 1107 var editor_template = changeset.getEditorURITemplate(); 1108 if (editor_template !== null) { 1109 var editor_item = new JX.PHUIXActionView() 1110 .setIcon('fa-i-cursor') 1111 .setName(pht('Open in Editor')) 1112 .setKeyCommand('\\') 1113 .setHandler(function(e) { 1114 1115 changeset_list._openEditor(changeset); 1116 1117 e.prevent(); 1118 menu.close(); 1119 }); 1120 1121 list.addItem(editor_item); 1122 } else { 1123 var configure_uri = changeset.getEditorConfigureURI(); 1124 if (configure_uri !== null) { 1125 add_link('fa-wrench', pht('Configure Editor'), configure_uri); 1126 } 1127 } 1128 1129 menu.setContent(list.getNode()); 1130 1131 menu.listen('open', function() { 1132 // When the user opens the menu, check if there are any "Show More" 1133 // links in the changeset body. If there aren't, disable the "Show 1134 // Entire File" menu item since it won't change anything. 1135 1136 var nodes = JX.DOM.scry(JX.$(data.containerID), 'a', 'show-more'); 1137 if (nodes.length) { 1138 reveal_item 1139 .setDisabled(false) 1140 .setName(pht('Show All Context')) 1141 .setIcon('fa-arrows-v') 1142 .setHandler(function(e) { 1143 changeset.loadAllContext(); 1144 e.prevent(); 1145 menu.close(); 1146 }); 1147 } else { 1148 reveal_item 1149 .setDisabled(true) 1150 .setUnresponsive(true) 1151 .setIcon('fa-file') 1152 .setName(pht('All Context Shown')) 1153 .setHref(null); 1154 } 1155 1156 encoding_item.setDisabled(!changeset.isLoaded()); 1157 highlight_item.setDisabled(!changeset.isLoaded()); 1158 engine_item.setDisabled(!changeset.isLoaded()); 1159 1160 if (changeset.isLoaded()) { 1161 if (changeset.getRendererKey() == '2up') { 1162 up_item 1163 .setIcon('fa-list-alt') 1164 .setName(pht('View Unified Diff')); 1165 } else { 1166 up_item 1167 .setIcon('fa-columns') 1168 .setName(pht('View Side-by-Side Diff')); 1169 } 1170 } else { 1171 up_item 1172 .setIcon('fa-refresh') 1173 .setName(pht('Load Changes')); 1174 } 1175 1176 visible_item 1177 .setDisabled(true) 1178 .setIcon('fa-eye-slash') 1179 .setName(pht('Hide Changeset')); 1180 1181 var diffs = JX.DOM.scry( 1182 JX.$(data.containerID), 1183 'table', 1184 'differential-diff'); 1185 1186 if (diffs.length > 1) { 1187 JX.$E( 1188 'More than one node with sigil "differential-diff" was found in "'+ 1189 data.containerID+'."'); 1190 } else if (diffs.length == 1) { 1191 visible_item.setDisabled(false); 1192 } else { 1193 // Do nothing when there is no diff shown in the table. For example, 1194 // the file is binary. 1195 } 1196 1197 }); 1198 1199 data.menu = menu; 1200 changeset.setViewMenu(menu); 1201 menu.open(); 1202 }, 1203 1204 _oncollapse: function(is_collapse, e) { 1205 e.kill(); 1206 1207 var inline = this._getInlineForEvent(e); 1208 1209 inline.setCollapsed(is_collapse); 1210 }, 1211 1212 _onresize: function() { 1213 this._redrawFocus(); 1214 this._redrawSelection(); 1215 1216 // Force a banner redraw after a resize event. Particularly, this makes 1217 // sure the inline state updates immediately after an inline edit 1218 // operation, even if the changeset itself has not changed. 1219 this._bannerChangeset = null; 1220 1221 this._redrawBanner(); 1222 1223 var changesets = this._changesets; 1224 for (var ii = 0; ii < changesets.length; ii++) { 1225 changesets[ii].redrawFileTree(); 1226 } 1227 }, 1228 1229 _onscroll: function() { 1230 this._redrawBanner(); 1231 }, 1232 1233 _onselect: function(e) { 1234 // If the user clicked some element inside the header, like an action 1235 // icon, ignore the event. They have to click the header element itself. 1236 if (e.getTarget() !== e.getNode('differential-inline-header')) { 1237 return; 1238 } 1239 1240 // If the user has double-clicked or triple-clicked a header, we want to 1241 // toggle the inline selection mode, not select text. Kill select events 1242 // originating with this element as the target. 1243 if (e.getType() === 'selectstart') { 1244 e.kill(); 1245 return; 1246 } 1247 1248 var inline = this._getInlineForEvent(e); 1249 if (!inline) { 1250 return; 1251 } 1252 1253 // NOTE: Don't kill or prevent the event. In particular, we want this 1254 // click to clear any text selection as it normally would. 1255 1256 this.selectInline(inline); 1257 }, 1258 1259 selectInline: function(inline, force, scroll) { 1260 var selection = this._getSelectionState(); 1261 var item; 1262 1263 if (!force) { 1264 // If the comment the user clicked is currently selected, deselect it. 1265 // This makes it easy to undo things if you clicked by mistake. 1266 if (selection.cursor !== null) { 1267 item = selection.items[selection.cursor]; 1268 if (item.target === inline) { 1269 this._setSelectionState(null, false); 1270 return; 1271 } 1272 } 1273 } 1274 1275 // Otherwise, select the item that the user clicked. This makes it 1276 // easier to resume keyboard operations after using the mouse to do 1277 // something else. 1278 var items = selection.items; 1279 for (var ii = 0; ii < items.length; ii++) { 1280 item = items[ii]; 1281 if (item.target === inline) { 1282 this._setSelectionState(item, scroll); 1283 } 1284 } 1285 1286 }, 1287 1288 redrawPreview: function() { 1289 // TODO: This isn't the cleanest way to find the preview form, but 1290 // rendering no longer has direct access to it. 1291 var forms = JX.DOM.scry(document.body, 'form', 'transaction-append'); 1292 if (forms.length) { 1293 JX.DOM.invoke(forms[0], 'shouldRefresh'); 1294 } 1295 1296 // Clear the mouse hover reticle after a substantive edit: we don't get 1297 // a "mouseout" event if the row vanished because of row being removed 1298 // after an edit. 1299 this.resetHover(); 1300 }, 1301 1302 setFocus: function(node, extended_node) { 1303 if (!node) { 1304 var tree = this._getTreeView(); 1305 tree.setSelectedPath(null); 1306 this._selectChangeset(null); 1307 } 1308 1309 this._focusStart = node; 1310 this._focusEnd = extended_node; 1311 this._redrawFocus(); 1312 }, 1313 1314 _selectChangeset: function(changeset) { 1315 if (this._selectedChangeset === changeset) { 1316 return; 1317 } 1318 1319 if (this._selectedChangeset !== null) { 1320 this._selectedChangeset.setIsSelected(false); 1321 this._selectedChangeset = null; 1322 } 1323 1324 this._selectedChangeset = changeset; 1325 if (this._selectedChangeset !== null) { 1326 this._selectedChangeset.setIsSelected(true); 1327 } 1328 }, 1329 1330 _redrawFocus: function() { 1331 var node = this._focusStart; 1332 var extended_node = this._focusEnd || node; 1333 1334 var reticle = this._getFocusNode(); 1335 if (!node || this.isAsleep()) { 1336 JX.DOM.remove(reticle); 1337 return; 1338 } 1339 1340 // Outset the reticle some pixels away from the element, so there's some 1341 // space between the focused element and the outline. 1342 var p = JX.Vector.getPos(node); 1343 var s = JX.Vector.getAggregateScrollForNode(node); 1344 var d = JX.Vector.getDim(node); 1345 1346 p.add(s).add(d.x + 1, 4).setPos(reticle); 1347 // Compute the size we need to extend to the full extent of the focused 1348 // nodes. 1349 JX.Vector.getPos(extended_node) 1350 .add(-p.x, -p.y) 1351 .add(0, JX.Vector.getDim(extended_node).y) 1352 .add(10, -4) 1353 .setDim(reticle); 1354 1355 JX.DOM.getContentFrame().appendChild(reticle); 1356 }, 1357 1358 _getFocusNode: function() { 1359 if (!this._focusNode) { 1360 var node = JX.$N('div', {className : 'keyboard-focus-focus-reticle'}); 1361 this._focusNode = node; 1362 } 1363 return this._focusNode; 1364 }, 1365 1366 _setHoverInline: function(inline) { 1367 var origin = null; 1368 var target = null; 1369 1370 if (inline) { 1371 var changeset = inline.getChangeset(); 1372 1373 var changeset_id; 1374 var side = inline.getDisplaySide(); 1375 if (side == 'right') { 1376 changeset_id = changeset.getRightChangesetID(); 1377 } else { 1378 changeset_id = changeset.getLeftChangesetID(); 1379 } 1380 1381 var new_part; 1382 if (inline.isNewFile()) { 1383 new_part = 'N'; 1384 } else { 1385 new_part = 'O'; 1386 } 1387 1388 var prefix = 'C' + changeset_id + new_part + 'L'; 1389 1390 var number = inline.getLineNumber(); 1391 var length = inline.getLineLength(); 1392 1393 try { 1394 origin = JX.$(prefix + number); 1395 target = JX.$(prefix + (number + length)); 1396 } catch (error) { 1397 // There may not be any nodes present in the document. A case where 1398 // this occurs is when you reply to a ghost inline which was made 1399 // on lines near the bottom of "long.txt" in an earlier diff, and 1400 // the file was later shortened so those lines no longer exist. For 1401 // more details, see T11662. 1402 1403 origin = null; 1404 target = null; 1405 } 1406 } 1407 1408 this._setHoverRange(origin, target, inline); 1409 }, 1410 1411 _setHoverRange: function(origin, target, inline) { 1412 inline = inline || null; 1413 1414 var origin_dirty = (origin !== this._hoverOrigin); 1415 var target_dirty = (target !== this._hoverTarget); 1416 var inline_dirty = (inline !== this._hoverInline); 1417 1418 var any_dirty = (origin_dirty || target_dirty || inline_dirty); 1419 if (any_dirty) { 1420 this._hoverOrigin = origin; 1421 this._hoverTarget = target; 1422 this._hoverInline = inline; 1423 this._redrawHover(); 1424 } 1425 }, 1426 1427 resetHover: function() { 1428 this._setHoverRange(null, null, null); 1429 }, 1430 1431 _redrawHover: function() { 1432 var map = this._hoverMap; 1433 if (map) { 1434 this._hoverMap = null; 1435 this._applyHoverHighlight(map, false); 1436 } 1437 1438 var rows = this._hoverRows; 1439 if (rows) { 1440 this._hoverRows = null; 1441 this._applyHoverHighlight(rows, false); 1442 } 1443 1444 if (!this._hoverOrigin || this.isAsleep()) { 1445 return; 1446 } 1447 1448 var top = this._hoverOrigin; 1449 var bot = this._hoverTarget; 1450 if (JX.$V(top).y > JX.$V(bot).y) { 1451 var tmp = top; 1452 top = bot; 1453 bot = tmp; 1454 } 1455 1456 // Find the leftmost cell that we're going to highlight. This is the 1457 // next sibling with a "data-copy-mode" attribute, which is a marker 1458 // for the cell with actual content in it. 1459 var content_cell = top; 1460 while (content_cell && !this._isContentCell(content_cell)) { 1461 content_cell = content_cell.nextSibling; 1462 } 1463 1464 // If we didn't find a cell to highlight, don't highlight anything. 1465 if (!content_cell) { 1466 return; 1467 } 1468 1469 rows = this._findContentCells(top, bot, content_cell); 1470 1471 var inline = this._hoverInline; 1472 if (!inline) { 1473 this._hoverRows = rows; 1474 this._applyHoverHighlight(this._hoverRows, true); 1475 return; 1476 } 1477 1478 if (!inline.hoverMap) { 1479 inline.hoverMap = this._newHoverMap(rows, inline); 1480 } 1481 1482 this._hoverMap = inline.hoverMap; 1483 this._applyHoverHighlight(this._hoverMap, true); 1484 }, 1485 1486 _applyHoverHighlight: function(items, on) { 1487 for (var ii = 0; ii < items.length; ii++) { 1488 var item = items[ii]; 1489 1490 JX.DOM.alterClass(item.lineNode, 'inline-hover', on); 1491 JX.DOM.alterClass(item.cellNode, 'inline-hover', on); 1492 1493 if (item.bright) { 1494 JX.DOM.alterClass(item.cellNode, 'inline-hover-bright', on); 1495 } 1496 1497 if (item.hoverNode) { 1498 if (on) { 1499 item.cellNode.insertBefore( 1500 item.hoverNode, 1501 item.cellNode.firstChild); 1502 } else { 1503 JX.DOM.remove(item.hoverNode); 1504 } 1505 } 1506 } 1507 }, 1508 1509 _findContentCells: function(top, bot, content_cell) { 1510 var head_row = JX.DOM.findAbove(top, 'tr'); 1511 var last_row = JX.DOM.findAbove(bot, 'tr'); 1512 1513 var cursor = head_row; 1514 var rows = []; 1515 var idx = null; 1516 var ii; 1517 var line_cell = null; 1518 do { 1519 line_cell = null; 1520 for (ii = 0; ii < cursor.childNodes.length; ii++) { 1521 var child = cursor.childNodes[ii]; 1522 if (!JX.DOM.isType(child, 'td')) { 1523 continue; 1524 } 1525 1526 if (child.getAttribute('data-n')) { 1527 line_cell = child; 1528 } 1529 1530 if (child === content_cell) { 1531 idx = ii; 1532 } 1533 1534 if (ii !== idx) { 1535 continue; 1536 } 1537 1538 if (this._isContentCell(child)) { 1539 rows.push({ 1540 lineNode: line_cell, 1541 cellNode: child 1542 }); 1543 } 1544 1545 break; 1546 } 1547 1548 if (cursor === last_row) { 1549 break; 1550 } 1551 1552 cursor = cursor.nextSibling; 1553 } while (cursor); 1554 1555 return rows; 1556 }, 1557 1558 _newHoverMap: function(rows, inline) { 1559 var start = inline.getStartOffset(); 1560 var end = inline.getEndOffset(); 1561 1562 var info; 1563 var content; 1564 for (ii = 0; ii < rows.length; ii++) { 1565 info = this._getSelectionOffset(rows[ii].cellNode, null); 1566 1567 content = info.content; 1568 content = content.replace(/\n+$/, ''); 1569 1570 rows[ii].content = content; 1571 } 1572 1573 var attr_dull = { 1574 className: 'inline-hover-text' 1575 }; 1576 1577 var attr_bright = { 1578 className: 'inline-hover-text inline-hover-text-bright' 1579 }; 1580 1581 var attr_container = { 1582 className: 'inline-hover-container' 1583 }; 1584 1585 var min = 0; 1586 var max = rows.length - 1; 1587 var offset_min; 1588 var offset_max; 1589 var len; 1590 var node; 1591 var text; 1592 var any_highlight = false; 1593 for (ii = 0; ii < rows.length; ii++) { 1594 content = rows[ii].content; 1595 len = content.length; 1596 1597 if (ii === min && (start !== null)) { 1598 offset_min = start; 1599 } else { 1600 offset_min = 0; 1601 } 1602 1603 if (ii === max && (end !== null)) { 1604 offset_max = Math.min(end, len); 1605 } else { 1606 offset_max = len; 1607 } 1608 1609 var has_min = (offset_min > 0); 1610 var has_max = (offset_max < len); 1611 1612 if (has_min || has_max) { 1613 any_highlight = true; 1614 } 1615 1616 rows[ii].min = offset_min; 1617 rows[ii].max = offset_max; 1618 rows[ii].hasMin = has_min; 1619 rows[ii].hasMax = has_max; 1620 } 1621 1622 for (ii = 0; ii < rows.length; ii++) { 1623 content = rows[ii].content; 1624 offset_min = rows[ii].min; 1625 offset_max = rows[ii].max; 1626 1627 var has_highlight = (rows[ii].hasMin || rows[ii].hasMax); 1628 1629 if (any_highlight) { 1630 var parts = []; 1631 1632 if (offset_min > 0) { 1633 text = content.substring(0, offset_min); 1634 node = JX.$N('span', attr_dull, text); 1635 parts.push(node); 1636 } 1637 1638 if (len) { 1639 text = content.substring(offset_min, offset_max); 1640 node = JX.$N('span', attr_bright, text); 1641 parts.push(node); 1642 } 1643 1644 if (offset_max < len) { 1645 text = content.substring(offset_max, len); 1646 node = JX.$N('span', attr_dull, text); 1647 parts.push(node); 1648 } 1649 1650 rows[ii].hoverNode = JX.$N('div', attr_container, parts); 1651 } else { 1652 rows[ii].hoverNode = null; 1653 } 1654 1655 rows[ii].bright = (any_highlight && !has_highlight); 1656 } 1657 1658 return rows; 1659 }, 1660 1661 _deleteInlineByID: function(id) { 1662 var uri = this.getInlineURI(); 1663 var data = { 1664 op: 'refdelete', 1665 id: id 1666 }; 1667 1668 var handler = JX.bind(this, this.redrawPreview); 1669 1670 new JX.Workflow(uri, data) 1671 .setHandler(handler) 1672 .start(); 1673 }, 1674 1675 _getInlineForEvent: function(e) { 1676 var node = e.getNode('differential-changeset'); 1677 if (!node) { 1678 return null; 1679 } 1680 1681 var changeset = this.getChangesetForNode(node); 1682 1683 var inline_row = e.getNode('inline-row'); 1684 return changeset.getInlineForRow(inline_row); 1685 }, 1686 1687 getLineNumberFromHeader: function(node) { 1688 var n = parseInt(node.getAttribute('data-n')); 1689 1690 if (!n) { 1691 return null; 1692 } 1693 1694 // If this is a line number that's part of a row showing more context, 1695 // we don't want to let users leave inlines here. 1696 1697 try { 1698 JX.DOM.findAbove(node, 'tr', 'context-target'); 1699 return null; 1700 } catch (ex) { 1701 // Ignore. 1702 } 1703 1704 return n; 1705 }, 1706 1707 getDisplaySideFromHeader: function(th) { 1708 return (th.parentNode.firstChild != th) ? 'right' : 'left'; 1709 }, 1710 1711 _onrangedown: function(e) { 1712 // NOTE: We're allowing "mousedown" from a touch event through so users 1713 // can leave inlines on a single line. 1714 1715 // See PHI985. We want to exclude both right-mouse and middle-mouse 1716 // clicks from continuing. 1717 if (!e.isLeftButton()) { 1718 return; 1719 } 1720 1721 if (this._rangeActive) { 1722 return; 1723 } 1724 1725 var target = e.getTarget(); 1726 var number = this.getLineNumberFromHeader(target); 1727 if (!number) { 1728 return; 1729 } 1730 1731 e.kill(); 1732 this._rangeActive = true; 1733 1734 this._rangeOrigin = target; 1735 this._rangeTarget = target; 1736 1737 this._setHoverRange(this._rangeOrigin, this._rangeTarget); 1738 }, 1739 1740 _onrangemove: function(e) { 1741 if (e.getIsTouchEvent()) { 1742 return; 1743 } 1744 1745 var is_out = (e.getType() == 'mouseout'); 1746 var target = e.getTarget(); 1747 1748 this._updateRange(target, is_out); 1749 }, 1750 1751 _updateRange: function(target, is_out) { 1752 // Don't update the range if this target doesn't correspond to a line 1753 // number. For instance, this may be a dead line number, like the empty 1754 // line numbers on the left hand side of a newly added file. 1755 var number = this.getLineNumberFromHeader(target); 1756 if (!number) { 1757 return; 1758 } 1759 1760 if (this._rangeActive) { 1761 var origin = this._hoverOrigin; 1762 1763 // Don't update the reticle if we're selecting a line range and the 1764 // "<th />" under the cursor is on the wrong side of the file. You can 1765 // only leave inline comments on the left or right side of a file, not 1766 // across lines on both sides. 1767 var origin_side = this.getDisplaySideFromHeader(origin); 1768 var target_side = this.getDisplaySideFromHeader(target); 1769 if (origin_side != target_side) { 1770 return; 1771 } 1772 1773 // Don't update the reticle if we're selecting a line range and the 1774 // "<th />" under the cursor corresponds to a different file. You can 1775 // only leave inline comments on lines in a single file, not across 1776 // multiple files. 1777 var origin_table = JX.DOM.findAbove(origin, 'table'); 1778 var target_table = JX.DOM.findAbove(target, 'table'); 1779 if (origin_table != target_table) { 1780 return; 1781 } 1782 } 1783 1784 if (is_out) { 1785 if (this._rangeActive) { 1786 // If we're dragging a range, just leave the state as it is. This 1787 // allows you to drag over something invalid while selecting a 1788 // range without the range flickering or getting lost. 1789 } else { 1790 // Otherwise, clear the current range. 1791 this.resetHover(); 1792 } 1793 return; 1794 } 1795 1796 if (this._rangeActive) { 1797 this._rangeTarget = target; 1798 } else { 1799 this._rangeOrigin = target; 1800 this._rangeTarget = target; 1801 } 1802 1803 this._setHoverRange(this._rangeOrigin, this._rangeTarget); 1804 }, 1805 1806 _onrangeup: function(e) { 1807 if (!this._rangeActive) { 1808 return; 1809 } 1810 1811 e.kill(); 1812 1813 var origin = this._rangeOrigin; 1814 var target = this._rangeTarget; 1815 1816 // If the user dragged a range from the bottom to the top, swap the node 1817 // order around. 1818 if (JX.$V(origin).y > JX.$V(target).y) { 1819 var tmp = target; 1820 target = origin; 1821 origin = tmp; 1822 } 1823 1824 var node = JX.DOM.findAbove(origin, null, 'differential-changeset'); 1825 var changeset = this.getChangesetForNode(node); 1826 1827 changeset.newInlineForRange(origin, target); 1828 1829 this._rangeActive = false; 1830 this._rangeOrigin = null; 1831 this._rangeTarget = null; 1832 1833 this.resetHover(); 1834 }, 1835 1836 _redrawBanner: function() { 1837 // If the inline comment menu is open and we've done a redraw, close it. 1838 // In particular, this makes it close when you scroll the document: 1839 // otherwise, it stays open but the banner moves underneath it. 1840 if (this._dropdownMenu) { 1841 this._dropdownMenu.close(); 1842 } 1843 1844 var node = this._getBannerNode(); 1845 var changeset = this._getVisibleChangeset(); 1846 var tree = this._getTreeView(); 1847 var formation = this.getFormationView(); 1848 1849 if (!changeset) { 1850 this._bannerChangeset = null; 1851 JX.DOM.remove(node); 1852 tree.setFocusedPath(null); 1853 1854 if (formation) { 1855 formation.repaint(); 1856 } 1857 1858 return; 1859 } 1860 1861 // Don't do anything if nothing has changed. This seems to avoid some 1862 // flickering issues in Safari, at least. 1863 if (this._bannerChangeset === changeset) { 1864 return; 1865 } 1866 this._bannerChangeset = changeset; 1867 1868 var paths = tree.getPaths(); 1869 for (var ii = 0; ii < paths.length; ii++) { 1870 var path = paths[ii]; 1871 if (path.getChangeset() === changeset) { 1872 tree.setFocusedPath(path); 1873 } 1874 } 1875 1876 var inlines = this._getInlinesByType(); 1877 1878 var unsaved = inlines.unsaved; 1879 var unsubmitted = inlines.unsubmitted; 1880 var undone = inlines.undone; 1881 var done = inlines.done; 1882 var draft_done = inlines.draftDone; 1883 1884 JX.DOM.alterClass( 1885 node, 1886 'diff-banner-has-unsaved', 1887 !!unsaved.length); 1888 1889 JX.DOM.alterClass( 1890 node, 1891 'diff-banner-has-unsubmitted', 1892 !!unsubmitted.length); 1893 1894 JX.DOM.alterClass( 1895 node, 1896 'diff-banner-has-draft-done', 1897 !!draft_done.length); 1898 1899 var pht = this.getTranslations(); 1900 var unsaved_button = this._getUnsavedButton(); 1901 var unsubmitted_button = this._getUnsubmittedButton(); 1902 var done_button = this._getDoneButton(); 1903 var menu_button = this._getMenuButton(); 1904 1905 if (unsaved.length) { 1906 unsaved_button.setText(unsaved.length + ' ' + pht('Unsaved')); 1907 JX.DOM.show(unsaved_button.getNode()); 1908 } else { 1909 JX.DOM.hide(unsaved_button.getNode()); 1910 } 1911 1912 if (unsubmitted.length || draft_done.length) { 1913 var any_draft_count = unsubmitted.length + draft_done.length; 1914 1915 unsubmitted_button.setText(any_draft_count + ' ' + pht('Unsubmitted')); 1916 JX.DOM.show(unsubmitted_button.getNode()); 1917 } else { 1918 JX.DOM.hide(unsubmitted_button.getNode()); 1919 } 1920 1921 if (done.length || undone.length) { 1922 // If you haven't marked any comments as "Done", we just show text 1923 // like "3 Comments". If you've marked at least one done, we show 1924 // "1 / 3 Comments". 1925 1926 var done_text; 1927 if (done.length) { 1928 done_text = [ 1929 done.length, 1930 ' / ', 1931 (done.length + undone.length), 1932 ' ', 1933 pht('Comments') 1934 ]; 1935 } else { 1936 done_text = [ 1937 undone.length, 1938 ' ', 1939 pht('Comments') 1940 ]; 1941 } 1942 1943 done_button.setText(done_text); 1944 1945 JX.DOM.show(done_button.getNode()); 1946 1947 // If any comments are not marked "Done", this cycles through the 1948 // missing comments. Otherwise, it cycles through all the saved 1949 // comments. 1950 if (undone.length) { 1951 this._doneMode = 'undone'; 1952 } else { 1953 this._doneMode = 'done'; 1954 } 1955 1956 } else { 1957 JX.DOM.hide(done_button.getNode()); 1958 } 1959 1960 var path_view = [icon, ' ', changeset.getDisplayPath()]; 1961 1962 var buttons_attrs = { 1963 className: 'diff-banner-buttons' 1964 }; 1965 1966 var buttons_list = [ 1967 unsaved_button.getNode(), 1968 unsubmitted_button.getNode(), 1969 done_button.getNode(), 1970 menu_button.getNode() 1971 ]; 1972 1973 var buttons_view = JX.$N('div', buttons_attrs, buttons_list); 1974 1975 var icon = new JX.PHUIXIconView() 1976 .setIcon(changeset.getIcon()) 1977 .getNode(); 1978 JX.DOM.setContent(node, [buttons_view, path_view]); 1979 1980 document.body.appendChild(node); 1981 1982 if (formation) { 1983 formation.repaint(); 1984 } 1985 }, 1986 1987 _getInlinesByType: function() { 1988 var changesets = this._changesets; 1989 var unsaved = []; 1990 var unsubmitted = []; 1991 var undone = []; 1992 var done = []; 1993 var draft_done = []; 1994 1995 var visible_done = []; 1996 var visible_collapsed = []; 1997 var visible_ghosts = []; 1998 var visible = []; 1999 var hidden = []; 2000 2001 for (var ii = 0; ii < changesets.length; ii++) { 2002 var inlines = changesets[ii].getInlines(); 2003 var inline; 2004 var jj; 2005 for (jj = 0; jj < inlines.length; jj++) { 2006 inline = inlines[jj]; 2007 2008 if (inline.isDeleted()) { 2009 continue; 2010 } 2011 2012 if (inline.isSynthetic()) { 2013 continue; 2014 } 2015 2016 if (inline.isEditing()) { 2017 unsaved.push(inline); 2018 } else if (!inline.getID()) { 2019 // These are new comments which have been cancelled, and do not 2020 // count as anything. 2021 continue; 2022 } else if (inline.isDraft()) { 2023 unsubmitted.push(inline); 2024 } else { 2025 // NOTE: Unlike other states, an inline may be marked with a 2026 // draft checkmark and still be a "done" or "undone" comment. 2027 if (inline.isDraftDone()) { 2028 draft_done.push(inline); 2029 } 2030 2031 if (!inline.isDone()) { 2032 undone.push(inline); 2033 } else { 2034 done.push(inline); 2035 } 2036 } 2037 } 2038 2039 for (jj = 0; jj < inlines.length; jj++) { 2040 inline = inlines[jj]; 2041 if (inline.isDeleted()) { 2042 continue; 2043 } 2044 2045 if (inline.isEditing()) { 2046 continue; 2047 } 2048 2049 if (inline.isHidden()) { 2050 hidden.push(inline); 2051 continue; 2052 } 2053 2054 visible.push(inline); 2055 2056 if (inline.isDone()) { 2057 visible_done.push(inline); 2058 } 2059 2060 if (inline.isCollapsed()) { 2061 visible_collapsed.push(inline); 2062 } 2063 2064 if (inline.isGhost()) { 2065 visible_ghosts.push(inline); 2066 } 2067 } 2068 } 2069 2070 return { 2071 unsaved: unsaved, 2072 unsubmitted: unsubmitted, 2073 undone: undone, 2074 done: done, 2075 draftDone: draft_done, 2076 visibleDone: visible_done, 2077 visibleGhosts: visible_ghosts, 2078 visibleCollapsed: visible_collapsed, 2079 visible: visible, 2080 hidden: hidden 2081 }; 2082 2083 }, 2084 2085 _getUnsavedButton: function() { 2086 if (!this._unsavedButton) { 2087 var button = new JX.PHUIXButtonView() 2088 .setIcon('fa-commenting-o') 2089 .setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE); 2090 2091 var node = button.getNode(); 2092 2093 var onunsaved = JX.bind(this, this._onunsavedclick); 2094 JX.DOM.listen(node, 'click', null, onunsaved); 2095 2096 this._unsavedButton = button; 2097 } 2098 2099 return this._unsavedButton; 2100 }, 2101 2102 _getUnsubmittedButton: function() { 2103 if (!this._unsubmittedButton) { 2104 var button = new JX.PHUIXButtonView() 2105 .setIcon('fa-comment-o') 2106 .setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE); 2107 2108 var node = button.getNode(); 2109 2110 var onunsubmitted = JX.bind(this, this._onunsubmittedclick); 2111 JX.DOM.listen(node, 'click', null, onunsubmitted); 2112 2113 this._unsubmittedButton = button; 2114 } 2115 2116 return this._unsubmittedButton; 2117 }, 2118 2119 _getDoneButton: function() { 2120 if (!this._doneButton) { 2121 var button = new JX.PHUIXButtonView() 2122 .setIcon('fa-comment') 2123 .setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE); 2124 2125 var node = button.getNode(); 2126 2127 var ondone = JX.bind(this, this._ondoneclick); 2128 JX.DOM.listen(node, 'click', null, ondone); 2129 2130 this._doneButton = button; 2131 } 2132 2133 return this._doneButton; 2134 }, 2135 2136 _getMenuButton: function() { 2137 if (!this._menuButton) { 2138 var pht = this.getTranslations(); 2139 2140 var button = new JX.PHUIXButtonView() 2141 .setIcon('fa-bars') 2142 .setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE) 2143 .setAuralLabel(pht('Display Options')); 2144 2145 var dropdown = new JX.PHUIXDropdownMenu(button.getNode()); 2146 this._menuItems = {}; 2147 2148 var list = new JX.PHUIXActionListView(); 2149 dropdown.setContent(list.getNode()); 2150 2151 var map = { 2152 hideDone: { 2153 type: 'done' 2154 }, 2155 hideCollapsed: { 2156 type: 'collapsed' 2157 }, 2158 hideGhosts: { 2159 type: 'ghosts' 2160 }, 2161 hideAll: { 2162 type: 'all' 2163 }, 2164 showAll: { 2165 type: 'show' 2166 } 2167 }; 2168 2169 for (var k in map) { 2170 var spec = map[k]; 2171 2172 var handler = JX.bind(this, this._onhideinlines, spec.type); 2173 var item = new JX.PHUIXActionView() 2174 .setHandler(handler); 2175 2176 list.addItem(item); 2177 this._menuItems[k] = item; 2178 } 2179 2180 dropdown.listen('open', JX.bind(this, this._ondropdown)); 2181 2182 if (this.getInlineListURI()) { 2183 list.addItem( 2184 new JX.PHUIXActionView() 2185 .setDivider(true)); 2186 2187 list.addItem( 2188 new JX.PHUIXActionView() 2189 .setIcon('fa-external-link') 2190 .setName(pht('List Inline Comments')) 2191 .setHref(this.getInlineListURI())); 2192 } 2193 2194 this._menuButton = button; 2195 this._dropdownMenu = dropdown; 2196 } 2197 2198 return this._menuButton; 2199 }, 2200 2201 _ondropdown: function() { 2202 var inlines = this._getInlinesByType(); 2203 var items = this._menuItems; 2204 var pht = this.getTranslations(); 2205 2206 items.hideDone 2207 .setName(pht('Hide "Done" Inlines')) 2208 .setDisabled(!inlines.visibleDone.length); 2209 2210 items.hideCollapsed 2211 .setName(pht('Hide Collapsed Inlines')) 2212 .setDisabled(!inlines.visibleCollapsed.length); 2213 2214 items.hideGhosts 2215 .setName(pht('Hide Older Inlines')) 2216 .setDisabled(!inlines.visibleGhosts.length); 2217 2218 items.hideAll 2219 .setName(pht('Hide All Inlines')) 2220 .setDisabled(!inlines.visible.length); 2221 2222 items.showAll 2223 .setName(pht('Show All Inlines')) 2224 .setDisabled(!inlines.hidden.length); 2225 }, 2226 2227 _onhideinlines: function(type, e) { 2228 this._dropdownMenu.close(); 2229 e.prevent(); 2230 2231 this._toggleInlines(type); 2232 }, 2233 2234 _toggleInlines: function(type) { 2235 var inlines = this._getInlinesByType(); 2236 2237 // Clear the selection state since we end up in a weird place if the 2238 // user hides the selected inline. 2239 this._setSelectionState(null); 2240 2241 var targets; 2242 var mode = true; 2243 switch (type) { 2244 case 'done': 2245 targets = inlines.visibleDone; 2246 break; 2247 case 'collapsed': 2248 targets = inlines.visibleCollapsed; 2249 break; 2250 case 'ghosts': 2251 targets = inlines.visibleGhosts; 2252 break; 2253 case 'all': 2254 targets = inlines.visible; 2255 break; 2256 case 'show': 2257 targets = inlines.hidden; 2258 mode = false; 2259 break; 2260 } 2261 2262 for (var ii = 0; ii < targets.length; ii++) { 2263 targets[ii].setHidden(mode); 2264 } 2265 }, 2266 2267 _onunsavedclick: function(e) { 2268 e.kill(); 2269 2270 var options = { 2271 filter: 'comment', 2272 wrap: true, 2273 show: true, 2274 attribute: 'unsaved' 2275 }; 2276 2277 this._onjumpkey(1, options); 2278 }, 2279 2280 _onunsubmittedclick: function(e) { 2281 e.kill(); 2282 2283 var options = { 2284 filter: 'comment', 2285 wrap: true, 2286 show: true, 2287 attribute: 'anyDraft' 2288 }; 2289 2290 this._onjumpkey(1, options); 2291 }, 2292 2293 _ondoneclick: function(e) { 2294 e.kill(); 2295 2296 var options = { 2297 filter: 'comment', 2298 wrap: true, 2299 show: true, 2300 attribute: this._doneMode 2301 }; 2302 2303 this._onjumpkey(1, options); 2304 }, 2305 2306 _getBannerNode: function() { 2307 if (!this._bannerNode) { 2308 var attributes = { 2309 className: 'diff-banner', 2310 id: 'diff-banner' 2311 }; 2312 2313 this._bannerNode = JX.$N('div', attributes); 2314 } 2315 2316 return this._bannerNode; 2317 }, 2318 2319 _getVisibleChangeset: function() { 2320 if (this.isAsleep()) { 2321 return null; 2322 } 2323 2324 if (JX.Device.getDevice() != 'desktop') { 2325 return null; 2326 } 2327 2328 // Never show the banner if we're very near the top of the page. 2329 var margin = 480; 2330 var s = JX.Vector.getScroll(); 2331 if (s.y < margin) { 2332 return null; 2333 } 2334 2335 // We're going to find the changeset which spans an invisible line a 2336 // little underneath the bottom of the banner. This makes the header 2337 // tick over from "A.txt" to "B.txt" just as "A.txt" scrolls completely 2338 // offscreen. 2339 var detect_height = 64; 2340 2341 for (var ii = 0; ii < this._changesets.length; ii++) { 2342 var changeset = this._changesets[ii]; 2343 var c = changeset.getVectors(); 2344 2345 // If the changeset starts above the line... 2346 if (c.pos.y <= (s.y + detect_height)) { 2347 // ...and ends below the line, this is the current visible changeset. 2348 if ((c.pos.y + c.dim.y) >= (s.y + detect_height)) { 2349 return changeset; 2350 } 2351 } 2352 } 2353 2354 return null; 2355 }, 2356 2357 _getTreeView: function() { 2358 if (!this._treeView) { 2359 var tree = new JX.DiffTreeView(); 2360 2361 for (var ii = 0; ii < this._changesets.length; ii++) { 2362 var changeset = this._changesets[ii]; 2363 tree.addPath(changeset.getPathView()); 2364 } 2365 2366 this._treeView = tree; 2367 } 2368 return this._treeView; 2369 }, 2370 2371 _redrawFiletree : function() { 2372 var formation = this.getFormationView(); 2373 2374 if (!formation) { 2375 return; 2376 } 2377 2378 var filetree = formation.getColumn(0); 2379 var flank = filetree.getFlank(); 2380 2381 var flank_body = flank.getBodyNode(); 2382 2383 var tree = this._getTreeView(); 2384 JX.DOM.setContent(flank_body, tree.getNode()); 2385 }, 2386 2387 _setupInlineCommentListeners: function() { 2388 var onsave = JX.bind(this, this._onInlineEvent, 'save'); 2389 JX.Stratcom.listen( 2390 ['submit', 'didSyntheticSubmit'], 2391 'inline-edit-form', 2392 onsave); 2393 2394 var oncancel = JX.bind(this, this._onInlineEvent, 'cancel'); 2395 JX.Stratcom.listen( 2396 'click', 2397 'inline-edit-cancel', 2398 oncancel); 2399 2400 var onundo = JX.bind(this, this._onInlineEvent, 'undo'); 2401 JX.Stratcom.listen( 2402 'click', 2403 'differential-inline-comment-undo', 2404 onundo); 2405 2406 var ondone = JX.bind(this, this._onInlineEvent, 'done'); 2407 JX.Stratcom.listen( 2408 'click', 2409 ['differential-inline-comment', 'differential-inline-done'], 2410 ondone); 2411 2412 var ondelete = JX.bind(this, this._onInlineEvent, 'delete'); 2413 JX.Stratcom.listen( 2414 'click', 2415 ['differential-inline-comment', 'differential-inline-delete'], 2416 ondelete); 2417 2418 var onmenu = JX.bind(this, this._onInlineEvent, 'menu'); 2419 JX.Stratcom.listen( 2420 'click', 2421 ['differential-inline-comment', 'inline-action-dropdown'], 2422 onmenu); 2423 2424 var ondraft = JX.bind(this, this._onInlineEvent, 'draft'); 2425 JX.Stratcom.listen( 2426 'keydown', 2427 ['differential-inline-comment', 'tag:textarea'], 2428 ondraft); 2429 2430 var on_preview_view = JX.bind(this, this._onPreviewEvent, 'view'); 2431 JX.Stratcom.listen( 2432 'click', 2433 'differential-inline-preview-jump', 2434 on_preview_view); 2435 }, 2436 2437 _onPreviewEvent: function(action, e) { 2438 if (this.isAsleep()) { 2439 return; 2440 } 2441 2442 var data = e.getNodeData('differential-inline-preview-jump'); 2443 var inline = this.getInlineByID(data.inlineCommentID); 2444 if (!inline) { 2445 return; 2446 } 2447 2448 e.kill(); 2449 2450 switch (action) { 2451 case 'view': 2452 this.selectInline(inline, true, true); 2453 break; 2454 } 2455 }, 2456 2457 _onInlineEvent: function(action, e) { 2458 if (this.isAsleep()) { 2459 return; 2460 } 2461 2462 if (action !== 'draft' && action !== 'menu') { 2463 e.kill(); 2464 } 2465 2466 var inline = this._getInlineForEvent(e); 2467 var is_ref = false; 2468 2469 // If we don't have a natural inline object, the user may have clicked 2470 // an action (like "Delete") inside a preview element at the bottom of 2471 // the page. 2472 2473 // If they did, try to find an associated normal inline to act on, and 2474 // pretend they clicked that instead. This makes the overall state of 2475 // the page more consistent. 2476 2477 // However, there may be no normal inline (for example, because it is 2478 // on a version of the diff which is not visible). In this case, we 2479 // act by reference. 2480 2481 if (inline === null) { 2482 var data = e.getNodeData('differential-inline-comment'); 2483 inline = this.getInlineByID(data.id); 2484 if (inline) { 2485 is_ref = true; 2486 } else { 2487 switch (action) { 2488 case 'delete': 2489 this._deleteInlineByID(data.id); 2490 return; 2491 } 2492 } 2493 } 2494 2495 // TODO: For normal operations, highlight the inline range here. 2496 2497 switch (action) { 2498 case 'save': 2499 inline.save(); 2500 break; 2501 case 'cancel': 2502 inline.cancel(); 2503 break; 2504 case 'undo': 2505 inline.undo(); 2506 break; 2507 case 'done': 2508 inline.toggleDone(); 2509 break; 2510 case 'delete': 2511 inline.delete(is_ref); 2512 break; 2513 case 'draft': 2514 inline.triggerDraft(); 2515 break; 2516 case 'menu': 2517 var node = e.getNode('inline-action-dropdown'); 2518 inline.activateMenu(node, e); 2519 break; 2520 } 2521 }, 2522 2523 _onSelectRange: function(e) { 2524 this._updateSourceSelection(); 2525 }, 2526 2527 _updateSourceSelection: function() { 2528 var ranges = this._getSelectedRanges(); 2529 2530 // In Firefox, selecting multiple rows gives us multiple ranges. In 2531 // Safari and Chrome, we get a single range. 2532 if (!ranges.length) { 2533 this._setSourceSelection(null, null); 2534 return; 2535 } 2536 2537 var min = 0; 2538 var max = ranges.length - 1; 2539 2540 var head = ranges[min].startContainer; 2541 var last = ranges[max].endContainer; 2542 2543 var head_loc = this._getFragmentLocation(head); 2544 var last_loc = this._getFragmentLocation(last); 2545 2546 if (head_loc === null || last_loc === null) { 2547 this._setSourceSelection(null, null); 2548 return; 2549 } 2550 2551 if (head_loc.changesetID !== last_loc.changesetID) { 2552 this._setSourceSelection(null, null); 2553 return; 2554 } 2555 2556 head_loc.offset += ranges[min].startOffset; 2557 last_loc.offset += ranges[max].endOffset; 2558 2559 this._setSourceSelection(head_loc, last_loc); 2560 }, 2561 2562 _setSourceSelection: function(start, end) { 2563 var start_updated = 2564 !this._isSameSourceSelection(this._sourceSelectionStart, start); 2565 2566 var end_updated = 2567 !this._isSameSourceSelection(this._sourceSelectionEnd, end); 2568 2569 if (!start_updated && !end_updated) { 2570 return; 2571 } 2572 2573 this._sourceSelectionStart = start; 2574 this._sourceSelectionEnd = end; 2575 2576 if (!start) { 2577 this._closeSourceSelectionMenu(); 2578 return; 2579 } 2580 2581 var menu; 2582 if (this._sourceSelectionMenu) { 2583 menu = this._sourceSelectionMenu; 2584 } else { 2585 menu = this._newSourceSelectionMenu(); 2586 this._sourceSelectionMenu = menu; 2587 } 2588 2589 var pos = JX.$V(start.node) 2590 .add(0, -menu.getMenuNodeDimensions().y) 2591 .add(0, -24); 2592 2593 menu.setPosition(pos); 2594 menu.open(); 2595 }, 2596 2597 _newSourceSelectionMenu: function() { 2598 var pht = this.getTranslations(); 2599 2600 var menu = new JX.PHUIXDropdownMenu(null) 2601 .setWidth(240); 2602 2603 // We need to disable autofocus for this menu, since it operates on the 2604 // text selection in the document. If we leave this enabled, opening the 2605 // menu immediately discards the selection. 2606 menu.setDisableAutofocus(true); 2607 2608 var list = new JX.PHUIXActionListView(); 2609 menu.setContent(list.getNode()); 2610 2611 var oncreate = JX.bind(this, this._onSourceSelectionMenuAction, 'create'); 2612 2613 var comment_item = new JX.PHUIXActionView() 2614 .setIcon('fa-comment-o') 2615 .setName(pht('New Inline Comment')) 2616 .setKeyCommand('c') 2617 .setHandler(oncreate); 2618 2619 list.addItem(comment_item); 2620 2621 return menu; 2622 }, 2623 2624 _onSourceSelectionMenuAction: function(action, e) { 2625 e.kill(); 2626 this._closeSourceSelectionMenu(); 2627 2628 switch (action) { 2629 case 'create': 2630 this._onKeyCreate(); 2631 break; 2632 } 2633 }, 2634 2635 _closeSourceSelectionMenu: function() { 2636 if (this._sourceSelectionMenu) { 2637 this._sourceSelectionMenu.close(); 2638 } 2639 }, 2640 2641 _isSameSourceSelection: function(u, v) { 2642 if (u === null && v === null) { 2643 return true; 2644 } 2645 2646 if (u === null && v !== null) { 2647 return false; 2648 } 2649 2650 if (u !== null && v === null) { 2651 return false; 2652 } 2653 2654 return ( 2655 (u.changesetID === v.changesetID) && 2656 (u.line === v.line) && 2657 (u.displayColumn === v.displayColumn) && 2658 (u.offset === v.offset) 2659 ); 2660 }, 2661 2662 _getFragmentLocation: function(fragment) { 2663 // Find the changeset containing the fragment. 2664 var changeset = null; 2665 try { 2666 var node = JX.DOM.findAbove( 2667 fragment, 2668 'div', 2669 'differential-changeset'); 2670 2671 changeset = this.getChangesetForNode(node); 2672 if (!changeset) { 2673 return null; 2674 } 2675 } catch (ex) { 2676 return null; 2677 } 2678 2679 // Find the line number and display column for the fragment. 2680 var line = null; 2681 var column_count = -1; 2682 var has_new = false; 2683 var has_old = false; 2684 var offset = null; 2685 var target_node = null; 2686 var td; 2687 try { 2688 2689 // NOTE: In Safari, you can carefully select an entire line and then 2690 // move your mouse down slightly, causing selection of an empty 2691 // document fragment which is an immediate child of the next "<tr />". 2692 2693 // If the fragment is a direct child of a "<tr />" parent, assume the 2694 // user has done this and select the last child of the previous row 2695 // instead. It's possible there are other ways to do this, so this may 2696 // not always be the right rule. 2697 2698 // Otherwise, select the containing "<td />". 2699 2700 var is_end; 2701 if (JX.DOM.isType(fragment.parentNode, 'tr')) { 2702 // Assume this is Safari, and that the user has carefully selected a 2703 // row and then moved their mouse down a few pixels to select the 2704 // invisible fragment at the beginning of the next row. 2705 var cells = fragment.parentNode.previousSibling.childNodes; 2706 td = cells[cells.length - 1]; 2707 is_end = true; 2708 } else { 2709 td = this._findContentCell(fragment); 2710 is_end = false; 2711 } 2712 2713 var cursor = td; 2714 while (cursor) { 2715 if (cursor.getAttribute('data-copy-mode')) { 2716 column_count++; 2717 } else { 2718 // In unified mode, the content column isn't currently marked 2719 // with an attribute, and we can't count content columns anyway. 2720 // Keep track of whether or not we see a "NL" (New Line) column 2721 // and/or an "OL" (Old Line) column to try to puzzle out which 2722 // side of the display change we're on. 2723 2724 if (cursor.id.match(/NL/)) { 2725 has_new = true; 2726 } else if (cursor.id.match(/OL/)) { 2727 has_old = true; 2728 } 2729 } 2730 2731 var n = parseInt(cursor.getAttribute('data-n')); 2732 2733 if (n) { 2734 if (line === null) { 2735 target_node = cursor; 2736 line = n; 2737 } 2738 } 2739 2740 cursor = cursor.previousSibling; 2741 } 2742 2743 if (!line) { 2744 return null; 2745 } 2746 2747 if (column_count < 0) { 2748 if (has_new || has_old) { 2749 if (has_new) { 2750 column_count = 1; 2751 } else { 2752 column_count = 0; 2753 } 2754 } else { 2755 return null; 2756 } 2757 } 2758 2759 var info = this._getSelectionOffset(td, fragment); 2760 2761 if (info.found) { 2762 offset = info.offset; 2763 } else { 2764 if (is_end) { 2765 offset = info.offset; 2766 } else { 2767 offset = 0; 2768 } 2769 } 2770 } catch (ex) { 2771 return null; 2772 } 2773 2774 var changeset_id; 2775 if (column_count > 0) { 2776 changeset_id = changeset.getRightChangesetID(); 2777 } else { 2778 changeset_id = changeset.getLeftChangesetID(); 2779 } 2780 2781 return { 2782 node: td, 2783 changeset: changeset, 2784 changesetID: changeset_id, 2785 line: line, 2786 displayColumn: column_count, 2787 offset: offset, 2788 targetNode: target_node 2789 }; 2790 }, 2791 2792 _getSelectionOffset: function(node, target) { 2793 // If this is an aural hint node in a unified diff, ignore it when 2794 // calculating the selection offset. 2795 if (node.getAttribute && node.getAttribute('data-aural')) { 2796 return { 2797 offset: 0, 2798 content: '', 2799 found: false 2800 }; 2801 } 2802 2803 if (!node.childNodes || !node.childNodes.length) { 2804 return { 2805 offset: node.textContent.length, 2806 content: node.textContent, 2807 found: false 2808 }; 2809 } 2810 2811 var found = false; 2812 var offset = 0; 2813 var content = ''; 2814 for (var ii = 0; ii < node.childNodes.length; ii++) { 2815 var child = node.childNodes[ii]; 2816 2817 if (child === target) { 2818 found = true; 2819 } 2820 2821 var spec = this._getSelectionOffset(child, target); 2822 2823 content += spec.content; 2824 if (!found) { 2825 offset += spec.offset; 2826 } 2827 2828 found = found || spec.found; 2829 } 2830 2831 return { 2832 offset: offset, 2833 content: content, 2834 found: found 2835 }; 2836 }, 2837 2838 _getSelectedRanges: function() { 2839 var ranges = []; 2840 2841 if (!window.getSelection) { 2842 return ranges; 2843 } 2844 2845 var selection = window.getSelection(); 2846 for (var ii = 0; ii < selection.rangeCount; ii++) { 2847 var range = selection.getRangeAt(ii); 2848 if (range.collapsed) { 2849 continue; 2850 } 2851 2852 ranges.push(range); 2853 } 2854 2855 return ranges; 2856 }, 2857 2858 _isContentCell: function(node) { 2859 return !!node.getAttribute('data-copy-mode'); 2860 }, 2861 2862 _findContentCell: function(node) { 2863 var cursor = node; 2864 while (true) { 2865 cursor = JX.DOM.findAbove(cursor, 'td'); 2866 if (this._isContentCell(cursor)) { 2867 return cursor; 2868 } 2869 } 2870 } 2871 2872 } 2873 2874});