mirror of
https://we.phorge.it/source/phorge.git
synced 2024-11-27 01:02:42 +01:00
7d44e7cb4d
Summary: Ref T12733. When an inline is selected, make it stand out so you can see where you are in the document more clearly. Test Plan: {F4968509} Reviewers: chad Reviewed By: chad Maniphest Tasks: T12733 Differential Revision: https://secure.phabricator.com/D17982
795 lines
20 KiB
JavaScript
795 lines
20 KiB
JavaScript
/**
|
|
* @provides phabricator-diff-inline
|
|
* @requires javelin-dom
|
|
* @javelin
|
|
*/
|
|
|
|
JX.install('DiffInline', {
|
|
|
|
construct : function() {
|
|
},
|
|
|
|
members: {
|
|
_id: null,
|
|
_phid: null,
|
|
_changesetID: null,
|
|
_row: null,
|
|
_hidden: false,
|
|
_number: null,
|
|
_length: null,
|
|
_displaySide: null,
|
|
_isNewFile: null,
|
|
_undoRow: null,
|
|
_replyToCommentPHID: null,
|
|
_originalText: null,
|
|
_snippet: null,
|
|
|
|
_isDeleted: false,
|
|
_isInvisible: false,
|
|
_isLoading: false,
|
|
|
|
_changeset: null,
|
|
_objective: null,
|
|
|
|
_isDraft: null,
|
|
_isFixed: null,
|
|
_isEditing: false,
|
|
_isNew: false,
|
|
|
|
bindToRow: function(row) {
|
|
this._row = row;
|
|
this._objective.setAnchor(this._row);
|
|
|
|
var row_data = JX.Stratcom.getData(row);
|
|
row_data.inline = this;
|
|
this._hidden = row_data.hidden || false;
|
|
|
|
// TODO: Get smarter about this once we do more editing, this is pretty
|
|
// hacky.
|
|
var comment = JX.DOM.find(row, 'div', 'differential-inline-comment');
|
|
var data = JX.Stratcom.getData(comment);
|
|
|
|
this._id = data.id;
|
|
this._phid = data.phid;
|
|
|
|
// TODO: This is very, very, very, very, very, very, very hacky.
|
|
var td = comment.parentNode;
|
|
var th = td.previousSibling;
|
|
if (th.parentNode.firstChild != th) {
|
|
this._displaySide = 'right';
|
|
} else {
|
|
this._displaySide = 'left';
|
|
}
|
|
|
|
this._number = parseInt(data.number, 10);
|
|
this._length = parseInt(data.length, 10);
|
|
this._originalText = data.original;
|
|
this._isNewFile =
|
|
(this.getDisplaySide() == 'right') ||
|
|
(data.left != data.right);
|
|
|
|
this._replyToCommentPHID = data.replyToCommentPHID;
|
|
|
|
this._isDraft = data.isDraft;
|
|
this._isFixed = data.isFixed;
|
|
this._isGhost = data.isGhost;
|
|
|
|
this._changesetID = data.changesetID;
|
|
this._isNew = false;
|
|
this._snippet = data.snippet;
|
|
|
|
this.setInvisible(false);
|
|
|
|
this.updateObjective();
|
|
|
|
return this;
|
|
},
|
|
|
|
bindToRange: function(data) {
|
|
this._displaySide = data.displaySide;
|
|
this._number = parseInt(data.number, 10);
|
|
this._length = parseInt(data.length, 10);
|
|
this._isNewFile = data.isNewFile;
|
|
this._changesetID = data.changesetID;
|
|
this._isNew = true;
|
|
|
|
// Insert the comment after any other comments which already appear on
|
|
// the same row.
|
|
var parent_row = JX.DOM.findAbove(data.target, 'tr');
|
|
var target_row = parent_row.nextSibling;
|
|
while (target_row && JX.Stratcom.hasSigil(target_row, 'inline-row')) {
|
|
target_row = target_row.nextSibling;
|
|
}
|
|
|
|
var row = this._newRow();
|
|
parent_row.parentNode.insertBefore(row, target_row);
|
|
|
|
this.setInvisible(true);
|
|
|
|
return this;
|
|
},
|
|
|
|
bindToReply: function(inline) {
|
|
this._displaySide = inline._displaySide;
|
|
this._number = inline._number;
|
|
this._length = inline._length;
|
|
this._isNewFile = inline._isNewFile;
|
|
this._changesetID = inline._changesetID;
|
|
this._isNew = true;
|
|
|
|
this._replyToCommentPHID = inline._phid;
|
|
|
|
var changeset = this.getChangeset();
|
|
|
|
// We're going to figure out where in the document to position the new
|
|
// inline. Normally, it goes after any existing inline rows (so if
|
|
// several inlines reply to the same line, they appear in chronological
|
|
// order).
|
|
|
|
// However: if inlines are threaded, we want to put the new inline in
|
|
// the right place in the thread. This might be somewhere in the middle,
|
|
// so we need to do a bit more work to figure it out.
|
|
|
|
// To find the right place in the thread, we're going to look for any
|
|
// inline which is at or above the level of the comment we're replying
|
|
// to. This means we've reached a new fork of the thread, and should
|
|
// put our new inline before the comment we found.
|
|
var ancestor_map = {};
|
|
var ancestor = inline;
|
|
var reply_phid;
|
|
while (ancestor) {
|
|
reply_phid = ancestor.getReplyToCommentPHID();
|
|
if (!reply_phid) {
|
|
break;
|
|
}
|
|
ancestor_map[reply_phid] = true;
|
|
ancestor = changeset.getInlineByPHID(reply_phid);
|
|
}
|
|
|
|
var parent_row = inline._row;
|
|
var target_row = parent_row.nextSibling;
|
|
while (target_row && JX.Stratcom.hasSigil(target_row, 'inline-row')) {
|
|
var target = changeset.getInlineForRow(target_row);
|
|
reply_phid = target.getReplyToCommentPHID();
|
|
|
|
// If we found an inline which is replying directly to some ancestor
|
|
// of this new comment, this is where the new rows go.
|
|
if (ancestor_map.hasOwnProperty(reply_phid)) {
|
|
break;
|
|
}
|
|
|
|
target_row = target_row.nextSibling;
|
|
}
|
|
|
|
var row = this._newRow();
|
|
parent_row.parentNode.insertBefore(row, target_row);
|
|
|
|
this.setInvisible(true);
|
|
|
|
return this;
|
|
},
|
|
|
|
setChangeset: function(changeset) {
|
|
this._changeset = changeset;
|
|
|
|
var objectives = changeset.getChangesetList().getObjectives();
|
|
|
|
// Create this inline's objective, but don't show it yet.
|
|
this._objective = objectives.newObjective()
|
|
.setCallback(JX.bind(this, this._onobjective))
|
|
.hide();
|
|
|
|
return this;
|
|
},
|
|
|
|
getChangeset: function() {
|
|
return this._changeset;
|
|
},
|
|
|
|
setEditing: function(editing) {
|
|
this._isEditing = editing;
|
|
this.updateObjective();
|
|
return this;
|
|
},
|
|
|
|
_onobjective: function() {
|
|
this.getChangeset().getChangesetList().selectInline(this);
|
|
},
|
|
|
|
updateObjective: function() {
|
|
var objective = this._objective;
|
|
|
|
if (this.isHidden() || this._isDeleted) {
|
|
objective.hide();
|
|
return;
|
|
}
|
|
|
|
// If this is a new comment which we aren't editing, don't show anything:
|
|
// the use started a comment or reply, then cancelled it.
|
|
if (this._isNew && !this._isEditing) {
|
|
objective.hide();
|
|
return;
|
|
}
|
|
|
|
var changeset = this.getChangeset();
|
|
if (!changeset.isVisible()) {
|
|
objective.hide();
|
|
return;
|
|
}
|
|
|
|
var pht = changeset.getChangesetList().getTranslations();
|
|
|
|
var icon = 'fa-comment';
|
|
var color = 'bluegrey';
|
|
var tooltip = this._snippet;
|
|
var anchor = this._row;
|
|
var should_stack = false;
|
|
|
|
if (this._isEditing) {
|
|
icon = 'fa-star';
|
|
color = 'pink';
|
|
tooltip = pht('Editing Comment');
|
|
|
|
// If we're editing, anchor to the row with the editor instead of the
|
|
// actual comment row (which is invisible and can have a misleading
|
|
// position).
|
|
anchor = this._row.nextSibling;
|
|
} else if (this._isDraft) {
|
|
// This inline is an unsubmitted draft.
|
|
icon = 'fa-pencil';
|
|
color = 'indigo';
|
|
} else if (this._isFixed) {
|
|
// This inline has been marked done.
|
|
icon = 'fa-check';
|
|
color = 'grey';
|
|
} else if (this._isGhost) {
|
|
icon = 'fa-comment-o';
|
|
color = 'grey';
|
|
} else if (this._replyToCommentPHID) {
|
|
icon = 'fa-reply';
|
|
should_stack = true;
|
|
}
|
|
|
|
if (changeset.getChangesetList().getSelectedInline() === this) {
|
|
// TODO: Maybe add some other kind of effect here, since we're only
|
|
// using color to show this?
|
|
color = 'yellow';
|
|
}
|
|
|
|
|
|
objective
|
|
.setAnchor(anchor)
|
|
.setIcon(icon)
|
|
.setColor(color)
|
|
.setTooltip(tooltip)
|
|
.setShouldStack(should_stack)
|
|
.show();
|
|
},
|
|
|
|
canReply: function() {
|
|
if (!this._hasAction('reply')) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
canEdit: function() {
|
|
if (!this._hasAction('edit')) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
canDone: function() {
|
|
if (!JX.DOM.scry(this._row, 'input', 'differential-inline-done').length) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
canHide: function() {
|
|
if (!JX.DOM.scry(this._row, 'a', 'hide-inline').length) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
getRawText: function() {
|
|
return this._originalText;
|
|
},
|
|
|
|
_hasAction: function(action) {
|
|
var nodes = JX.DOM.scry(this._row, 'a', 'differential-inline-' + action);
|
|
return (nodes.length > 0);
|
|
},
|
|
|
|
_newRow: function() {
|
|
var attributes = {
|
|
sigil: 'inline-row'
|
|
};
|
|
|
|
var row = JX.$N('tr', attributes);
|
|
|
|
JX.Stratcom.getData(row).inline = this;
|
|
this._row = row;
|
|
this._objective.setAnchor(this._row);
|
|
|
|
this._id = null;
|
|
this._phid = null;
|
|
this._hidden = false;
|
|
|
|
this._originalText = null;
|
|
|
|
return row;
|
|
},
|
|
|
|
setHidden: function(hidden) {
|
|
this._hidden = hidden;
|
|
|
|
JX.DOM.alterClass(this._row, 'inline-hidden', this._hidden);
|
|
|
|
var op;
|
|
if (hidden) {
|
|
op = 'hide';
|
|
} else {
|
|
op = 'show';
|
|
}
|
|
|
|
var inline_uri = this._getInlineURI();
|
|
var comment_id = this._id;
|
|
|
|
new JX.Workflow(inline_uri, {op: op, ids: comment_id})
|
|
.setHandler(JX.bag)
|
|
.start();
|
|
|
|
this._didUpdate(true);
|
|
},
|
|
|
|
isHidden: function() {
|
|
return this._hidden;
|
|
},
|
|
|
|
toggleDone: function() {
|
|
var uri = this._getInlineURI();
|
|
var data = {
|
|
op: 'done',
|
|
id: this._id
|
|
};
|
|
|
|
var ondone = JX.bind(this, this._ondone);
|
|
|
|
new JX.Workflow(uri, data)
|
|
.setHandler(ondone)
|
|
.start();
|
|
},
|
|
|
|
_ondone: function(response) {
|
|
var checkbox = JX.DOM.find(
|
|
this._row,
|
|
'input',
|
|
'differential-inline-done');
|
|
|
|
checkbox.checked = (response.isChecked ? 'checked' : null);
|
|
|
|
var comment = JX.DOM.findAbove(
|
|
checkbox,
|
|
'div',
|
|
'differential-inline-comment');
|
|
|
|
JX.DOM.alterClass(comment, 'inline-is-done', response.isChecked);
|
|
|
|
// NOTE: This is marking the inline as having an unsubmitted checkmark,
|
|
// as opposed to a submitted checkmark. This is different from the
|
|
// top-level "draft" state of unsubmitted comments.
|
|
JX.DOM.alterClass(comment, 'inline-state-is-draft', response.draftState);
|
|
|
|
this._isFixed = response.isChecked;
|
|
|
|
this._didUpdate();
|
|
},
|
|
|
|
create: function(text) {
|
|
var uri = this._getInlineURI();
|
|
var handler = JX.bind(this, this._oncreateresponse);
|
|
var data = this._newRequestData('new', text);
|
|
|
|
this.setLoading(true);
|
|
|
|
new JX.Request(uri, handler)
|
|
.setData(data)
|
|
.send();
|
|
},
|
|
|
|
reply: function(text) {
|
|
var changeset = this.getChangeset();
|
|
return changeset.newInlineReply(this, text);
|
|
},
|
|
|
|
edit: function(text) {
|
|
var uri = this._getInlineURI();
|
|
var handler = JX.bind(this, this._oneditresponse);
|
|
var data = this._newRequestData('edit', text || null);
|
|
|
|
this.setLoading(true);
|
|
|
|
new JX.Request(uri, handler)
|
|
.setData(data)
|
|
.send();
|
|
},
|
|
|
|
delete: function(is_ref) {
|
|
var uri = this._getInlineURI();
|
|
var handler = JX.bind(this, this._ondeleteresponse);
|
|
|
|
// NOTE: This may be a direct delete (the user clicked on the inline
|
|
// itself) or a "refdelete" (the user clicked somewhere else, like the
|
|
// preview, but the inline is present on the page).
|
|
|
|
// For a "refdelete", we prompt the user to confirm that they want to
|
|
// delete the comment, because they can not undo deletions from the
|
|
// preview. We could jump the user to the inline instead, but this would
|
|
// be somewhat disruptive and make deleting several comments more
|
|
// difficult.
|
|
|
|
var op;
|
|
if (is_ref) {
|
|
op = 'refdelete';
|
|
} else {
|
|
op = 'delete';
|
|
}
|
|
|
|
var data = this._newRequestData(op);
|
|
|
|
this.setLoading(true);
|
|
|
|
new JX.Workflow(uri, data)
|
|
.setHandler(handler)
|
|
.start();
|
|
},
|
|
|
|
getDisplaySide: function() {
|
|
return this._displaySide;
|
|
},
|
|
|
|
getLineNumber: function() {
|
|
return this._number;
|
|
},
|
|
|
|
getLineLength: function() {
|
|
return this._length;
|
|
},
|
|
|
|
isNewFile: function() {
|
|
return this._isNewFile;
|
|
},
|
|
|
|
getID: function() {
|
|
return this._id;
|
|
},
|
|
|
|
getPHID: function() {
|
|
return this._phid;
|
|
},
|
|
|
|
getChangesetID: function() {
|
|
return this._changesetID;
|
|
},
|
|
|
|
getReplyToCommentPHID: function() {
|
|
return this._replyToCommentPHID;
|
|
},
|
|
|
|
setDeleted: function(deleted) {
|
|
this._isDeleted = deleted;
|
|
this._redraw();
|
|
return this;
|
|
},
|
|
|
|
setInvisible: function(invisible) {
|
|
this._isInvisible = invisible;
|
|
this._redraw();
|
|
return this;
|
|
},
|
|
|
|
setLoading: function(loading) {
|
|
this._isLoading = loading;
|
|
this._redraw();
|
|
return this;
|
|
},
|
|
|
|
_newRequestData: function(operation, text) {
|
|
return {
|
|
op: operation,
|
|
id: this._id,
|
|
on_right: ((this.getDisplaySide() == 'right') ? 1 : 0),
|
|
renderer: this.getChangeset().getRenderer(),
|
|
number: this.getLineNumber(),
|
|
length: this.getLineLength(),
|
|
is_new: this.isNewFile(),
|
|
changesetID: this.getChangesetID(),
|
|
replyToCommentPHID: this.getReplyToCommentPHID() || '',
|
|
text: text || ''
|
|
};
|
|
},
|
|
|
|
_oneditresponse: function(response) {
|
|
var rows = JX.$H(response).getNode();
|
|
|
|
this._drawEditRows(rows);
|
|
|
|
this.setLoading(false);
|
|
this.setInvisible(true);
|
|
},
|
|
|
|
_oncreateresponse: function(response) {
|
|
var rows = JX.$H(response).getNode();
|
|
|
|
this._drawEditRows(rows);
|
|
},
|
|
|
|
_ondeleteresponse: function() {
|
|
this._drawUndeleteRows();
|
|
|
|
this.setLoading(false);
|
|
this.setDeleted(true);
|
|
|
|
this._didUpdate();
|
|
},
|
|
|
|
_drawUndeleteRows: function() {
|
|
return this._drawUndoRows('undelete', this._row);
|
|
},
|
|
|
|
_drawUneditRows: function(text) {
|
|
return this._drawUndoRows('unedit', null, text);
|
|
},
|
|
|
|
_drawUndoRows: function(mode, cursor, text) {
|
|
var templates = this.getChangeset().getUndoTemplates();
|
|
|
|
var template;
|
|
if (this.getDisplaySide() == 'right') {
|
|
template = templates.r;
|
|
} else {
|
|
template = templates.l;
|
|
}
|
|
template = JX.$H(template).getNode();
|
|
|
|
this._undoRow = this._drawRows(template, cursor, mode, text);
|
|
},
|
|
|
|
_drawContentRows: function(rows) {
|
|
return this._drawRows(rows, null, 'content');
|
|
},
|
|
|
|
_drawEditRows: function(rows) {
|
|
this.setEditing(true);
|
|
return this._drawRows(rows, null, 'edit');
|
|
},
|
|
|
|
_drawRows: function(rows, cursor, type, text) {
|
|
var first_row = JX.DOM.scry(rows, 'tr')[0];
|
|
var first_meta;
|
|
var row = first_row;
|
|
var anchor = cursor || this._row;
|
|
cursor = cursor || this._row.nextSibling;
|
|
|
|
var next_row;
|
|
while (row) {
|
|
// Grab this first, since it's going to change once we insert the row
|
|
// into the document.
|
|
next_row = row.nextSibling;
|
|
|
|
// Bind edit and undo rows to this DiffInline object so that
|
|
// interactions like hovering work properly.
|
|
JX.Stratcom.getData(row).inline = this;
|
|
|
|
anchor.parentNode.insertBefore(row, cursor);
|
|
cursor = row;
|
|
|
|
var row_meta = {
|
|
node: row,
|
|
type: type,
|
|
text: text || null,
|
|
listeners: []
|
|
};
|
|
|
|
if (!first_meta) {
|
|
first_meta = row_meta;
|
|
}
|
|
|
|
if (type == 'edit') {
|
|
row_meta.listeners.push(
|
|
JX.DOM.listen(
|
|
row,
|
|
['submit', 'didSyntheticSubmit'],
|
|
'inline-edit-form',
|
|
JX.bind(this, this._onsubmit, row_meta)));
|
|
|
|
row_meta.listeners.push(
|
|
JX.DOM.listen(
|
|
row,
|
|
'click',
|
|
'inline-edit-cancel',
|
|
JX.bind(this, this._oncancel, row_meta)));
|
|
} else if (type == 'content') {
|
|
// No special listeners for these rows.
|
|
} else {
|
|
row_meta.listeners.push(
|
|
JX.DOM.listen(
|
|
row,
|
|
'click',
|
|
'differential-inline-comment-undo',
|
|
JX.bind(this, this._onundo, row_meta)));
|
|
}
|
|
|
|
// If the row has a textarea, focus it. This allows the user to start
|
|
// typing a comment immediately after a "new", "edit", or "reply"
|
|
// action.
|
|
var textareas = JX.DOM.scry(
|
|
row,
|
|
'textarea',
|
|
'differential-inline-comment-edit-textarea');
|
|
if (textareas.length) {
|
|
var area = textareas[0];
|
|
area.focus();
|
|
|
|
var length = area.value.length;
|
|
JX.TextAreaUtils.setSelectionRange(area, length, length);
|
|
}
|
|
|
|
row = next_row;
|
|
}
|
|
|
|
JX.Stratcom.invoke('resize');
|
|
|
|
return first_meta;
|
|
},
|
|
|
|
_onsubmit: function(row, e) {
|
|
e.kill();
|
|
|
|
var handler = JX.bind(this, this._onsubmitresponse, row);
|
|
|
|
this.setLoading(true);
|
|
|
|
JX.Workflow.newFromForm(e.getTarget())
|
|
.setHandler(handler)
|
|
.start();
|
|
},
|
|
|
|
_onundo: function(row, e) {
|
|
e.kill();
|
|
|
|
this._removeRow(row);
|
|
|
|
if (row.type == 'undelete') {
|
|
var uri = this._getInlineURI();
|
|
var data = this._newRequestData('undelete');
|
|
var handler = JX.bind(this, this._onundelete);
|
|
|
|
this.setDeleted(false);
|
|
this.setLoading(true);
|
|
|
|
new JX.Request(uri, handler)
|
|
.setData(data)
|
|
.send();
|
|
}
|
|
|
|
if (row.type == 'unedit') {
|
|
if (this.getID()) {
|
|
this.edit(row.text);
|
|
} else {
|
|
this.create(row.text);
|
|
}
|
|
}
|
|
},
|
|
|
|
_onundelete: function() {
|
|
this.setLoading(false);
|
|
this._didUpdate();
|
|
},
|
|
|
|
_oncancel: function(row, e) {
|
|
e.kill();
|
|
|
|
var text = this._readText(row.node);
|
|
if (text && text.length && (text != this._originalText)) {
|
|
this._drawUneditRows(text);
|
|
}
|
|
|
|
this._removeRow(row);
|
|
this.setEditing(false);
|
|
|
|
this.setInvisible(false);
|
|
|
|
this._didUpdate(true);
|
|
},
|
|
|
|
_readText: function(row) {
|
|
var textarea;
|
|
try {
|
|
textarea = JX.DOM.find(
|
|
row,
|
|
'textarea',
|
|
'differential-inline-comment-edit-textarea');
|
|
} catch (ex) {
|
|
return null;
|
|
}
|
|
|
|
return textarea.value;
|
|
},
|
|
|
|
_onsubmitresponse: function(row, response) {
|
|
this._removeRow(row);
|
|
|
|
this.setLoading(false);
|
|
this.setInvisible(false);
|
|
this.setEditing(false);
|
|
|
|
this._onupdate(response);
|
|
},
|
|
|
|
_onupdate: function(response) {
|
|
var new_row;
|
|
if (response.markup) {
|
|
new_row = this._drawContentRows(JX.$H(response.markup).getNode()).node;
|
|
}
|
|
|
|
// TODO: Save the old row so the action it's undo-able if it was a
|
|
// delete.
|
|
var remove_old = true;
|
|
if (remove_old) {
|
|
JX.DOM.remove(this._row);
|
|
}
|
|
|
|
this.bindToRow(new_row);
|
|
|
|
this._didUpdate();
|
|
},
|
|
|
|
_didUpdate: function(local_only) {
|
|
// After making changes to inline comments, refresh the transaction
|
|
// preview at the bottom of the page.
|
|
if (!local_only) {
|
|
this.getChangeset().getChangesetList().redrawPreview();
|
|
}
|
|
|
|
this.updateObjective();
|
|
|
|
this.getChangeset().getChangesetList().redrawCursor();
|
|
this.getChangeset().getChangesetList().resetHover();
|
|
|
|
// Emit a resize event so that UI elements like the keyboard focus
|
|
// reticle can redraw properly.
|
|
JX.Stratcom.invoke('resize');
|
|
},
|
|
|
|
_redraw: function() {
|
|
var is_invisible = (this._isInvisible || this._isDeleted);
|
|
var is_loading = (this._isLoading);
|
|
|
|
var row = this._row;
|
|
JX.DOM.alterClass(row, 'differential-inline-hidden', is_invisible);
|
|
JX.DOM.alterClass(row, 'differential-inline-loading', is_loading);
|
|
},
|
|
|
|
_removeRow: function(row) {
|
|
JX.DOM.remove(row.node);
|
|
for (var ii = 0; ii < row.listeners.length; ii++) {
|
|
row.listeners[ii].remove();
|
|
}
|
|
},
|
|
|
|
_getInlineURI: function() {
|
|
var changeset = this.getChangeset();
|
|
var list = changeset.getChangesetList();
|
|
return list.getInlineURI();
|
|
}
|
|
}
|
|
|
|
});
|