diff --git a/src/applications/differential/view/DifferentialInlineCommentView.php b/src/applications/differential/view/DifferentialInlineCommentView.php index 2a0ed077c..a52578a59 100644 --- a/src/applications/differential/view/DifferentialInlineCommentView.php +++ b/src/applications/differential/view/DifferentialInlineCommentView.php @@ -1,276 +1,287 @@ <?php /* * Copyright 2012 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ final class DifferentialInlineCommentView extends AphrontView { private $inlineComment; private $onRight; private $buildScaffolding; private $handles; private $markupEngine; private $editable; private $preview; private $allowReply; public function setInlineComment(PhabricatorInlineCommentInterface $comment) { $this->inlineComment = $comment; return $this; } public function setOnRight($on_right) { $this->onRight = $on_right; return $this; } public function setBuildScaffolding($scaffold) { $this->buildScaffolding = $scaffold; return $this; } public function setHandles(array $handles) { assert_instances_of($handles, 'PhabricatorObjectHandle'); $this->handles = $handles; return $this; } public function setMarkupEngine(PhutilMarkupEngine $engine) { $this->markupEngine = $engine; return $this; } public function setEditable($editable) { $this->editable = $editable; return $this; } public function setPreview($preview) { $this->preview = $preview; return $this; } public function setAllowReply($allow_reply) { $this->allowReply = $allow_reply; return $this; } public function render() { $inline = $this->inlineComment; $start = $inline->getLineNumber(); $length = $inline->getLineLength(); if ($length) { $end = $start + $length; $line = 'Lines '.number_format($start).'-'.number_format($end); } else { $line = 'Line '.number_format($start); } $metadata = array( 'id' => $inline->getID(), 'number' => $inline->getLineNumber(), 'length' => $inline->getLineLength(), 'on_right' => $this->onRight, 'original' => $inline->getContent(), ); $sigil = 'differential-inline-comment'; + if ($this->preview) { + $sigil = $sigil . ' differential-inline-comment-preview'; + } $content = $inline->getContent(); $handles = $this->handles; $links = array(); $is_synthetic = false; if ($inline->getSyntheticAuthor()) { $is_synthetic = true; } $is_draft = false; if ($inline->isDraft() && !$is_synthetic) { $links[] = 'Not Submitted Yet'; $is_draft = true; } if (!$this->preview) { $links[] = javelin_render_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'sigil' => 'differential-inline-prev', ), 'Previous'); $links[] = javelin_render_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'sigil' => 'differential-inline-next', ), 'Next'); if ($this->allowReply) { if (!$is_synthetic) { // NOTE: No product reason why you can't reply to these, but the reply // mechanism currently sends the inline comment ID to the server, not // file/line information, and synthetic comments don't have an inline // comment ID. $links[] = javelin_render_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'sigil' => 'differential-inline-reply', ), 'Reply'); } } } $anchor_name = 'inline-'.$inline->getID(); if ($this->editable && !$this->preview) { $links[] = javelin_render_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'sigil' => 'differential-inline-edit', ), 'Edit'); $links[] = javelin_render_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'sigil' => 'differential-inline-delete', ), 'Delete'); } else if ($this->preview) { $links[] = javelin_render_tag( 'a', array( 'meta' => array( 'anchor' => $anchor_name, ), 'sigil' => 'differential-inline-preview-jump', ), 'Not Visible'); + $links[] = javelin_render_tag( + 'a', + array( + 'href' => '#', + 'mustcapture' => true, + 'sigil' => 'differential-inline-delete', + ), + 'Delete'); } if ($links) { $links = '<span class="differential-inline-comment-links">'. implode(' · ', $links). '</span>'; } else { $links = null; } $cache = $inline->getCache(); if (strlen($cache)) { $content = $cache; } else { $content = $this->markupEngine->markupText($content); if ($inline->getID()) { $inline->setCache($content); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $inline->save(); unset($unguarded); } } if ($this->preview) { $anchor = null; } else { $anchor = phutil_render_tag( 'a', array( 'name' => $anchor_name, 'id' => $anchor_name, 'class' => 'differential-inline-comment-anchor', ), ''); } $classes = array( 'differential-inline-comment', ); if ($is_draft) { $classes[] = 'differential-inline-comment-unsaved-draft'; } if ($is_synthetic) { $classes[] = 'differential-inline-comment-synthetic'; } $classes = implode(' ', $classes); if ($is_synthetic) { $author = $inline->getSyntheticAuthor(); } else { $author = $handles[$inline->getAuthorPHID()]->getName(); } $markup = javelin_render_tag( 'div', array( 'class' => $classes, 'sigil' => $sigil, 'meta' => $metadata, ), '<div class="differential-inline-comment-head">'. $anchor. $links. ' <span class="differential-inline-comment-line">'.$line.'</span> '. phutil_escape_html($author). '</div>'. '<div class="differential-inline-comment-content">'. '<div class="phabricator-remarkup">'. $content. '</div>'. '</div>'); return $this->scaffoldMarkup($markup); } private function scaffoldMarkup($markup) { if (!$this->buildScaffolding) { return $markup; } $left_markup = !$this->onRight ? $markup : ''; $right_markup = $this->onRight ? $markup : ''; return '<table>'. '<tr class="inline">'. '<th></th>'. '<td>'.$left_markup.'</td>'. '<th></th>'. '<td colspan="2">'.$right_markup.'</td>'. '</tr>'. '</table>'; } } diff --git a/webroot/rsrc/js/application/differential/DifferentialInlineCommentEditor.js b/webroot/rsrc/js/application/differential/DifferentialInlineCommentEditor.js index eddd65531..6e544fe78 100644 --- a/webroot/rsrc/js/application/differential/DifferentialInlineCommentEditor.js +++ b/webroot/rsrc/js/application/differential/DifferentialInlineCommentEditor.js @@ -1,266 +1,271 @@ /** * @provides differential-inline-comment-editor * @requires javelin-dom * javelin-util * javelin-stratcom * javelin-install * javelin-request * javelin-workflow */ JX.install('DifferentialInlineCommentEditor', { construct : function(uri) { this._uri = uri; }, events : ['done'], members : { _uri : null, _undoText : null, _skipOverInlineCommentRows : function(node) { // TODO: Move this semantic information out of class names. while (node && node.className.indexOf('inline') !== -1) { node = node.nextSibling; } return node; }, _buildRequestData : function() { return { op : this.getOperation(), on_right : this.getOnRight(), id : this.getID(), number : this.getLineNumber(), is_new : this.getIsNew(), length : this.getLength(), changeset : this.getChangeset(), text : this.getText() || '' }; }, _draw : function(content, exact_row) { var row = this.getRow(); var table = this.getTable(); var target = exact_row ? row : this._skipOverInlineCommentRows(row); return copyRows(table, content, target); }, _removeUndoLink : function() { var rows = JX.DifferentialInlineCommentEditor._undoRows; if (rows) { for (var ii = 0; ii < rows.length; ii++) { JX.DOM.remove(rows[ii]); } } }, _undo : function() { this._removeUndoLink(); this.setText(this._undoText); this.start(); }, _registerUndoListener : function() { if (!JX.DifferentialInlineCommentEditor._activeEditor) { JX.Stratcom.listen( 'click', 'differential-inline-comment-undo', function(e) { JX.DifferentialInlineCommentEditor._activeEditor._undo(); e.kill(); }); } JX.DifferentialInlineCommentEditor._activeEditor = this; }, _setRowState : function(state) { var is_hidden = (state == 'hidden'); var is_loading = (state == 'loading'); var row = this.getRow(); JX.DOM.alterClass(row, 'differential-inline-hidden', is_hidden); JX.DOM.alterClass(row, 'differential-inline-loading', is_loading); }, _didContinueWorkflow : function(response) { var drawn = this._draw(JX.$N('div', JX.$H(response))); var op = this.getOperation(); if (op == 'edit') { this._setRowState('hidden'); } JX.DOM.find( drawn[0], 'textarea', 'differential-inline-comment-edit-textarea').focus(); var oncancel = JX.bind(this, function(e) { e.kill(); this._didCancelWorkflow(); if (op == 'edit') { this._setRowState('visible'); } JX.DOM.remove(drawn[0]); }); JX.DOM.listen(drawn[0], 'click', 'inline-edit-cancel', oncancel); var onsubmit = JX.bind(this, function(e) { e.kill(); JX.Workflow.newFromForm(e.getTarget()) .setHandler(JX.bind(this, function(response) { JX.DOM.remove(drawn[0]); if (op == 'edit') { this._setRowState('visible'); } this._didCompleteWorkflow(response); })) .start(); JX.DOM.alterClass(drawn[0], 'differential-inline-loading', true); }); JX.DOM.listen(drawn[0], 'submit', 'inline-edit-form', onsubmit); }, _didCompleteWorkflow : function(response) { var op = this.getOperation(); // We don't get any markup back if the user deletes a comment, or saves // an empty comment (which effects a delete). if (response.markup) { this._draw(JX.$N('div', JX.$H(response.markup))); } // These operations remove the old row (edit adds a new row first). var remove_old = (op == 'edit' || op == 'delete'); if (remove_old) { JX.DOM.remove(this.getRow()); + var other_rows = this.getOtherRows(); + for(var i = 0; i < other_rows.length; ++i) { + JX.DOM.remove(other_rows[i]); + } } // Once the user saves something, get rid of the 'undo' option. A // particular case where we need this is saving a delete, when we might // otherwise leave around an 'undo' for an earlier edit to the same // comment. this._removeUndoLink(); JX.Stratcom.invoke('differential-inline-comment-update'); this.invoke('done'); }, _didCancelWorkflow : function() { this.invoke('done'); var op = this.getOperation(); if (op == 'delete') { // No undo for delete, we prompt the user explicitly. return; } try { var textarea = JX.DOM.find( document.body, // TODO: use getDialogRootNode() when available 'textarea', 'differential-inline-comment-edit-textarea'); } catch (ex) { // The close handler is called whenever the dialog closes, even if the // user closed it by completing the workflow with "Save". The // JX.Workflow API should probably be refined to allow programmatic // distinction of close caused by 'cancel' vs 'submit'. Testing for // presence of the textarea serves as a proxy for detecting a 'cancel'. return; } var text = textarea.value; // If the user hasn't edited the text (i.e., no change from original for // 'edit' or no text at all), don't offer them an undo. if (text == this.getOriginalText() || text == '') { return; } // Save the text so we can 'undo' back to it. this._undoText = text; var template = this.getOnRight() ? this.getTemplates().r : this.getTemplates().l; template = JX.$N('div', JX.$H(template)); // NOTE: Operation order matters here; we can't remove anything until // after we draw the new rows because _draw uses the old rows to figure // out where to place the comment. // We use 'exact_row' to put the "undo" text directly above the affected // comment. var exact_row = true; var rows = this._draw(template, exact_row); this._removeUndoLink(); JX.DifferentialInlineCommentEditor._undoRows = rows; }, start : function() { this._registerUndoListener(); var data = this._buildRequestData(); var op = this.getOperation(); if (op == 'delete') { this._setRowState('loading'); var oncomplete = JX.bind(this, this._didCompleteWorkflow); var onclose = JX.bind(this, function() { this._setRowState('visible'); this._didCancelWorkflow(); }); new JX.Workflow(this._uri, data) .setHandler(oncomplete) .setCloseHandler(onclose) .start(); } else { var handler = JX.bind(this, this._didContinueWorkflow); if (op == 'edit') { this._setRowState('loading'); } new JX.Request(this._uri, handler) .setData(data) .send(); } return this; } }, statics : { /** * Global refernece to the 'undo' rows currently rendered in the document. */ _undoRows : null, /** * Global listener for the 'undo' click associated with the currently * displayed 'undo' link. When an editor is start()ed, it becomes the active * editor. */ _activeEditor : null }, properties : { operation : null, row : null, + otherRows: [], table : null, onRight : null, ID : null, lineNumber : null, changeset : null, length : null, isNew : null, text : null, templates : null, originalText : null } }); diff --git a/webroot/rsrc/js/application/differential/behavior-edit-inline-comments.js b/webroot/rsrc/js/application/differential/behavior-edit-inline-comments.js index e28c4ebcd..ae8bb3c04 100644 --- a/webroot/rsrc/js/application/differential/behavior-edit-inline-comments.js +++ b/webroot/rsrc/js/application/differential/behavior-edit-inline-comments.js @@ -1,242 +1,262 @@ /** * @provides javelin-behavior-differential-edit-inline-comments * @requires javelin-behavior * javelin-stratcom * javelin-dom * javelin-util * javelin-vector * differential-inline-comment-editor */ JX.behavior('differential-edit-inline-comments', function(config) { var selecting = false; var reticle = JX.$N('div', {className: 'differential-reticle'}); JX.DOM.hide(reticle); document.body.appendChild(reticle); var origin = null; var target = null; var root = null; var changeset = null; var editor = null; function updateReticle() { var top = origin; var bot = target; if (JX.$V(top).y > JX.$V(bot).y) { var tmp = top; top = bot; bot = tmp; } var code = target.nextSibling; var pos = JX.$V(top).add(1 + JX.Vector.getDim(target).x, 0); var dim = JX.Vector.getDim(code).add(-4, 0); if (isOnRight(target)) { dim.x += JX.Vector.getDim(code.nextSibling).x; } dim.y = (JX.$V(bot).y - pos.y) + JX.Vector.getDim(bot).y; pos.setPos(reticle); dim.setDim(reticle); JX.DOM.show(reticle); } function hideReticle() { JX.DOM.hide(reticle); } JX.DifferentialInlineCommentEditor.listen('done', function() { selecting = false; editor = false; hideReticle(); set_link_state(false); }); function isOnRight(node) { return node.parentNode.firstChild != node; } function isNewFile(node) { var data = JX.Stratcom.getData(root); return isOnRight(node) || (data.left != data.right); } function getRowNumber(th_node) { try { return parseInt(th_node.id.match(/^C\d+[ON]L(\d+)$/)[1], 10); } catch (x) { return undefined; } } var set_link_state = function(active) { JX.DOM.alterClass(JX.$(config.stage), 'inline-editor-active', active); }; JX.Stratcom.listen( 'mousedown', ['differential-changeset', 'tag:th'], function(e) { if (editor || selecting || e.isRightButton() || getRowNumber(e.getTarget()) === undefined) { return; } selecting = true; root = e.getNode('differential-changeset'); origin = target = e.getTarget(); var data = e.getNodeData('differential-changeset'); if (isOnRight(target)) { changeset = data.right; } else { changeset = data.left; } updateReticle(); e.kill(); }); JX.Stratcom.listen( 'mouseover', ['differential-changeset', 'tag:th'], function(e) { if (!selecting || editor || (getRowNumber(e.getTarget()) === undefined) || (isOnRight(e.getTarget()) != isOnRight(origin)) || (e.getNode('differential-changeset') !== root)) { return; } target = e.getTarget(); updateReticle(); }); JX.Stratcom.listen( 'mouseup', null, function(e) { if (editor || !selecting) { return; } var o = getRowNumber(origin); var t = getRowNumber(target); var insert; var len; if (t < o) { len = (o - t); o = t; insert = origin.parentNode; } else { len = (t - o); insert = target.parentNode; } editor = new JX.DifferentialInlineCommentEditor(config.uri) .setTemplates(config.undo_templates) .setOperation('new') .setChangeset(changeset) .setLineNumber(o) .setLength(len) .setIsNew(isNewFile(target) ? 1 : 0) .setOnRight(isOnRight(target) ? 1 : 0) .setRow(insert.nextSibling) .setTable(insert.parentNode) .start(); set_link_state(true); e.kill(); }); JX.Stratcom.listen( ['mouseover', 'mouseout'], 'differential-inline-comment', function(e) { if (e.getType() == 'mouseout') { hideReticle(); } else { root = e.getNode('differential-changeset'); var data = e.getNodeData('differential-inline-comment'); var change = e.getNodeData('differential-changeset'); var id_part = data.on_right ? change.right : change.left; var th = e.getNode('tag:td').previousSibling; var new_part = isNewFile(th) ? 'N' : 'O'; var prefix = 'C' + id_part + new_part + 'L'; origin = JX.$(prefix + data.number); target = JX.$(prefix + (parseInt(data.number, 10) + parseInt(data.length, 10))); updateReticle(); } }); var action_handler = function(op, e) { e.kill(); if (editor) { return; } var node = e.getNode('differential-inline-comment'); handle_inline_action(node, op); } var handle_inline_action = function(node, op) { var data = JX.Stratcom.getData(node); var row = node.parentNode.parentNode; + var other_rows = []; + if (JX.Stratcom.hasSigil(node, 'differential-inline-comment-preview')) { + // The DOM structure around the comment is different if it's part of the + // preview, so make sure not to pass the wrong container. + row = node; + if (op === 'delete') { + // Furthermore, deleting a comment in the preview does not automatically + // delete other occurrences of the same comment, so do that manually. + var nodes = JX.DOM.scry( + document.body, + 'div', + 'differential-inline-comment'); + for (var i = 0; i < nodes.length; ++i) { + if (JX.Stratcom.getData(nodes[i]).id === data.id) { + other_rows.push(nodes[i]); + } + } + } + } var original = data.original; if (op == 'reply') { // If the user hit "reply", the original text is empty (a new reply), not // the text of the comment they're replying to. original = ''; } editor = new JX.DifferentialInlineCommentEditor(config.uri) .setTemplates(config.undo_templates) .setOperation(op) .setID(data.id) .setLineNumber(data.number) .setLength(data.length) .setOnRight(data.on_right) .setOriginalText(original) .setRow(row) + .setOtherRows(other_rows) .setTable(row.parentNode) .start(); set_link_state(true); } for (var op in {'edit' : 1, 'delete' : 1, 'reply' : 1}) { JX.Stratcom.listen( 'click', ['differential-inline-comment', 'differential-inline-' + op], JX.bind(null, action_handler, op)); } JX.Stratcom.listen( 'differential-inline-action', null, function(e) { var data = e.getData(); handle_inline_action(data.node, data.op); }); });