clone for mozreview modification (bug 1115707); r?smacleod draft
authorbyron jones <glob@mozilla.com>
Mon, 19 Sep 2016 14:20:46 +0800
changeset 149 d328b16756667e7781e4d6b9fd6abc34e26f672e
parent 148 879e331e126d6023e04be41cb7105a6001bb02c5
child 150 f7b22922b3593fdc037931bc9bd07a4026e0389e
push idunknown
push userunknown
push dateunknown
reviewerssmacleod
bugs1115707
clone for mozreview modification (bug 1115707); r?smacleod MozReview-Commit-ID: 4vvtLKzLiVL
reviewboard/reviewboard/static/rb/js/views/textCommentRowSelector_mozreview.js
reviewboard/reviewboard/staticbundles.py
new file mode 100644
--- /dev/null
+++ b/reviewboard/reviewboard/static/rb/js/views/textCommentRowSelector_mozreview.js
@@ -0,0 +1,736 @@
+(function() {
+
+
+/*
+ * Provides multi-line commenting capabilities for a diff.
+ *
+ * This tacks on commenting capabilities onto a DiffReviewableView's
+ * element. It listens for mouse events that begin/end the creation of
+ * a new comment.
+ */
+RB.TextCommentRowSelector = Backbone.View.extend({
+    ghostCommentFlagTemplate: _.template([
+        '<span class="commentflag ghost-commentflag">',
+        ' <span class="commentflag-shadow"></span>',
+        ' <span class="commentflag-inner"></span>',
+        '</span>'
+    ].join('')),
+
+    events: {
+        'copy': '_onCopy',
+        'mousedown': '_onMouseDown',
+        'mouseup': '_onMouseUp',
+        'mouseover': '_onMouseOver',
+        'mouseout': '_onMouseOut',
+        'touchmove': '_onTouchMove',
+        'touchcancel': '_onTouchCancel'
+    },
+
+    /*
+     * Initializes the commenting selector.
+     */
+    initialize: function() {
+        this._$begin = null;
+        this._$end = null;
+        this._beginLineNum = 0;
+        this._endLineNum = 0;
+        this._lastSeenIndex = 0;
+        this._selectionClass = null;
+
+        /*
+         * Support setting the clipboard only if we have the necessary
+         * functions. This may still be turned off later if we can't
+         * actually set the data.
+         */
+        this._supportsSetClipboard = (
+            window.getSelection !== undefined &&
+            window.Range !== undefined &&
+            window.Range.prototype.cloneContents !== undefined);
+
+        this._newlineChar = null;
+
+        this._$ghostCommentFlag = null;
+        this._$ghostCommentFlagCell = null;
+    },
+
+    /*
+     * Removes the selector.
+     */
+    remove: function() {
+        Backbone.View.prototype.remove.call(this);
+
+        this._$ghostCommentFlag.remove();
+    },
+
+    /*
+     * Renders the selector.
+     */
+    render: function() {
+        this._$ghostCommentFlag = $(this.ghostCommentFlagTemplate())
+            .on({
+                mousedown: _.bind(this._onMouseDown, this),
+                mouseup: _.bind(this._onMouseUp, this),
+                mouseover: _.bind(this._onMouseOver, this),
+                mouseout: _.bind(this._onMouseOut, this)
+            })
+            .hide()
+            .appendTo('body');
+
+        return this;
+    },
+
+   /*
+    * Creates a comment for a chunk of a diff.
+    */
+    createComment: function(beginLineNum, endLineNum, beginNode, endNode) {
+        this._beginLineNum = beginLineNum;
+        this._endLineNum = endLineNum;
+        this._$begin = this._getActualLineNumCell($(beginNode)).parent();
+        this._$end = this._getActualLineNumCell($(endNode)).parent();
+
+        if (this._isLineNumCell(endNode)) {
+            this._end(this._getActualLineNumCell($(endNode)).parent());
+        }
+
+        this._reset();
+    },
+
+    /*
+     * Return the beginning and end rows for a given line number range.
+     *
+     * If the first line number corresponds to a valid row within the table,
+     * an array will be returned containing the DOM elements for the
+     * two rows matching the two line numbers. If the second line number
+     * could not be found, its entry in the array will be null.
+     *
+     * If the first line number could not be found, null is returned instead
+     * of an array.
+     *
+     * A minimum rowIndex can be provided in order to constrain the search.
+     * No rows prior to that minimum rowIndex will be searched.
+     */
+    getRowsForRange: function(beginLineNum, endLineNum, minRowIndex) {
+        var beginRowEl = this.findLineNumRow(beginLineNum, minRowIndex),
+            endRowEl,
+            rowIndex;
+
+        if (beginRowEl) {
+            rowIndex = beginRowEl.rowIndex;
+
+            endRowEl = (endLineNum === beginLineNum
+                        ? beginRowEl
+                        : this.findLineNumRow(
+                            endLineNum,
+                            rowIndex,
+                            rowIndex + endLineNum - beginLineNum));
+
+            return [beginRowEl, endRowEl];
+        } else {
+            return null;
+        }
+    },
+
+    /*
+     * Finds the row in a table matching the specified line number.
+     *
+     * This will perform a binary search of the lines trying to find
+     * the matching line number. It will then return the row element,
+     * if found.
+     */
+    findLineNumRow: function(lineNum, startRow, endRow) {
+        var row = null,
+            table = this.el,
+            rowOffset = 1, // Get past the headers.
+            guessRowNum,
+            guessRow,
+            oldHigh,
+            oldLow,
+            high,
+            low,
+            value,
+            found,
+            i,
+            j;
+
+        if (table.rows.length - rowOffset > lineNum) {
+            row = table.rows[rowOffset + lineNum];
+
+            // Account for the "x lines hidden" row.
+            if (row && this.getLineNum(row) === lineNum) {
+                return row;
+            }
+        }
+
+        if (startRow) {
+            // startRow already includes the offset, so we need to remove it.
+            startRow -= rowOffset;
+        }
+
+        low = startRow || 0;
+        high = Math.min(endRow || table.rows.length, table.rows.length);
+
+        if (endRow !== undefined && endRow < table.rows.length) {
+            // See if we got lucky and found it in the last row.
+            if (this.getLineNum(table.rows[endRow]) === lineNum) {
+                return table.rows[endRow];
+            }
+        } else if (row) {
+            /*
+             * We collapsed the rows (unless someone mucked with the DB),
+             * so the desired row is less than the row number retrieved.
+             */
+            high = Math.min(high, rowOffset + lineNum);
+        }
+
+        // Binary search for this cell.
+        for (i = Math.round((low + high) / 2); low < high - 1;) {
+            row = table.rows[rowOffset + i];
+
+            if (!row) {
+                // This should not happen, unless we miscomputed high.
+                high--;
+
+                /*
+                 * This won't do much if low + high is odd, but we'll catch
+                 * up on the next iteration.
+                 */
+                i = Math.round((low + high) / 2);
+                continue;
+            }
+
+            value = this.getLineNum(row);
+
+            if (!value) {
+                /*
+                 * Bad luck, let's look around.
+                 *
+                 * We'd expect to find a value on the first try, but the
+                 * following makes sure we explore all rows.
+                 */
+                found = false;
+
+                for (j = 1; j <= (high - low) / 2; j++) {
+                    row = table.rows[rowOffset + i + j];
+
+                    if (row && this.getLineNum(row)) {
+                        i = i + j;
+                        found = true;
+                        break;
+                    } else {
+                        row = table.rows[rowOffset + i - j];
+
+                        if (row && this.getLineNum(row)) {
+                            i = i - j;
+                            found = true;
+                            break;
+                        }
+                    }
+                }
+
+                if (found) {
+                    value = this.getLineNum(row);
+                } else {
+                    return null;
+                }
+            }
+
+            // See if we can use simple math to find the row quickly.
+            guessRowNum = lineNum - value + rowOffset + i;
+
+            if (guessRowNum >= 0 && guessRowNum < table.rows.length) {
+                guessRow = table.rows[guessRowNum];
+
+                if (guessRow && this.getLineNum(guessRow) === lineNum) {
+                    // We found it using maths!
+                    return guessRow;
+                }
+            }
+
+            oldHigh = high;
+            oldLow = low;
+
+            if (value > lineNum) {
+                high = i;
+            } else if (value < lineNum) {
+                low = i;
+            } else {
+                return row;
+            }
+
+            /*
+             * Make sure we don't get stuck in an infinite loop. This can happen
+             * when a comment is placed in a line that isn't being shown.
+             */
+            if (oldHigh === high && oldLow === low) {
+                break;
+            }
+
+            i = Math.round((low + high) / 2);
+        }
+
+        // Well.. damn. Ignore this then.
+        return null;
+    },
+
+    /*
+     * Begins the selection of line numbers.
+     */
+    _begin: function($row) {
+        var lineNum = this.getLineNum($row[0]);
+
+        this._$begin = $row;
+        this._$end = $row;
+        this._beginLineNum = lineNum;
+        this._endLineNum = lineNum;
+        this._lastSeenIndex = $row[0].rowIndex;
+
+        $row.addClass('selected');
+        this.$el.disableSelection();
+    },
+
+    /*
+     * Finalizes the selection and pops up a comment dialog.
+     */
+    _end: function($row) {
+        var $commentFlag;
+
+        if (this._beginLineNum === this._endLineNum) {
+            /* See if we have a comment flag on the selected row. */
+            $commentFlag = $row.find('.commentflag');
+
+            if ($commentFlag.length === 1) {
+                $commentFlag.click();
+                return;
+            }
+        }
+
+        /*
+         * Selection was finalized. Create the comment block
+         * and show the comment dialog.
+         */
+        this.options.reviewableView.createAndEditCommentBlock({
+            beginLineNum: this._beginLineNum,
+            endLineNum: this._endLineNum,
+            $beginRow: this._$begin,
+            $endRow: this._$end
+        });
+    },
+
+    /*
+     * Adds a row to the selection. This will update the selection range
+     * and mark the rows as selected.
+     *
+     * This row is assumed to be the most recently selected row, and
+     * will mark the new beginning or end of the selection.
+     */
+    _addRow: function($row) {
+        var lineNum,
+            min,
+            max,
+            i;
+
+        /* We have an active selection. */
+        lineNum = this.getLineNum($row[0]);
+
+        if (lineNum < this._beginLineNum) {
+            this._$begin = $row;
+            this._beginLineNum = lineNum;
+        } else if (lineNum > this._beginLineNum) {
+            this._$end = $row;
+            this._endLineNum = lineNum;
+        }
+
+        min = Math.min(this._lastSeenIndex, $row[0].rowIndex);
+        max = Math.max(this._lastSeenIndex, $row[0].rowIndex);
+
+        for (i = min; i <= max; i++) {
+            $(this.el.rows[i]).addClass('selected');
+        }
+
+        this._lastSeenIndex = $row[0].rowIndex;
+    },
+
+    /*
+     * Highlights a row.
+     *
+     * This will highlight a row and show a ghost comment flag. This is done
+     * when the mouse hovers over the row.
+     */
+    _highlightRow: function($row) {
+        var $lineNumCell = $($row[0].cells[0]);
+
+        /* See if we have a comment flag in here. */
+        if ($lineNumCell.find('.commentflag').length === 0) {
+            this._$ghostCommentFlag
+                .css('top', $row.offset().top - 1)
+                .show()
+                .parent()
+                    .removeClass('selected');
+            this._$ghostCommentFlagCell = $lineNumCell;
+        }
+
+        $row.addClass('selected');
+    },
+
+    /*
+     * Removes any old rows from the selection, based on the most recent
+     * row selected.
+     */
+    _removeOldRows: function($row) {
+        var destRowIndex = $row[0].rowIndex;
+
+        if (destRowIndex >= this._$begin[0].rowIndex) {
+            if (   this._lastSeenIndex !== this._$end[0].rowIndex
+                && this._lastSeenIndex < destRowIndex) {
+                /*
+                 * We're removing from the top of the range. The beginning
+                 * location will need to be moved.
+                 */
+                this._removeSelectionClasses(this._lastSeenIndex, destRowIndex);
+                this._$begin = $row;
+                this._beginLineNum = this.getLineNum($row[0]);
+            } else {
+                /*
+                 * We're removing from the bottom of the selection. The end
+                 * location will need to be moved.
+                 */
+                this._removeSelectionClasses(destRowIndex,
+                                             this._lastSeenIndex);
+
+                this._$end = $row;
+                this._endLineNum = this.getLineNum($row[0]);
+            }
+
+            this._lastSeenIndex = destRowIndex;
+        }
+    },
+
+    /*
+     * Resets the selection information.
+     */
+    _reset: function() {
+        if (this._$begin) {
+            /* Reset the selection. */
+            this._removeSelectionClasses(this._$begin[0].rowIndex,
+                                         this._$end[0].rowIndex);
+
+            this._$begin = null;
+            this._$end = null;
+            this._beginLineNum = 0;
+            this._endLineNum = 0;
+            this._lastSeenIndex = 0;
+        }
+
+        this._$ghostCommentFlagCell = null;
+
+        /* Re-enable text selection on IE */
+        this.$el.enableSelection();
+    },
+
+    /*
+     * Removes selection classes on a range of rows.
+     */
+    _removeSelectionClasses: function(startRowIndex, endRowIndex) {
+        var i;
+
+        for (i = startRowIndex; i <= endRowIndex; i++) {
+            $(this.el.rows[i]).removeClass('selected');
+        }
+    },
+
+    /*
+     * Returns whether a particular cell is a line number cell.
+     */
+    _isLineNumCell: function(cell) {
+        return cell.tagName === 'TH' &&
+               cell.parentNode.getAttribute('line');
+    },
+
+    /*
+     * Returns the actual cell node in the table.
+     *
+     * If the node specified is the ghost flag, this will return the
+     * cell the ghost flag represents.
+     *
+     * If this is a comment flag inside a cell, this will return the
+     * comment flag's parent cell
+     *
+     * @return {jQuery} The row.
+     */
+    _getActualLineNumCell: function($node) {
+        if ($node.hasClass('commentflag')) {
+            if ($node[0] === this._$ghostCommentFlag[0]) {
+                $node = this._$ghostCommentFlagCell;
+            } else {
+                $node = $node.parent();
+            }
+        }
+
+        return $node;
+    },
+
+    /*
+     * Handler for when the user copies text in a column.
+     *
+     * This will begin the process of capturing any selected text in
+     * a column to the clipboard in a cross-browser way.
+     */
+    _onCopy: function(e) {
+        var clipboardData = e.originalEvent.clipboardData ||
+                            window.clipboardData;
+
+        if (clipboardData && this._supportsSetClipboard &&
+            this._copySelectionToClipboard(clipboardData)) {
+            /*
+             * Prevent the default copy action from occurring.
+             */
+            return false;
+        }
+    },
+
+    /*
+     * Copies the current selection to the clipboard.
+     *
+     * This will locate the desired text to copy, based on the selection
+     * range within the column where selection started. It will then
+     * extract the code from the <pre> tags and build a string to set in
+     * the clipboard.
+     *
+     * This requires support in the browser for setting clipboard contents
+     * on copy. If the browser does not support this, the default behavior
+     * will be used.
+     */
+    _copySelectionToClipboard: function(clipboardData) {
+        var sel = window.getSelection(),
+            s = '',
+            tdClass,
+            range,
+            doc,
+            nodes,
+            i,
+            j;
+
+        if (this._newlineChar === null) {
+            /*
+             * Figure out what newline character should be used on this
+             * platform. Ideally, we'd determine this from some browser
+             * behavior, but it doesn't seem that can be consistently
+             * determined.
+             */
+            if (navigator.appVersion.indexOf('Win') !== -1) {
+                this._newlineChar = '\r\n';
+            } else {
+                this._newlineChar = '\n';
+            }
+        }
+
+        if (this._selectedCellIndex === 3 || this.$el.hasClass('newfile')) {
+            tdClass = 'r';
+        } else {
+            tdClass = 'l';
+        }
+
+        for (i = 0; i < sel.rangeCount; i++) {
+            range = sel.getRangeAt(i);
+
+            if (range.collapsed) {
+                continue;
+            }
+
+            doc = range.cloneContents();
+            nodes = doc.querySelectorAll('td.' + tdClass + ' pre');
+
+            /*
+             * The selection spans multiple rows. Find the blocks of text
+             * in the column we want, and copy those to the clipboard.
+             */
+            if (nodes.length > 0) {
+                for (j = 0; j < nodes.length; j++) {
+                    s += nodes[j].textContent;
+
+                    /*
+                     * We only want to include a newline if this isn't the
+                     * last node, or the boundary ends within an element
+                     * (likely <pre>, but possibly another) and isn't ending
+                     * at the beginning of that element.
+                     *
+                     * This prevents a newline from appearing at the end of
+                     * a selection if the selection ends in the middle of a
+                     * line of code.
+                     */
+                    if (j < nodes.length - 1 ||
+                        (range.endContainer.nodeType === Node.ELEMENT_NODE &&
+                         range.endOffset > 0)) {
+                        s += this._newlineChar;
+                    }
+                }
+            } else if (sel.rangeCount === 1) {
+                /*
+                 * If we're here, then we selected a subset of a single
+                 * cell. There was only one Range, and no <pre> tags as
+                 * part of it. We can just grab the text of the document.
+                 *
+                 * (We don't really need to break here, but we're going to
+                 * in order to be clear that we're completely done.)
+                 */
+                s = $(doc).text();
+                break;
+            }
+        }
+
+        try {
+            clipboardData.setData('text', s);
+        } catch (e) {
+            /* Let the native behavior take over. */
+            this._supportsSetClipboard = false;
+            return false;
+        }
+
+        return true;
+    },
+
+    /*
+     * Handles the mouse down event, which begins selection for comments.
+     */
+    _onMouseDown: function(e) {
+        var node = e.target,
+            $node;
+
+        if (this._selectionClass) {
+            this.$el.removeClass(this._selectionClass);
+        }
+
+        if (this._$ghostCommentFlagCell) {
+            node = this._$ghostCommentFlagCell[0];
+        }
+
+        if (this._isLineNumCell(node)) {
+            this._begin($(node.parentNode));
+            return false;
+        } else {
+            if (node.tagName === 'TD') {
+                $node = $(node);
+            } else {
+                $node = $(node).parentsUntil('tr', 'td');
+            }
+
+            if ($node.length > 0) {
+                this._selectionClass = 'selecting-col-' + $node[0].cellIndex;
+                this._selectedCellIndex = $node[0].cellIndex;
+                this.$el.addClass(this._selectionClass);
+            }
+        }
+
+        return true;
+    },
+
+    /*
+     * Handles the mouse up event, which finalizes selection of a range of
+     * lines.
+     *
+     * This will create a new comment block and display the comment dialog.
+     */
+    _onMouseUp: function(e) {
+        var node = e.target;
+
+        e.preventDefault();
+
+        if (this._$ghostCommentFlagCell) {
+            node = this._$ghostCommentFlagCell[0];
+        }
+
+        if (this._isLineNumCell(node)) {
+            this._end(this._getActualLineNumCell($(node)).parent());
+            e.stopImmediatePropagation();
+        }
+
+        this._reset();
+    },
+
+    /*
+     * Handles the mouse over event.
+     *
+     * This will update the selection, if there is one, to include this row
+     * in the range, and set the "selected" class on the new row.
+     */
+    _onMouseOver: function(e) {
+        var $node = this._getActualLineNumCell($(e.target)),
+            $row = $node.parent();
+
+        if (this._isLineNumCell($node[0])) {
+            if (this._$begin) {
+                this._addRow($row);
+            } else {
+                this._highlightRow($row);
+            }
+        } else if (this._$ghostCommentFlagCell &&
+                   $node[0] !== this._$ghostCommentFlagCell[0]) {
+            $row.removeClass('selected');
+        }
+    },
+
+    /*
+     * Handles the mouse out event, removing any lines outside the new range
+     * from the selection.
+     */
+    _onMouseOut: function(e) {
+        var relTarget = e.relatedTarget,
+            $node = this._getActualLineNumCell($(e.target));
+
+        if (relTarget !== this._$ghostCommentFlag[0]) {
+            this._$ghostCommentFlag.hide();
+            this._$ghostCommentFlagCell = null;
+        }
+
+        if (this._$begin) {
+            if (relTarget && this._isLineNumCell(relTarget)) {
+                this._removeOldRows($(relTarget.parentNode));
+            }
+        } else if ($node && this._isLineNumCell($node[0])) {
+            /*
+             * Opera seems to generate lots of spurious mouse-out
+             * events, which would cause us to get all sorts of
+             * errors in here unless we check the target above.
+             */
+            $node.parent().removeClass('selected');
+        }
+    },
+
+    /*
+     * Handles touch move events.
+     *
+     * Simulates mouse clicks/drags for line number selection.
+     */
+    _onTouchMove: function(e) {
+        var firstTouch = e.originalEvent.targetTouches[0],
+            target = document.elementFromPoint(firstTouch.pageX,
+                                               firstTouch.pageY),
+            $node = this._getActualLineNumCell($(target)),
+            $row = node.parent();
+
+        if (   this._lastSeenIndex !== $row[0].rowIndex
+            && this._isLineNumCell($node[0])) {
+            this._removeOldRows($row);
+            this._addRow($row);
+        }
+    },
+
+    /*
+     * Handles touch cancel events.
+     *
+     * Resets the line number selection.
+     */
+    _onTouchCancel: function() {
+        this._reset();
+    },
+
+    /*
+     * Returns the line number for a row.
+     */
+    getLineNum: function(row) {
+        return parseInt(row.getAttribute('line'), 10);
+    }
+});
+
+
+})();
--- a/reviewboard/reviewboard/staticbundles.py
+++ b/reviewboard/reviewboard/staticbundles.py
@@ -230,17 +230,17 @@ PIPELINE_JS = dict({
             'rb/js/views/reviewReplyDraftBannerView.js',
             'rb/js/views/reviewReplyEditorView.js',
             'rb/js/views/reviewRequestEditorView.js',
             'rb/js/views/screenshotThumbnailView.js',
             'rb/js/views/imageReviewableView.js',
             'rb/js/views/dummyReviewableView.js',
             'rb/js/views/textBasedCommentBlockView.js',
             'rb/js/views/textBasedReviewableView.js',
-            'rb/js/views/textCommentRowSelector.js',
+            'rb/js/views/textCommentRowSelector_mozreview.js',
             'rb/js/views/markdownReviewableView.js',
             'rb/js/views/uploadDiffView.js',
             'rb/js/views/updateDiffView.js',
             'rb/js/diffviewer/models/diffCommentBlockModel.js',
             'rb/js/diffviewer/models/diffCommentsHintModel.js',
             'rb/js/diffviewer/models/diffFileModel.js',
             'rb/js/diffviewer/models/diffReviewableModel.js',
             'rb/js/diffviewer/models/diffRevisionModel.js',