--- a/devtools/client/webconsole/components/JSTerm.js
+++ b/devtools/client/webconsole/components/JSTerm.js
@@ -177,43 +177,78 @@ class JSTerm extends Component {
enableCodeFolding: false,
gutters: [],
lineWrapping: true,
mode: Editor.modes.js,
styleActiveLine: false,
tabIndex: "0",
viewportMargin: Infinity,
extraKeys: {
- "Enter": (e, cm) => {
- if (!this.autocompletePopup.isOpen && (
- e.shiftKey || !Debugger.isCompilableUnit(this.getInputValue())
- )) {
- // shift return or incomplete statement
+ "Enter": () => {
+ // No need to handle shift + Enter as it's natively handled by CodeMirror.
+ if (
+ !this.autocompletePopup.isOpen &&
+ !Debugger.isCompilableUnit(this.getInputValue())
+ ) {
+ // incomplete statement
return "CodeMirror.Pass";
}
+ if (this._autocompletePopupNavigated &&
+ this.autocompletePopup.isOpen &&
+ this.autocompletePopup.selectedIndex > -1
+ ) {
+ return this.acceptProposedCompletion();
+ }
+
this.execute();
return null;
},
+
+ "Tab": () => {
+ // Generate a completion and accept the first proposed value.
+ if (
+ this.complete(this.COMPLETE_HINT_ONLY) &&
+ this.lastCompletion &&
+ this.acceptProposedCompletion()
+ ) {
+ return false;
+ }
+
+ if (this.hasEmptyInput()) {
+ this.editor.codeMirror.getInputField().blur();
+ return false;
+ }
+
+ if (!this.editor.somethingSelected()) {
+ this.insertStringAtCursor("\t");
+ return false;
+ }
+
+ // Input is not empty and some text is selected, let the editor handle this.
+ return true;
+ },
+
"Up": () => {
let inputUpdated;
if (this.autocompletePopup.isOpen) {
inputUpdated = this.complete(this.COMPLETE_BACKWARD);
if (inputUpdated) {
this._autocompletePopupNavigated = true;
}
} else if (this.canCaretGoPrevious()) {
inputUpdated = this.historyPeruse(HISTORY_BACK);
}
if (!inputUpdated) {
return "CodeMirror.Pass";
}
return null;
},
+
"Down": () => {
let inputUpdated;
if (this.autocompletePopup.isOpen) {
inputUpdated = this.complete(this.COMPLETE_FORWARD);
if (inputUpdated) {
this._autocompletePopupNavigated = true;
}
} else if (this.canCaretGoNext()) {
@@ -221,16 +256,46 @@ class JSTerm extends Component {
}
if (!inputUpdated) {
return "CodeMirror.Pass";
}
return null;
},
+ "Left": () => {
+ if (this.autocompletePopup.isOpen || this.lastCompletion.value) {
+ this.clearCompletion();
+ }
+ return "CodeMirror.Pass";
+ },
+
+ "Right": () => {
+ const haveSuggestion =
+ this.autocompletePopup.isOpen || this.lastCompletion.value;
+ const useCompletion =
+ this.canCaretGoNext() || this._autocompletePopupNavigated;
+
+ if (
+ haveSuggestion &&
+ useCompletion &&
+ this.complete(this.COMPLETE_HINT_ONLY) &&
+ this.lastCompletion.value &&
+ this.acceptProposedCompletion()
+ ) {
+ return null;
+ }
+
+ if (this.autocompletePopup.isOpen) {
+ this.clearCompletion();
+ }
+
+ return "CodeMirror.Pass";
+ },
+
"Ctrl-N": () => {
// Control-N differs from down arrow: it ignores autocomplete state.
// Note that we preserve the default 'down' navigation within
// multiline text.
if (
Services.appinfo.OS === "Darwin"
&& this.canCaretGoNext()
&& this.historyPeruse(HISTORY_FORWARD)
@@ -238,33 +303,84 @@ class JSTerm extends Component {
return null;
}
this.clearCompletion();
return "CodeMirror.Pass";
},
"Ctrl-P": () => {
- // Control-N differs from down arrow: it ignores autocomplete state.
- // Note that we preserve the default 'down' navigation within
+ // Control-P differs from up arrow: it ignores autocomplete state.
+ // Note that we preserve the default 'up' navigation within
// multiline text.
if (
Services.appinfo.OS === "Darwin"
&& this.canCaretGoPrevious()
&& this.historyPeruse(HISTORY_BACK)
) {
return null;
}
this.clearCompletion();
return "CodeMirror.Pass";
+ },
+
+ "Esc": () => {
+ if (this.autocompletePopup.isOpen) {
+ this.clearCompletion();
+ return null;
+ }
+
+ return "CodeMirror.Pass";
+ },
+
+ "PageUp": () => {
+ if (this.autocompletePopup.isOpen) {
+ if (this.complete(this.COMPLETE_PAGEUP)) {
+ this._autocompletePopupNavigated = true;
+ }
+ return null;
+ }
+
+ return "CodeMirror.Pass";
+ },
+
+ "PageDown": () => {
+ if (this.autocompletePopup.isOpen) {
+ if (this.complete(this.COMPLETE_PAGEDOWN)) {
+ this._autocompletePopupNavigated = true;
+ }
+ return null;
+ }
+
+ return "CodeMirror.Pass";
+ },
+
+ "Home": () => {
+ if (this.autocompletePopup.isOpen) {
+ this.autocompletePopup.selectedIndex = 0;
+ return null;
+ }
+
+ return "CodeMirror.Pass";
+ },
+
+ "End": () => {
+ if (this.autocompletePopup.isOpen) {
+ this.autocompletePopup.selectedIndex =
+ this.autocompletePopup.itemCount - 1;
+ return null;
+ }
+
+ return "CodeMirror.Pass";
}
}
});
+ this.editor.on("change", this._inputEventHandler);
this.editor.appendToLocalElement(this.node);
const cm = this.editor.codeMirror;
cm.on("paste", (_, event) => this.props.onPaste(event));
cm.on("drop", (_, event) => this.props.onPaste(event));
}
} else if (this.inputNode) {
this.inputNode.addEventListener("keypress", this._keyPress);
this.inputNode.addEventListener("input", this._inputEventHandler);
@@ -571,22 +687,23 @@ class JSTerm extends Component {
* Sets the value of the input field (command line), and resizes the field to
* fit its contents. This method is preferred over setting "inputNode.value"
* directly, because it correctly resizes the field.
*
* @param string newValue
* The new value to set.
* @returns void
*/
- setInputValue(newValue) {
+ setInputValue(newValue = "") {
if (this.props.codeMirrorEnabled) {
if (this.editor) {
this.editor.setText(newValue);
// Set the cursor at the end of the input.
this.editor.setCursor({line: this.editor.getDoc().lineCount(), ch: 0});
+ this.editor.setAutoCompletionText();
}
} else {
if (!this.inputNode) {
return;
}
this.inputNode.value = newValue;
this.completeNode.value = "";
@@ -598,31 +715,40 @@ class JSTerm extends Component {
}
/**
* Gets the value from the input field
* @returns string
*/
getInputValue() {
if (this.props.codeMirrorEnabled) {
- return this.editor.getText() || "";
+ return this.editor ? this.editor.getText() || "" : "";
}
return this.inputNode ? this.inputNode.value || "" : "";
}
+ getSelectionStart() {
+ if (this.props.codeMirrorEnabled) {
+ return this.getInputValueBeforeCursor().length;
+ }
+
+ return this.inputNode ? this.inputNode.selectionStart : null;
+ }
+
/**
* The inputNode "input" and "keyup" event handler.
* @private
*/
_inputEventHandler() {
- if (this.lastInputValue != this.getInputValue()) {
+ const value = this.getInputValue();
+ if (this.lastInputValue !== value) {
this.resizeInput();
this.complete(this.COMPLETE_HINT_ONLY);
- this.lastInputValue = this.getInputValue();
+ this.lastInputValue = value;
}
}
/**
* The window "blur" event handler.
* @private
*/
_blurEventHandler() {
@@ -637,17 +763,16 @@ class JSTerm extends Component {
*
* @private
* @param Event event
*/
_keyPress(event) {
const inputNode = this.inputNode;
const inputValue = this.getInputValue();
let inputUpdated = false;
-
if (event.ctrlKey) {
switch (event.charCode) {
case 101:
// control-e
if (Services.appinfo.OS == "WINNT") {
break;
}
let lineEndPos = inputValue.length;
@@ -994,39 +1119,40 @@ class JSTerm extends Component {
* completed text.
* @param function callback
* Optional function invoked when the autocomplete properties are
* updated.
* @returns boolean true if there existed a completion for the current input,
* or false otherwise.
*/
complete(type, callback) {
- const inputNode = this.inputNode;
const inputValue = this.getInputValue();
const frameActor = this.getFrameActor(this.SELECTED_FRAME);
-
// If the inputNode has no value, then don't try to complete on it.
if (!inputValue) {
this.clearCompletion();
callback && callback(this);
this.emit("autocomplete-updated");
return false;
}
+ const {editor, inputNode} = this;
// Only complete if the selection is empty.
- if (inputNode.selectionStart != inputNode.selectionEnd) {
+ if (
+ (inputNode && inputNode.selectionStart != inputNode.selectionEnd) ||
+ (editor && editor.getSelection())
+ ) {
this.clearCompletion();
this.callback && callback(this);
this.emit("autocomplete-updated");
return false;
}
// Update the completion results.
- if (this.lastCompletion.value != inputValue ||
- frameActor != this._lastFrameActorId) {
+ if (this.lastCompletion.value != inputValue || frameActor != this._lastFrameActorId) {
this._updateCompletionResult(type, callback);
return false;
}
const popup = this.autocompletePopup;
let accepted = false;
if (type != this.COMPLETE_HINT_ONLY && popup.itemCount == 1) {
@@ -1053,135 +1179,155 @@ class JSTerm extends Component {
*
* @private
* @param int type
* Completion type. See this.complete() for details.
* @param function [callback]
* Optional, function to invoke when completion results are received.
*/
_updateCompletionResult(type, callback) {
+ const value = this.getInputValue();
const frameActor = this.getFrameActor(this.SELECTED_FRAME);
- if (this.lastCompletion.value == this.getInputValue() &&
- frameActor == this._lastFrameActorId) {
+ if (this.lastCompletion.value == value && frameActor == this._lastFrameActorId) {
return;
}
const requestId = gSequenceId();
- const cursor = this.inputNode.selectionStart;
- const input = this.getInputValue().substring(0, cursor);
+ const cursor = this.getSelectionStart();
+ const input = value.substring(0, cursor);
const cache = this._autocompleteCache;
// If the current input starts with the previous input, then we already
// have a list of suggestions and we just need to filter the cached
- // suggestions. When the current input ends with a non-alphanumeri;
+ // suggestions. When the current input ends with a non-alphanumeric
// character we ask the server again for suggestions.
// Check if last character is non-alphanumeric
if (!/[a-zA-Z0-9]$/.test(input) || frameActor != this._lastFrameActorId) {
this._autocompleteQuery = null;
this._autocompleteCache = null;
}
-
if (this._autocompleteQuery && input.startsWith(this._autocompleteQuery)) {
let filterBy = input;
// Find the last non-alphanumeric other than _ or $ if it exists.
const lastNonAlpha = input.match(/[^a-zA-Z0-9_$][a-zA-Z0-9_$]*$/);
// If input contains non-alphanumerics, use the part after the last one
// to filter the cache
if (lastNonAlpha) {
filterBy = input.substring(input.lastIndexOf(lastNonAlpha) + 1);
}
- const newList = cache.sort().filter(function(l) {
- return l.startsWith(filterBy);
- });
+ const newList = cache.sort().filter(l => l.startsWith(filterBy));
this.lastCompletion = {
requestId: null,
completionType: type,
value: null,
};
const response = { matches: newList, matchProp: filterBy };
this._receiveAutocompleteProperties(null, callback, response);
return;
}
-
this._lastFrameActorId = frameActor;
this.lastCompletion = {
requestId: requestId,
completionType: type,
value: null,
};
const autocompleteCallback =
this._receiveAutocompleteProperties.bind(this, requestId, callback);
this.webConsoleClient.autocomplete(
input, cursor, autocompleteCallback, frameActor);
}
+ getInputValueBeforeCursor() {
+ if (this.editor) {
+ return this.editor.getDoc().getRange({line: 0, ch: 0}, this.editor.getCursor());
+ }
+
+ if (this.inputNode) {
+ const cursor = this.inputNode.selectionStart;
+ return this.getInputValue().substring(0, cursor);
+ }
+
+ return null;
+ }
+
/**
* Handler for the autocompletion results. This method takes
* the completion result received from the server and updates the UI
* accordingly.
*
* @param number requestId
* Request ID.
* @param function [callback=null]
* Optional, function to invoke when the completion result is received.
* @param object message
* The JSON message which holds the completion results received from
* the content process.
*/
_receiveAutocompleteProperties(requestId, callback, message) {
- const inputNode = this.inputNode;
const inputValue = this.getInputValue();
if (this.lastCompletion.value == inputValue ||
requestId != this.lastCompletion.requestId) {
return;
}
// Cache whatever came from the server if the last char is
// alphanumeric or '.'
- const cursor = inputNode.selectionStart;
- const inputUntilCursor = inputValue.substring(0, cursor);
+ const inputUntilCursor = this.getInputValueBeforeCursor();
if (requestId != null && /[a-zA-Z0-9.]$/.test(inputUntilCursor)) {
this._autocompleteCache = message.matches;
this._autocompleteQuery = inputUntilCursor;
}
const matches = message.matches;
const lastPart = message.matchProp;
if (!matches.length) {
this.clearCompletion();
callback && callback(this);
this.emit("autocomplete-updated");
return;
}
- const items = matches.reverse().map(function(match) {
- return { preLabel: lastPart, label: match };
- });
-
+ const items = matches.reverse().map(match => ({ preLabel: lastPart, label: match }));
const popup = this.autocompletePopup;
popup.setItems(items);
const completionType = this.lastCompletion.completionType;
this.lastCompletion = {
value: inputValue,
matchProp: lastPart,
};
if (items.length > 1 && !popup.isOpen) {
- const str = this.getInputValue().substr(0, this.inputNode.selectionStart);
- const offset = str.length - (str.lastIndexOf("\n") + 1) - lastPart.length;
- const x = offset * this._inputCharWidth;
- popup.openPopup(inputNode, x + this._chevronWidth);
- this._autocompletePopupNavigated = false;
+ let popupAlignElement;
+ let xOffset;
+ let yOffset;
+
+ if (this.editor) {
+ popupAlignElement = this.node.querySelector(".CodeMirror-cursor");
+ // We need to show the popup at the ".".
+ xOffset = -1 * lastPart.length * this._inputCharWidth;
+ yOffset = 4;
+ } else if (this.inputNode) {
+ const offset = inputUntilCursor.length -
+ (inputUntilCursor.lastIndexOf("\n") + 1) -
+ lastPart.length;
+ xOffset = (offset * this._inputCharWidth) + this._chevronWidth;
+ popupAlignElement = this.inputNode;
+ }
+
+ if (popupAlignElement) {
+ popup.openPopup(popupAlignElement, xOffset, yOffset);
+ this._autocompletePopupNavigated = false;
+ }
} else if (items.length < 2 && popup.isOpen) {
popup.hidePopup();
this._autocompletePopupNavigated = false;
}
if (items.length == 1) {
popup.selectedIndex = 0;
}
@@ -1196,17 +1342,17 @@ class JSTerm extends Component {
}
callback && callback(this);
this.emit("autocomplete-updated");
}
onAutocompleteSelect() {
// Render the suggestion only if the cursor is at the end of the input.
- if (this.inputNode.selectionStart != this.getInputValue().length) {
+ if (this.getSelectionStart() != this.getInputValue().length) {
return;
}
const currentItem = this.autocompletePopup.selectedItem;
if (currentItem && this.lastCompletion.value) {
const suffix =
currentItem.label.substring(this.lastCompletion.matchProp.length);
this.updateCompleteNode(suffix);
@@ -1223,19 +1369,21 @@ class JSTerm extends Component {
this.lastCompletion = { value: null };
this.updateCompleteNode("");
if (this.autocompletePopup) {
this.autocompletePopup.clearItems();
if (this.autocompletePopup.isOpen) {
// Trigger a blur/focus of the JSTerm input to force screen readers to read the
// value again.
- this.inputNode.blur();
+ if (this.inputNode) {
+ this.inputNode.blur();
+ }
this.autocompletePopup.once("popup-closed", () => {
- this.inputNode.focus();
+ this.focus();
});
this.autocompletePopup.hidePopup();
this._autocompletePopupNavigated = false;
}
}
}
/**
@@ -1248,53 +1396,69 @@ class JSTerm extends Component {
acceptProposedCompletion() {
let updated = false;
const currentItem = this.autocompletePopup.selectedItem;
if (currentItem && this.lastCompletion.value) {
this.insertStringAtCursor(
currentItem.label.substring(this.lastCompletion.matchProp.length)
);
+
updated = true;
}
this.clearCompletion();
return updated;
}
/**
* Insert a string into the console at the cursor location,
* moving the cursor to the end of the string.
*
* @param string str
*/
insertStringAtCursor(str) {
- const cursor = this.inputNode.selectionStart;
const value = this.getInputValue();
- this.setInputValue(value.substr(0, cursor) +
- str + value.substr(cursor));
- const newCursor = cursor + str.length;
- this.inputNode.selectionStart = this.inputNode.selectionEnd = newCursor;
+ const prefix = this.getInputValueBeforeCursor();
+ const suffix = value.replace(prefix, "");
+
+ // We need to retrieve the cursor before setting the new value.
+ const editorCursor = this.editor && this.editor.getCursor();
+
+ this.setInputValue(prefix + str + suffix);
+
+ if (this.inputNode) {
+ const newCursor = prefix.length + str.length;
+ this.inputNode.selectionStart = this.inputNode.selectionEnd = newCursor;
+ } else if (this.editor) {
+ // Set the cursor on the same line it was already at, after the autocompleted text
+ this.editor.setCursor({
+ line: editorCursor.line,
+ ch: editorCursor.ch + str.length
+ });
+ }
}
/**
* Update the node that displays the currently selected autocomplete proposal.
*
* @param string suffix
* The proposed suffix for the inputNode value.
*/
updateCompleteNode(suffix) {
- if (!this.completeNode) {
- return;
+ if (this.completeNode) {
+ // completion prefix = input, with non-control chars replaced by spaces
+ const prefix = suffix ? this.getInputValue().replace(/[\S]/g, " ") : "";
+ this.completeNode.value = prefix + suffix;
}
- // completion prefix = input, with non-control chars replaced by spaces
- const prefix = suffix ? this.getInputValue().replace(/[\S]/g, " ") : "";
- this.completeNode.value = prefix + suffix;
+ if (this.editor) {
+ this.editor.setAutoCompletionText(suffix);
+ }
}
/**
* Calculates and returns the width of a single character of the input box.
* This will be used in opening the popup at the correct offset.
*
* @returns {Number|null}: Width off the "x" char, or null if the input does not exist.
*/