diff --git a/reviewboard/static/rb/css/pages/diffviewer.less b/reviewboard/static/rb/css/pages/diffviewer.less
index c23c71699d2b3524df03e82a4e24517dcea70608..91cb06f96897b5f8eb2cfe9f69fc8c0d208c67ce 100644
--- a/reviewboard/static/rb/css/pages/diffviewer.less
+++ b/reviewboard/static/rb/css/pages/diffviewer.less
@@ -188,7 +188,7 @@
   tbody {
     tr {
       &.selected {
-        * {
+        > * {
           background: @diff-selected-color;
         }
 
@@ -358,7 +358,7 @@
 
     &.delete {
       tr {
-        &.selected * { background: @diff-delete-selected-color; }
+        &.selected > * { background: @diff-delete-selected-color; }
         &.highlight-anchor * { background: @diff-highlight-color; }
 
         td {
@@ -375,7 +375,7 @@
 
     &.insert {
       tr {
-        &.selected * { background: @diff-insert-selected-color; }
+        &.selected > * { background: @diff-insert-selected-color; }
         &.highlight-anchor * { background: @diff-highlight-color; }
 
         td {
@@ -392,7 +392,7 @@
 
     &.replace {
       tr {
-        &.selected * { background: @diff-replace-selected-color; }
+        &.selected > * { background: @diff-replace-selected-color; }
         &.highlight-anchor * { background: @diff-highlight-color; }
 
         td {
@@ -539,6 +539,36 @@
       .unselectable();
     }
   }
+
+  .move-icon {
+    background-color: transparent;
+    top: 0.5em;
+    position: absolute;
+    height: 1.6em;
+    width: 100%;
+    z-index: 20;
+    cursor: move;
+  }
+
+  .upper-resize-icon {
+    background-color: transparent;
+    top: 0;
+    position: absolute;
+    height: 0.5em;
+    width: 100%;
+    z-index: 20;
+    cursor: ns-resize;
+  }
+
+  .lower-resize-icon {
+    background-color: transparent;
+    bottom: 0;
+    position: absolute;
+    height: 0.5em;
+    width: 100%;
+    z-index: 20;
+    cursor: ns-resize;
+  }
 }
 
 .selected .commentflag .commentflag-inner {
diff --git a/reviewboard/static/rb/js/diffviewer/models/diffCommentBlockModel.js b/reviewboard/static/rb/js/diffviewer/models/diffCommentBlockModel.js
index d2977ddb6fd701e2dc68bfee53f1765fb1e67efd..e0e204c42406747ae8e3ee868442aa8d69e192e7 100644
--- a/reviewboard/static/rb/js/diffviewer/models/diffCommentBlockModel.js
+++ b/reviewboard/static/rb/js/diffviewer/models/diffCommentBlockModel.js
@@ -15,6 +15,34 @@ RB.DiffCommentBlock = RB.AbstractCommentBlock.extend({
     }, RB.AbstractCommentBlock.prototype.defaults),
 
     /*
+     * Return whether the begin and end line numbers can be changed for the
+     * draft comment in this comment block.
+     */
+    canUpdateBounds: function() {
+        return _.isEmpty(this.get('serializedComments'));
+    },
+
+    /*
+     * Save the begin and end line numbers of the draft comment.
+     */
+    saveDraftCommentBounds: function() {
+        var draftComment = this.get('draftComment');
+
+        draftComment.ready({
+            ready: function() {
+                draftComment.set({
+                    beginLineNum: this.get('beginLineNum'),
+                    endLineNum: this.get('endLineNum')
+                });
+                draftComment.save({
+                    attrs: ['beginLineNum', 'numLines'],
+                    boundsUpdated: true
+                });
+            }
+        }, this);
+    },
+
+    /*
      * Returns the number of lines this comment block spans.
      */
     getNumLines: function() {
diff --git a/reviewboard/static/rb/js/diffviewer/views/diffReviewableView.js b/reviewboard/static/rb/js/diffviewer/views/diffReviewableView.js
index 3c603b68e4d6fee39cecb626aad806de030b0f54..220dd732a7e190c3b4e680242e80204155d95d9c 100644
--- a/reviewboard/static/rb/js/diffviewer/views/diffReviewableView.js
+++ b/reviewboard/static/rb/js/diffviewer/views/diffReviewableView.js
@@ -195,6 +195,12 @@ RB.DiffReviewableView = RB.AbstractReviewableView.extend({
             commentBlockView.setRows($(beginRowEl), $(endRowEl || beginRowEl));
             commentBlockView.$el.appendTo(
                 commentBlockView.$beginRow[0].cells[0]);
+            this.listenTo(commentBlockView, 'commentBlockMoveIconClicked',
+                          this._moveCommentBlockView);
+            this.listenTo(commentBlockView, 'commentBlockUpperResizeIconClicked',
+                          this._resizeCommentBlockViewUpperBound);
+            this.listenTo(commentBlockView, 'commentBlockLowerResizeIconClicked',
+                          this._resizeCommentBlockViewLowerBound);
             this._visibleCommentBlockViews.push(commentBlockView);
 
             prevBeginRowIndex = beginRowEl.rowIndex;
@@ -206,6 +212,94 @@ RB.DiffReviewableView = RB.AbstractReviewableView.extend({
     },
 
     /*
+     * Handler for clicks on the move flag of the comment block.
+     *
+     * Move the comment block to the new begin row, and maintains the line
+     * range of the comment block by updating the end row.
+     */
+    _moveCommentBlockView: function(commentBlockView) {
+        commentBlockView.startDragging(_.bind(function(e) {
+            var $parent = $(e.target).parent(),
+                newBeginLineNum,
+                newEndLineNum,
+                endRow;
+
+            if (this._isValidRow($parent)) {
+                newBeginLineNum = parseInt($parent.attr('line'), 10);
+                newEndLineNum = commentBlockView.moveState.endLineNum +
+                    newBeginLineNum - commentBlockView.moveState.beginLineNum;
+                endRow = this._selector.findLineNumRow(newEndLineNum);
+
+                if (endRow != null) {
+                    commentBlockView.model.set({
+                        $beginRow: $parent,
+                        $endRow: $(endRow),
+                        beginLineNum: newBeginLineNum,
+                        endLineNum: newEndLineNum
+                    });
+                }
+            }
+        }, this));
+    },
+
+    /*
+     * Handler for clicks on the upper bound resize flag of the comment block.
+     *
+     * Update the begin row of the comment block.
+     */
+    _resizeCommentBlockViewUpperBound: function(commentBlockView) {
+        commentBlockView.startDragging(_.bind(function(e) {
+            var $parent = $(e.target).parent(),
+                newBeginLineNum;
+
+            if (this._isValidRow($parent)) {
+                newBeginLineNum = parseInt($parent.attr('line'), 10);
+
+                if (newBeginLineNum <= commentBlockView.moveState.endLineNum) {
+                    commentBlockView.model.set({
+                        $beginRow: $parent,
+                        beginLineNum: newBeginLineNum
+                    });
+                }
+            }
+        }, this));
+    },
+
+    /*
+     * Handler for clicks on the lower bound resize flag of the comment block.
+     *
+     * Update the end row of the comment block.
+     */
+    _resizeCommentBlockViewLowerBound: function(commentBlockView) {
+        commentBlockView.startDragging(_.bind(function(e) {
+            var $parent = $(e.target).parent(),
+                newEndLineNum;
+
+            if (this._isValidRow($parent)) {
+                newEndLineNum = parseInt($parent.attr('line'), 10);
+
+                if (newEndLineNum >= commentBlockView.moveState.beginLineNum) {
+                    commentBlockView.model.set({
+                        $endRow: $parent,
+                        endLineNum: newEndLineNum
+                    });
+                }
+            }
+        }, this));
+    },
+
+    /*
+     * Check whether the current row is a valid row based on these conditions:
+     * 1. It is a table row
+     * 2. It has a line number
+     * 3. It belongs in the same table
+     */
+    _isValidRow: function($row) {
+        return $row.is('tr') && $row.attr('line') !== undefined &&
+               $row.closest('table').is(this.$el);
+    },
+
+    /*
      * Places any hidden comment blocks onto the diff viewer.
      */
     _placeHiddenCommentBlockViews: function() {
diff --git a/reviewboard/static/rb/js/models/textBasedCommentBlockModel.js b/reviewboard/static/rb/js/models/textBasedCommentBlockModel.js
index a9b139a3a038508abe86d1b4d5a92ad91d63db86..54eedc9b878ef5f0e8cb6b1d3cf9163c522a9f0c 100644
--- a/reviewboard/static/rb/js/models/textBasedCommentBlockModel.js
+++ b/reviewboard/static/rb/js/models/textBasedCommentBlockModel.js
@@ -26,5 +26,36 @@ RB.TextCommentBlock = RB.FileAttachmentCommentBlock.extend({
         fields.endLineNum = parseInt(fields.endLineNum, 10);
 
         return fields;
+    },
+
+    /*
+     * Return whether the begin and end line numbers can be changed for the
+     * draft comment in this comment block.
+     */
+    canUpdateBounds: function() {
+        return _.isEmpty(this.get('serializedComments'));
+    },
+
+    /*
+     * Save the begin and end line numbers of the draft comment.
+     */
+    saveDraftCommentBounds: function() {
+        var draftComment = this.get('draftComment');
+
+        draftComment.ready({
+            ready: function() {
+                var extraData = draftComment.get('extraData');
+                extraData.beginLineNum = this.get('beginLineNum');
+                extraData.endLineNum = this.get('endLineNum');
+
+                draftComment.save({
+                    attrs: [
+                        'extra_data.beginLineNum',
+                        'extra_data.endLineNum'
+                    ],
+                    boundsUpdated: true
+                });
+            }
+        }, this);
     }
 });
diff --git a/reviewboard/static/rb/js/views/textBasedCommentBlockView.js b/reviewboard/static/rb/js/views/textBasedCommentBlockView.js
index 0a7e15e9a34a409534ac1d04f6412f023e7e59d8..1efd99adaf9513ee1e47e4378ac6a733440f04ec 100644
--- a/reviewboard/static/rb/js/views/textBasedCommentBlockView.js
+++ b/reviewboard/static/rb/js/views/textBasedCommentBlockView.js
@@ -9,6 +9,11 @@
  * This is meant to be used with a TextCommentBlock model.
  */
 RB.TextBasedCommentBlockView = RB.AbstractCommentBlockView.extend({
+    events: {
+        'mousedown': '_onMouseDown',
+        'dblclick': '_onDoubleClick'
+    },
+
     tagName: 'span',
     className: 'commentflag',
 
@@ -26,8 +31,14 @@ RB.TextBasedCommentBlockView = RB.AbstractCommentBlockView.extend({
     initialize: function() {
         this.$beginRow = null;
         this.$endRow = null;
+        this.moveState = {
+            hasMoved: false,
+            initialBeginLineNum: null,
+            initialEndLineNum: null,
+            dragCallback: _.noop
+        };
 
-        _.bindAll(this, '_updateSize');
+        _.bindAll(this, '_updateSize', '_onDrag', '_onMouseUp');
     },
 
     /*
@@ -48,6 +59,50 @@ RB.TextBasedCommentBlockView = RB.AbstractCommentBlockView.extend({
             });
 
         $(window).on('resize', this._updateSize);
+
+        if (this.model.canUpdateBounds()) {
+            this._$moveIcon = $('<div/>')
+                .addClass('move-icon')
+                .appendTo(this.$el);
+
+            this._$upperResizeIcon = $('<div/>')
+                .addClass('upper-resize-icon')
+                .appendTo(this.$el);
+
+            this._$lowerResizeIcon = $('<div/>')
+                .addClass('lower-resize-icon')
+                .appendTo(this.$el);
+        }
+    },
+
+    /*
+     * Initialize moveState dictionary.
+     *
+     * 'hasMoved' is used to distinguish dragging action from clicking.
+     * 'beginLineNum' and 'endLineNum' are used to restrict the new line range
+     *  while dragging.
+     */
+    initializeMoveState: function(callback) {
+        this.moveState.hasMoved = false;
+        this.moveState.beginLineNum = this.model.get('beginLineNum');
+        this.moveState.endLineNum = this.model.get('endLineNum');
+        this.moveState.dragCallback = callback;
+    },
+
+    /*
+     * This method should be called when people seem to start moving the view.
+     */
+    startDragging: function(callback) {
+        this.initializeMoveState(callback);
+        $(window).on('mousemove', this._onDrag);
+    },
+
+    /*
+     * This method should be called when people seem to end moving the view.
+     */
+    endDragging: function() {
+        this.moveState.hasMoved = false;
+        $(window).off('mousemove', this._onDrag);
     },
 
     /*
@@ -122,5 +177,106 @@ RB.TextBasedCommentBlockView = RB.AbstractCommentBlockView.extend({
                             this.$beginRow.offset().top -
                             (this.$el.getExtents('m', 't') || -4));
         }
+    },
+
+    /*
+     * Update the rows of the comment block.
+     *
+     * The new comment block will be moved under the new $beginRow, and ends
+     * at the new $endRow.
+     */
+    _updateRows: function() {
+        var model = this.model,
+            $beginRow = model.get('$beginRow'),
+            $endRow = model.get('$endRow');
+
+        if (_.isNull($beginRow)) {
+            $beginRow = this.$beginRow;
+            model.set('$beginRow', $beginRow);
+        }
+
+        if (_.isNull($endRow)) {
+            $endRow = this.$endRow;
+            model.set('$endRow', $endRow);
+        }
+
+        this.$el.appendTo($beginRow[0].cells[0]);
+        this.setRows($beginRow, $endRow);
+    },
+
+    /*
+     * Mouse-down handler.
+     *
+     * Mouse-down means one of these in this view:
+     * 1. click
+     * 2. start dragging to move the comment
+     * 3. start dragging to resize the comment
+     *
+     * This method looks at e.target and do the appropriate action.
+     */
+    _onMouseDown: function(e) {
+        $(window).one('mouseup', this._onMouseUp);
+        e.preventDefault();
+
+        if (this.model.canUpdateBounds()) {
+            if (e.target === this._$moveIcon.get(0)) {
+                this.trigger('commentBlockMoveIconClicked', this);
+            } else if (e.target === this._$upperResizeIcon.get(0)) {
+                this.trigger('commentBlockUpperResizeIconClicked', this);
+            } else if (e.target === this._$lowerResizeIcon.get(0)) {
+                this.trigger('commentBlockLowerResizeIconClicked', this);
+            }
+        }
+
+        this.listenTo(
+            this.model,
+            'change:beginLineNum change:endLineNum',
+            this._updateRows
+        );
+    },
+
+    /*
+     * Mouse-up handler.
+     *
+     * If something has been dragged, end dragging and update the comment's
+     * bounds.
+     * If not, which means the event was actually a 'click' event, call super
+     * class's click handler.
+     */
+    _onMouseUp: function() {
+        if (this.moveState.hasMoved) {
+            this.model.saveDraftCommentBounds();
+        } else {
+            _super(this)._onClicked.call(this);
+        }
+        this.endDragging();
+
+        $(window).off('mousemove', this._onDrag);
+
+        this.stopListening(this.model);
+    },
+
+    /*
+     * Double-click handler.
+     *
+     * The mouse-down handler conflicts with calling jQuery's click() function
+     * on the comment block. This is a workaround to allow
+     * textCommentRowSelector to use dblclick() to trigger the comment dialog
+     * popup.
+     */
+    _onDoubleClick: function() {
+        _super(this)._onClicked.call(this);
+    },
+
+    /*
+     * Handler for 'dragging'.
+     *
+     * Set moveState.hasMoved to yes to prevent triggering 'click' event
+     */
+    _onDrag: function(e) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.moveState.hasMoved = true;
+        this.moveState.dragCallback.call(this, e);
     }
 });
diff --git a/reviewboard/static/rb/js/views/textBasedReviewableView.js b/reviewboard/static/rb/js/views/textBasedReviewableView.js
index ebbb2fa12c743aacdd0d97a27077fa5ca4e110d9..edd9e7bafb028b0683271bd8afdde31dd792fba5 100644
--- a/reviewboard/static/rb/js/views/textBasedReviewableView.js
+++ b/reviewboard/static/rb/js/views/textBasedReviewableView.js
@@ -247,10 +247,122 @@ RB.TextBasedReviewableView = RB.FileAttachmentReviewableView.extend({
                 commentBlockView.setRows($(rowEls[0]), $(rowEls[1]));
                 commentBlockView.$el.appendTo(
                     commentBlockView.$beginRow[0].cells[0]);
+                this.listenTo(commentBlockView, 'commentBlockMoveIconClicked',
+                              this._moveCommentBlockView);
+                this.listenTo(commentBlockView, 'commentBlockUpperResizeIconClicked',
+                              this._resizeCommentBlockViewUpperBound);
+                this.listenTo(commentBlockView, 'commentBlockLowerResizeIconClicked',
+                              this._resizeCommentBlockViewLowerBound);
             }
         }
     },
 
+    _findEndRow: function($row, targetLine, lineDifference) {
+        var $targetRow = null,
+            $adjacentRows;
+
+        if (lineDifference >= 0) {
+            $adjacentRows = $row.nextAll();
+        } else {
+            lineDifference = -lineDifference;
+            $adjacentRows = $row.prevAll();
+        }
+
+        if ($adjacentRows.length >= lineDifference) {
+            $targetRow = $($adjacentRows.get(lineDifference - 1));
+        }
+
+        return $targetRow;
+    },
+
+    /*
+     * Handler for clicks on the move flag of the comment block.
+     *
+     * Move the comment block to the new begin row, and maintains the line
+     * range of the comment block by updating the end row.
+     */
+    _moveCommentBlockView: function(commentBlockView) {
+        commentBlockView.startDragging(_.bind(function(e) {
+            var $parent = $(e.target).parent(),
+                newBeginLineNum,
+                newEndLineNum,
+                $endRow;
+
+            if (this._isValidRow($parent)) {
+                newBeginLineNum = parseInt($parent.attr('line'), 10);
+                newEndLineNum = commentBlockView.moveState.endLineNum +
+                    newBeginLineNum - commentBlockView.moveState.beginLineNum;
+                $endRow = this._findEndRow($parent, newEndLineNum,
+                                           newEndLineNum - newBeginLineNum);
+
+                if ($endRow != null) {
+                    commentBlockView.model.set({
+                        $beginRow: $parent,
+                        $endRow: $endRow,
+                        beginLineNum: newBeginLineNum,
+                        endLineNum: newEndLineNum
+                    });
+                }
+            }
+        }, this));
+    },
+
+    /*
+     * Handler for clicks on the upper bound resize flag of the comment block.
+     *
+     * Update the begin row of the comment block.
+     */
+    _resizeCommentBlockViewUpperBound: function(commentBlockView) {
+        commentBlockView.startDragging(_.bind(function(e) {
+            var $parent = $(e.target).parent(),
+                newBeginLineNum;
+
+            if (this._isValidRow($parent)) {
+                newBeginLineNum = parseInt($parent.attr('line'), 10);
+
+                if (newBeginLineNum <= commentBlockView.moveState.endLineNum) {
+                    commentBlockView.model.set({
+                        $beginRow: $parent,
+                        beginLineNum: newBeginLineNum
+                    });
+                }
+            }
+        }, this));
+    },
+
+    /*
+     * Handler for clicks on the lower bound resize flag of the comment block.
+     *
+     * Update the end row of the comment block.
+     */
+    _resizeCommentBlockViewLowerBound: function(commentBlockView) {
+        commentBlockView.startDragging(_.bind(function(e) {
+            var $parent = $(e.target).parent(),
+                newEndLineNum;
+
+            if (this._isValidRow($parent)) {
+                newEndLineNum = parseInt($parent.attr('line'), 10);
+
+                if (newEndLineNum >= commentBlockView.moveState.beginLineNum) {
+                    commentBlockView.model.set({
+                        $endRow: $parent,
+                        endLineNum: newEndLineNum
+                    });
+                }
+            }
+        }, this));
+    },
+
+    /*
+     * Check whether the current row is a valid row based on these conditions:
+     * 1. It is a table row
+     * 2. It has a line number
+     * 3. It belongs in the same table
+     */
+    _isValidRow: function($row) {
+        return $row.is('tr') && typeof $row.attr('line') !== 'undefined';
+    },
+
     /*
      * Handler for when the view changes.
      *
diff --git a/reviewboard/static/rb/js/views/textCommentRowSelector.js b/reviewboard/static/rb/js/views/textCommentRowSelector.js
index fe48405eec4163da810d7f17370f9fe2792831eb..9bb431b36b4bcd1e3b2d6207472a6cf6513685b7 100644
--- a/reviewboard/static/rb/js/views/textCommentRowSelector.js
+++ b/reviewboard/static/rb/js/views/textCommentRowSelector.js
@@ -52,6 +52,8 @@ RB.TextCommentRowSelector = Backbone.View.extend({
 
         this._$ghostCommentFlag = null;
         this._$ghostCommentFlagCell = null;
+
+        this._commentBubbleSelected = false;
     },
 
     /*
@@ -300,7 +302,7 @@ RB.TextCommentRowSelector = Backbone.View.extend({
             $commentFlag = $row.find('.commentflag');
 
             if ($commentFlag.length === 1) {
-                $commentFlag.click();
+                $commentFlag.dblclick();
                 return;
             }
         }
@@ -424,6 +426,8 @@ RB.TextCommentRowSelector = Backbone.View.extend({
 
         this._$ghostCommentFlagCell = null;
 
+        this._commentBubbleSelected = false;
+
         /* Re-enable text selection on IE */
         this.$el.enableSelection();
     },
@@ -483,6 +487,13 @@ RB.TextCommentRowSelector = Backbone.View.extend({
     },
 
     /*
+     * Return whether a particular node is a comment bubble.
+     */
+    _isCommentBubble: function(node) {
+        return $(node).parentsUntil('span.comment-flag').length > 0;
+    },
+
+    /*
      * Returns the ancestor <tr> in the diff viewer for some
      * node.
      */
@@ -651,6 +662,8 @@ RB.TextCommentRowSelector = Backbone.View.extend({
         if (this._isLineNumCell(node)) {
             this._begin($(node.parentNode));
             return false;
+        } else if (this._isCommentBubble(node)) {
+            this._commentBubbleSelected = true;
         } else {
             if (node.tagName === 'TD') {
                 $node = $(node);
@@ -677,15 +690,17 @@ RB.TextCommentRowSelector = Backbone.View.extend({
     _onMouseUp: function(e) {
         var node = e.target;
 
-        e.preventDefault();
+        if (!this._commentBubbleSelected) {
+            e.preventDefault();
 
-        if (this._$ghostCommentFlagCell) {
-            node = this._$ghostCommentFlagCell[0];
-        }
+            if (this._$ghostCommentFlagCell) {
+                node = this._$ghostCommentFlagCell[0];
+            }
 
-        if (this._isLineNumCell(node)) {
-            this._end(this._getActualLineNumCell($(node)).parent());
-            e.stopImmediatePropagation();
+            if (this._isLineNumCell(node)) {
+                this._end(this._getActualLineNumCell($(node)).parent());
+                e.stopImmediatePropagation();
+            }
         }
 
         this._reset();
@@ -701,15 +716,17 @@ RB.TextCommentRowSelector = Backbone.View.extend({
         var $node = this._getActualLineNumCell($(e.target)),
             $row = $node.parent();
 
-        if (this._isLineNumCell($node[0])) {
-            if (this._$begin) {
-                this._addRow($row);
-            } else {
-                this._highlightRow($row);
+        if (!this._commentBubbleSelected) {
+            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');
             }
-        } else if (this._$ghostCommentFlagCell &&
-                   $node[0] !== this._$ghostCommentFlagCell[0]) {
-            $row.removeClass('selected');
         }
     },
 
