diff --git a/package.json b/package.json
index 112b8e85616..d22e5727305 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"@types/enzyme-adapter-react-16": "1.0.5",
"@types/expect-puppeteer": "3.3.1",
"@types/file-saver": "2.0.1",
+ "@types/is-hotkey": "0.1.1",
"@types/jest": "24.0.13",
"@types/jquery": "1.10.35",
"@types/lodash": "4.14.123",
@@ -203,6 +204,7 @@
"fast-text-encoding": "^1.0.0",
"file-saver": "1.3.8",
"immutable": "3.8.2",
+ "is-hotkey": "0.1.4",
"jquery": "3.4.1",
"lodash": "4.17.14",
"marked": "0.6.2",
diff --git a/public/app/features/explore/QueryField.test.tsx b/public/app/features/explore/QueryField.test.tsx
index e58127345f0..378115ec9fd 100644
--- a/public/app/features/explore/QueryField.test.tsx
+++ b/public/app/features/explore/QueryField.test.tsx
@@ -23,7 +23,7 @@ describe('', () => {
const wrapper = shallow();
const instance = wrapper.instance() as QueryField;
instance.executeOnChangeAndRunQueries = jest.fn();
- const handleEnterAndTabKeySpy = jest.spyOn(instance, 'handleEnterAndTabKey');
+ const handleEnterAndTabKeySpy = jest.spyOn(instance, 'handleEnterKey');
instance.onKeyDown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, {});
expect(handleEnterAndTabKeySpy).toBeCalled();
expect(instance.executeOnChangeAndRunQueries).toBeCalled();
diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx
index 9ad5d57f6f4..28fff337948 100644
--- a/public/app/features/explore/QueryField.tsx
+++ b/public/app/features/explore/QueryField.tsx
@@ -2,12 +2,13 @@ import _ from 'lodash';
import React, { Context } from 'react';
import ReactDOM from 'react-dom';
// @ts-ignore
-import { Change, Value } from 'slate';
+import { Change, Range, Value, Block } from 'slate';
// @ts-ignore
import { Editor } from 'slate-react';
// @ts-ignore
import Plain from 'slate-plain-serializer';
import classnames from 'classnames';
+import { isKeyHotkey } from 'is-hotkey';
import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
@@ -19,6 +20,14 @@ import { makeFragment, makeValue } from '@grafana/ui';
export const TYPEAHEAD_DEBOUNCE = 100;
export const HIGHLIGHT_WAIT = 500;
+const SLATE_TAB = ' ';
+const isIndentLeftHotkey = isKeyHotkey('mod+[');
+const isIndentRightHotkey = isKeyHotkey('mod+]');
+const isSelectLeftHotkey = isKeyHotkey('shift+left');
+const isSelectRightHotkey = isKeyHotkey('shift+right');
+const isSelectUpHotkey = isKeyHotkey('shift+up');
+const isSelectDownHotkey = isKeyHotkey('shift+down');
+const isSelectLineHotkey = isKeyHotkey('mod+l');
function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
// Flatten suggestion groups
@@ -305,8 +314,7 @@ export class QueryField extends React.PureComponent {
- const { typeaheadIndex, suggestions } = this.state;
+ handleEnterKey = (event: KeyboardEvent, change: Change) => {
event.preventDefault();
if (event.shiftKey) {
@@ -315,7 +323,16 @@ export class QueryField extends React.PureComponent {
+ const { typeaheadIndex, suggestions } = this.state;
+ event.preventDefault();
+
+ if (!suggestions || suggestions.length === 0) {
return undefined;
}
@@ -326,9 +343,132 @@ export class QueryField extends React.PureComponent {
+ const {
+ startBlock,
+ endBlock,
+ selection: { startOffset, startKey, endOffset, endKey },
+ } = change.value;
+
+ if (this.menuEl) {
+ this.selectSuggestion(change);
+ return;
+ }
+
+ const first = startBlock.getFirstText();
+
+ const startBlockIsSelected =
+ startOffset === 0 && startKey === first.key && endOffset === first.text.length && endKey === first.key;
+
+ if (startBlockIsSelected || !startBlock.equals(endBlock)) {
+ this.handleIndent(change, 'right');
+ } else {
+ change.insertText(SLATE_TAB);
+ }
+ };
+
+ handleIndent = (change: Change, indentDirection: 'left' | 'right') => {
+ const curSelection = change.value.selection;
+ const selectedBlocks = change.value.document.getBlocksAtRange(curSelection);
+
+ if (indentDirection === 'left') {
+ for (const block of selectedBlocks) {
+ const blockWhitespace = block.text.length - block.text.trimLeft().length;
+
+ const rangeProperties = {
+ anchorKey: block.getFirstText().key,
+ anchorOffset: blockWhitespace,
+ focusKey: block.getFirstText().key,
+ focusOffset: blockWhitespace,
+ };
+
+ // @ts-ignore
+ const whitespaceToDelete = Range.create(rangeProperties);
+
+ change.deleteBackwardAtRange(whitespaceToDelete, Math.min(SLATE_TAB.length, blockWhitespace));
+ }
+ } else {
+ const { startText } = change.value;
+ const textBeforeCaret = startText.text.slice(0, curSelection.startOffset);
+ const isWhiteSpace = /^\s*$/.test(textBeforeCaret);
+
+ for (const block of selectedBlocks) {
+ change.insertTextByKey(block.getFirstText().key, 0, SLATE_TAB);
+ }
+
+ if (isWhiteSpace) {
+ change.moveStart(-SLATE_TAB.length);
+ }
+ }
+ };
+
+ handleSelectVertical = (change: Change, direction: 'up' | 'down') => {
+ const { focusBlock } = change.value;
+ const adjacentBlock =
+ direction === 'up'
+ ? change.value.document.getPreviousBlock(focusBlock.key)
+ : change.value.document.getNextBlock(focusBlock.key);
+
+ if (!adjacentBlock) {
+ return true;
+ }
+ const adjacentText = adjacentBlock.getFirstText();
+ change.moveFocusTo(adjacentText.key, Math.min(change.value.anchorOffset, adjacentText.text.length)).focus();
+ return true;
+ };
+
+ handleSelectUp = (change: Change) => this.handleSelectVertical(change, 'up');
+
+ handleSelectDown = (change: Change) => this.handleSelectVertical(change, 'down');
+
onKeyDown = (event: KeyboardEvent, change: Change) => {
const { typeaheadIndex } = this.state;
+ // Shortcuts
+ if (isIndentLeftHotkey(event)) {
+ event.preventDefault();
+ this.handleIndent(change, 'left');
+ return true;
+ } else if (isIndentRightHotkey(event)) {
+ event.preventDefault();
+ this.handleIndent(change, 'right');
+ return true;
+ } else if (isSelectLeftHotkey(event)) {
+ event.preventDefault();
+ if (change.value.focusOffset > 0) {
+ change.moveFocus(-1);
+ }
+ return true;
+ } else if (isSelectRightHotkey(event)) {
+ event.preventDefault();
+ if (change.value.focusOffset < change.value.startText.text.length) {
+ change.moveFocus(1);
+ }
+ return true;
+ } else if (isSelectUpHotkey(event)) {
+ event.preventDefault();
+ this.handleSelectUp(change);
+ return true;
+ } else if (isSelectDownHotkey(event)) {
+ event.preventDefault();
+ this.handleSelectDown(change);
+ return true;
+ } else if (isSelectLineHotkey(event)) {
+ event.preventDefault();
+ const { focusBlock, document } = change.value;
+
+ change.moveAnchorToStartOfBlock(focusBlock.key);
+
+ const nextBlock = document.getNextBlock(focusBlock.key);
+ if (nextBlock) {
+ change.moveFocusToStartOfNextBlock();
+ } else {
+ change.moveFocusToEndOfText();
+ }
+
+ return true;
+ }
+
switch (event.key) {
case 'Escape': {
if (this.menuEl) {
@@ -348,10 +488,13 @@ export class QueryField extends React.PureComponent {
+ event.preventDefault();
+ const selectedBlocks = change.value.document.getBlocksAtRange(change.value.selection);
+ event.clipboardData.setData('Text', selectedBlocks.map((block: Block) => block.text).join('\n'));
+
+ return true;
+ };
+
handlePaste = (event: ClipboardEvent, change: Editor) => {
+ event.preventDefault();
const pastedValue = event.clipboardData.getData('Text');
- const newValue = change.value.change().insertText(pastedValue);
- this.onChange(newValue);
+ const lines = pastedValue.split('\n');
+
+ if (lines.length) {
+ change.insertText(lines[0]);
+ for (const line of lines.slice(1)) {
+ change.splitBlock().insertText(line);
+ }
+ }
+
+ return true;
+ };
+
+ handleCut = (event: ClipboardEvent, change: Editor) => {
+ this.handleCopy(event, change);
+ change.deleteAtRange(change.value.selection);
return true;
};
@@ -499,7 +664,9 @@ export class QueryField extends React.PureComponent