|
|
@@ -1,55 +1,36 @@
|
|
|
import _ from 'lodash';
|
|
|
import React, { Context } from 'react';
|
|
|
-import ReactDOM from 'react-dom';
|
|
|
-// @ts-ignore
|
|
|
-import { Change, Range, Value, Block } from 'slate';
|
|
|
-// @ts-ignore
|
|
|
-import { Editor } from 'slate-react';
|
|
|
-// @ts-ignore
|
|
|
+
|
|
|
+import { Value, Editor as CoreEditor } from 'slate';
|
|
|
+import { Editor, Plugin } from '@grafana/slate-react';
|
|
|
import Plain from 'slate-plain-serializer';
|
|
|
import classnames from 'classnames';
|
|
|
-// @ts-ignore
|
|
|
-import { isKeyHotkey } from 'is-hotkey';
|
|
|
|
|
|
-import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
|
|
|
+import { CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
|
|
|
|
|
|
import ClearPlugin from './slate-plugins/clear';
|
|
|
import NewlinePlugin from './slate-plugins/newline';
|
|
|
+import SelectionShortcutsPlugin from './slate-plugins/selection_shortcuts';
|
|
|
+import IndentationPlugin from './slate-plugins/indentation';
|
|
|
+import ClipboardPlugin from './slate-plugins/clipboard';
|
|
|
+import RunnerPlugin from './slate-plugins/runner';
|
|
|
+import SuggestionsPlugin, { SuggestionsState } from './slate-plugins/suggestions';
|
|
|
|
|
|
-import { TypeaheadWithTheme } from './Typeahead';
|
|
|
-import { makeFragment, makeValue } from '@grafana/ui';
|
|
|
+import { Typeahead } from './Typeahead';
|
|
|
|
|
|
-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
|
|
|
- const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
|
|
|
- const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
|
|
|
- return flattenedSuggestions[correctedIndex];
|
|
|
-}
|
|
|
+import { makeValue, SCHEMA } from '@grafana/ui';
|
|
|
|
|
|
-function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
|
|
|
- return suggestions && suggestions.length > 0;
|
|
|
-}
|
|
|
+export const HIGHLIGHT_WAIT = 500;
|
|
|
|
|
|
export interface QueryFieldProps {
|
|
|
- additionalPlugins?: any[];
|
|
|
+ additionalPlugins?: Plugin[];
|
|
|
cleanText?: (text: string) => string;
|
|
|
disabled?: boolean;
|
|
|
initialQuery: string | null;
|
|
|
onRunQuery?: () => void;
|
|
|
onChange?: (value: string) => void;
|
|
|
- onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
|
|
|
- onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string;
|
|
|
+ onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
|
|
|
+ onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string;
|
|
|
placeholder?: string;
|
|
|
portalOrigin?: string;
|
|
|
syntax?: string;
|
|
|
@@ -59,20 +40,19 @@ export interface QueryFieldProps {
|
|
|
export interface QueryFieldState {
|
|
|
suggestions: CompletionItemGroup[];
|
|
|
typeaheadContext: string | null;
|
|
|
- typeaheadIndex: number;
|
|
|
typeaheadPrefix: string;
|
|
|
typeaheadText: string;
|
|
|
- value: any;
|
|
|
+ value: Value;
|
|
|
lastExecutedValue: Value;
|
|
|
}
|
|
|
|
|
|
export interface TypeaheadInput {
|
|
|
- editorNode: Element;
|
|
|
prefix: string;
|
|
|
selection?: Selection;
|
|
|
text: string;
|
|
|
value: Value;
|
|
|
- wrapperNode: Element;
|
|
|
+ wrapperClasses: string[];
|
|
|
+ labelKey?: string;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -83,23 +63,35 @@ export interface TypeaheadInput {
|
|
|
*/
|
|
|
export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
|
|
|
menuEl: HTMLElement | null;
|
|
|
- plugins: any[];
|
|
|
- resetTimer: any;
|
|
|
+ plugins: Plugin[];
|
|
|
+ resetTimer: NodeJS.Timer;
|
|
|
mounted: boolean;
|
|
|
- updateHighlightsTimer: any;
|
|
|
+ updateHighlightsTimer: Function;
|
|
|
+ editor: Editor;
|
|
|
+ typeaheadRef: Typeahead;
|
|
|
|
|
|
constructor(props: QueryFieldProps, context: Context<any>) {
|
|
|
super(props, context);
|
|
|
|
|
|
this.updateHighlightsTimer = _.debounce(this.updateLogsHighlights, HIGHLIGHT_WAIT);
|
|
|
|
|
|
+ const { onTypeahead, cleanText, portalOrigin, onWillApplySuggestion } = props;
|
|
|
+
|
|
|
// Base plugins
|
|
|
- this.plugins = [ClearPlugin(), NewlinePlugin(), ...(props.additionalPlugins || [])].filter(p => p);
|
|
|
+ this.plugins = [
|
|
|
+ SuggestionsPlugin({ onTypeahead, cleanText, portalOrigin, onWillApplySuggestion, component: this }),
|
|
|
+ ClearPlugin(),
|
|
|
+ RunnerPlugin({ handler: this.executeOnChangeAndRunQueries }),
|
|
|
+ NewlinePlugin(),
|
|
|
+ SelectionShortcutsPlugin(),
|
|
|
+ IndentationPlugin(),
|
|
|
+ ClipboardPlugin(),
|
|
|
+ ...(props.additionalPlugins || []),
|
|
|
+ ].filter(p => p);
|
|
|
|
|
|
this.state = {
|
|
|
suggestions: [],
|
|
|
typeaheadContext: null,
|
|
|
- typeaheadIndex: 0,
|
|
|
typeaheadPrefix: '',
|
|
|
typeaheadText: '',
|
|
|
value: makeValue(props.initialQuery || '', props.syntax),
|
|
|
@@ -109,7 +101,6 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
|
|
|
|
|
|
componentDidMount() {
|
|
|
this.mounted = true;
|
|
|
- this.updateMenu();
|
|
|
}
|
|
|
|
|
|
componentWillUnmount() {
|
|
|
@@ -119,7 +110,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
|
|
|
|
|
|
componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
|
|
|
const { initialQuery, syntax } = this.props;
|
|
|
- const { value, suggestions } = this.state;
|
|
|
+ const { value } = this.state;
|
|
|
|
|
|
// if query changed from the outside
|
|
|
if (initialQuery !== prevProps.initialQuery) {
|
|
|
@@ -128,26 +119,17 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
|
|
|
this.setState({ value: makeValue(initialQuery || '', syntax) });
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- // Only update menu location when suggestion existence or text/selection changed
|
|
|
- if (value !== prevState.value || hasSuggestions(suggestions) !== hasSuggestions(prevState.suggestions)) {
|
|
|
- this.updateMenu();
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
UNSAFE_componentWillReceiveProps(nextProps: QueryFieldProps) {
|
|
|
if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
|
|
|
// Need a bogus edit to re-render the editor after syntax has fully loaded
|
|
|
- const change = this.state.value
|
|
|
- .change()
|
|
|
- .insertText(' ')
|
|
|
- .deleteBackward();
|
|
|
-
|
|
|
- this.onChange(change, true);
|
|
|
+ const editor = this.editor.insertText(' ').deleteBackward(1);
|
|
|
+ this.onChange(editor.value, true);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- onChange = ({ value }: Change, invokeParentOnValueChanged?: boolean) => {
|
|
|
+ onChange = (value: Value, invokeParentOnValueChanged?: boolean) => {
|
|
|
const documentChanged = value.document !== this.state.value.document;
|
|
|
const prevValue = this.state.value;
|
|
|
|
|
|
@@ -163,14 +145,6 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
-
|
|
|
- // Show suggest menu on text input
|
|
|
- if (documentChanged && value.selection.isCollapsed) {
|
|
|
- // Need one paint to allow DOM-based typeahead rules to work
|
|
|
- window.requestAnimationFrame(this.handleTypeahead);
|
|
|
- } else if (!this.resetTimer) {
|
|
|
- this.resetTypeahead();
|
|
|
- }
|
|
|
};
|
|
|
|
|
|
updateLogsHighlights = () => {
|
|
|
@@ -194,475 +168,18 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- handleTypeahead = _.debounce(async () => {
|
|
|
- const selection = window.getSelection();
|
|
|
- const { cleanText, onTypeahead } = this.props;
|
|
|
- const { value } = this.state;
|
|
|
-
|
|
|
- if (onTypeahead && selection.anchorNode) {
|
|
|
- const wrapperNode = selection.anchorNode.parentElement;
|
|
|
- const editorNode = wrapperNode.closest('.slate-query-field');
|
|
|
- if (!editorNode || this.state.value.isBlurred) {
|
|
|
- // Not inside this editor
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const range = selection.getRangeAt(0);
|
|
|
- const offset = range.startOffset;
|
|
|
- const text = selection.anchorNode.textContent;
|
|
|
- let prefix = text.substr(0, offset);
|
|
|
-
|
|
|
- // Label values could have valid characters erased if `cleanText()` is
|
|
|
- // blindly applied, which would undesirably interfere with suggestions
|
|
|
- const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/);
|
|
|
- if (labelValueMatch) {
|
|
|
- prefix = labelValueMatch[1];
|
|
|
- } else if (cleanText) {
|
|
|
- prefix = cleanText(prefix);
|
|
|
- }
|
|
|
-
|
|
|
- const { suggestions, context, refresher } = onTypeahead({
|
|
|
- editorNode,
|
|
|
- prefix,
|
|
|
- selection,
|
|
|
- text,
|
|
|
- value,
|
|
|
- wrapperNode,
|
|
|
- });
|
|
|
-
|
|
|
- let filteredSuggestions = suggestions
|
|
|
- .map(group => {
|
|
|
- if (group.items) {
|
|
|
- if (prefix) {
|
|
|
- // Filter groups based on prefix
|
|
|
- if (!group.skipFilter) {
|
|
|
- group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
|
|
|
- if (group.prefixMatch) {
|
|
|
- group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) === 0);
|
|
|
- } else {
|
|
|
- group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) > -1);
|
|
|
- }
|
|
|
- }
|
|
|
- // Filter out the already typed value (prefix) unless it inserts custom text
|
|
|
- group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
|
|
|
- }
|
|
|
-
|
|
|
- if (!group.skipSort) {
|
|
|
- group.items = _.sortBy(group.items, (item: CompletionItem) => item.sortText || item.label);
|
|
|
- }
|
|
|
- }
|
|
|
- return group;
|
|
|
- })
|
|
|
- .filter(group => group.items && group.items.length > 0); // Filter out empty groups
|
|
|
-
|
|
|
- // Keep same object for equality checking later
|
|
|
- if (_.isEqual(filteredSuggestions, this.state.suggestions)) {
|
|
|
- filteredSuggestions = this.state.suggestions;
|
|
|
- }
|
|
|
-
|
|
|
- this.setState(
|
|
|
- {
|
|
|
- suggestions: filteredSuggestions,
|
|
|
- typeaheadPrefix: prefix,
|
|
|
- typeaheadContext: context,
|
|
|
- typeaheadText: text,
|
|
|
- },
|
|
|
- () => {
|
|
|
- if (refresher) {
|
|
|
- refresher.then(this.handleTypeahead).catch(e => console.error(e));
|
|
|
- }
|
|
|
- }
|
|
|
- );
|
|
|
- }
|
|
|
- }, TYPEAHEAD_DEBOUNCE);
|
|
|
-
|
|
|
- applyTypeahead(change: Change, suggestion: CompletionItem): Change {
|
|
|
- const { cleanText, onWillApplySuggestion, syntax } = this.props;
|
|
|
- const { typeaheadPrefix, typeaheadText } = this.state;
|
|
|
- let suggestionText = suggestion.insertText || suggestion.label;
|
|
|
- const preserveSuffix = suggestion.kind === 'function';
|
|
|
- const move = suggestion.move || 0;
|
|
|
-
|
|
|
- if (onWillApplySuggestion) {
|
|
|
- suggestionText = onWillApplySuggestion(suggestionText, { ...this.state });
|
|
|
- }
|
|
|
-
|
|
|
- this.resetTypeahead();
|
|
|
-
|
|
|
- // Remove the current, incomplete text and replace it with the selected suggestion
|
|
|
- const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
|
|
|
- const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
|
|
|
- const suffixLength = text.length - typeaheadPrefix.length;
|
|
|
- const offset = typeaheadText.indexOf(typeaheadPrefix);
|
|
|
- const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
|
|
|
- const forward = midWord && !preserveSuffix ? suffixLength + offset : 0;
|
|
|
-
|
|
|
- // If new-lines, apply suggestion as block
|
|
|
- if (suggestionText.match(/\n/)) {
|
|
|
- const fragment = makeFragment(suggestionText, syntax);
|
|
|
- return change
|
|
|
- .deleteBackward(backward)
|
|
|
- .deleteForward(forward)
|
|
|
- .insertFragment(fragment)
|
|
|
- .focus();
|
|
|
- }
|
|
|
-
|
|
|
- return change
|
|
|
- .deleteBackward(backward)
|
|
|
- .deleteForward(forward)
|
|
|
- .insertText(suggestionText)
|
|
|
- .move(move)
|
|
|
- .focus();
|
|
|
- }
|
|
|
-
|
|
|
- handleEnterKey = (event: KeyboardEvent, change: Change) => {
|
|
|
- event.preventDefault();
|
|
|
-
|
|
|
- if (event.shiftKey) {
|
|
|
- // pass through if shift is pressed
|
|
|
- return undefined;
|
|
|
- } else if (!this.menuEl) {
|
|
|
- this.executeOnChangeAndRunQueries();
|
|
|
- return true;
|
|
|
- } else {
|
|
|
- return this.selectSuggestion(change);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- selectSuggestion = (change: Change) => {
|
|
|
- const { typeaheadIndex, suggestions } = this.state;
|
|
|
- event.preventDefault();
|
|
|
-
|
|
|
- if (!suggestions || suggestions.length === 0) {
|
|
|
- return undefined;
|
|
|
- }
|
|
|
-
|
|
|
- const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
|
|
|
- const nextChange = this.applyTypeahead(change, suggestion);
|
|
|
-
|
|
|
- const insertTextOperation = nextChange.operations.find((operation: any) => operation.type === 'insert_text');
|
|
|
- return insertTextOperation ? true : undefined;
|
|
|
- };
|
|
|
-
|
|
|
- handleTabKey = (change: Change): void => {
|
|
|
- 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) {
|
|
|
- event.preventDefault();
|
|
|
- event.stopPropagation();
|
|
|
- this.resetTypeahead();
|
|
|
- return true;
|
|
|
- }
|
|
|
- break;
|
|
|
- }
|
|
|
-
|
|
|
- case ' ': {
|
|
|
- if (event.ctrlKey) {
|
|
|
- event.preventDefault();
|
|
|
- this.handleTypeahead();
|
|
|
- return true;
|
|
|
- }
|
|
|
- break;
|
|
|
- }
|
|
|
-
|
|
|
- case 'Enter':
|
|
|
- return this.handleEnterKey(event, change);
|
|
|
-
|
|
|
- case 'Tab': {
|
|
|
- event.preventDefault();
|
|
|
- return this.handleTabKey(change);
|
|
|
- }
|
|
|
-
|
|
|
- case 'ArrowDown': {
|
|
|
- if (this.menuEl) {
|
|
|
- // Select next suggestion
|
|
|
- event.preventDefault();
|
|
|
- const itemsCount =
|
|
|
- this.state.suggestions.length > 0
|
|
|
- ? this.state.suggestions.reduce((totalCount, current) => totalCount + current.items.length, 0)
|
|
|
- : 0;
|
|
|
- this.setState({ typeaheadIndex: Math.min(itemsCount - 1, typeaheadIndex + 1) });
|
|
|
- }
|
|
|
- break;
|
|
|
- }
|
|
|
-
|
|
|
- case 'ArrowUp': {
|
|
|
- if (this.menuEl) {
|
|
|
- // Select previous suggestion
|
|
|
- event.preventDefault();
|
|
|
- this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
|
|
|
- }
|
|
|
- break;
|
|
|
- }
|
|
|
-
|
|
|
- default: {
|
|
|
- // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
|
|
|
- break;
|
|
|
- }
|
|
|
- }
|
|
|
- return undefined;
|
|
|
- };
|
|
|
-
|
|
|
- resetTypeahead = () => {
|
|
|
- if (this.mounted) {
|
|
|
- this.setState({ suggestions: [], typeaheadIndex: 0, typeaheadPrefix: '', typeaheadContext: null });
|
|
|
- this.resetTimer = null;
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- handleBlur = (event: FocusEvent, change: Change) => {
|
|
|
+ handleBlur = (event: Event, editor: CoreEditor, next: Function) => {
|
|
|
const { lastExecutedValue } = this.state;
|
|
|
const previousValue = lastExecutedValue ? Plain.serialize(this.state.lastExecutedValue) : null;
|
|
|
- const currentValue = Plain.serialize(change.value);
|
|
|
-
|
|
|
- // If we dont wait here, menu clicks wont work because the menu
|
|
|
- // will be gone.
|
|
|
- this.resetTimer = setTimeout(this.resetTypeahead, 100);
|
|
|
+ const currentValue = Plain.serialize(editor.value);
|
|
|
|
|
|
if (previousValue !== currentValue) {
|
|
|
this.executeOnChangeAndRunQueries();
|
|
|
}
|
|
|
- };
|
|
|
-
|
|
|
- onClickMenu = (item: CompletionItem) => {
|
|
|
- // Manually triggering change
|
|
|
- const change = this.applyTypeahead(this.state.value.change(), item);
|
|
|
- this.onChange(change, true);
|
|
|
- };
|
|
|
-
|
|
|
- updateMenu = () => {
|
|
|
- const { suggestions } = this.state;
|
|
|
- const menu = this.menuEl;
|
|
|
- // Exit for unit tests
|
|
|
- if (!window.getSelection) {
|
|
|
- return;
|
|
|
- }
|
|
|
- const selection = window.getSelection();
|
|
|
- const node = selection.anchorNode;
|
|
|
-
|
|
|
- // No menu, nothing to do
|
|
|
- if (!menu) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // No suggestions or blur, remove menu
|
|
|
- if (!hasSuggestions(suggestions)) {
|
|
|
- menu.removeAttribute('style');
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // Align menu overlay to editor node
|
|
|
- if (node) {
|
|
|
- // Read from DOM
|
|
|
- const rect = node.parentElement.getBoundingClientRect();
|
|
|
- const scrollX = window.scrollX;
|
|
|
- const scrollY = window.scrollY;
|
|
|
-
|
|
|
- // Write DOM
|
|
|
- requestAnimationFrame(() => {
|
|
|
- menu.style.opacity = '1';
|
|
|
- menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
|
|
|
- menu.style.left = `${rect.left + scrollX - 2}px`;
|
|
|
- });
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- menuRef = (el: HTMLElement) => {
|
|
|
- this.menuEl = el;
|
|
|
- };
|
|
|
-
|
|
|
- renderMenu = () => {
|
|
|
- const { portalOrigin } = this.props;
|
|
|
- const { suggestions, typeaheadIndex, typeaheadPrefix } = this.state;
|
|
|
- if (!hasSuggestions(suggestions)) {
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- const selectedItem = getSuggestionByIndex(suggestions, typeaheadIndex);
|
|
|
-
|
|
|
- // Create typeahead in DOM root so we can later position it absolutely
|
|
|
- return (
|
|
|
- <Portal origin={portalOrigin}>
|
|
|
- <TypeaheadWithTheme
|
|
|
- menuRef={this.menuRef}
|
|
|
- selectedItem={selectedItem}
|
|
|
- onClickItem={this.onClickMenu}
|
|
|
- prefix={typeaheadPrefix}
|
|
|
- groupedItems={suggestions}
|
|
|
- typeaheadIndex={typeaheadIndex}
|
|
|
- />
|
|
|
- </Portal>
|
|
|
- );
|
|
|
- };
|
|
|
-
|
|
|
- getCopiedText(textBlocks: string[], startOffset: number, endOffset: number) {
|
|
|
- if (!textBlocks.length) {
|
|
|
- return undefined;
|
|
|
- }
|
|
|
-
|
|
|
- const excludingLastLineLength = textBlocks.slice(0, -1).join('').length + textBlocks.length - 1;
|
|
|
- return textBlocks.join('\n').slice(startOffset, excludingLastLineLength + endOffset);
|
|
|
- }
|
|
|
-
|
|
|
- handleCopy = (event: ClipboardEvent, change: Change) => {
|
|
|
- event.preventDefault();
|
|
|
-
|
|
|
- const { document, selection, startOffset, endOffset } = change.value;
|
|
|
- const selectedBlocks = document.getBlocksAtRangeAsArray(selection).map((block: Block) => block.text);
|
|
|
-
|
|
|
- const copiedText = this.getCopiedText(selectedBlocks, startOffset, endOffset);
|
|
|
- if (copiedText) {
|
|
|
- event.clipboardData.setData('Text', copiedText);
|
|
|
- }
|
|
|
-
|
|
|
- return true;
|
|
|
- };
|
|
|
-
|
|
|
- handlePaste = (event: ClipboardEvent, change: Change) => {
|
|
|
- event.preventDefault();
|
|
|
- const pastedValue = event.clipboardData.getData('Text');
|
|
|
- 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: Change) => {
|
|
|
- this.handleCopy(event, change);
|
|
|
- change.deleteAtRange(change.value.selection);
|
|
|
+ editor.blur();
|
|
|
|
|
|
- return true;
|
|
|
+ return next();
|
|
|
};
|
|
|
|
|
|
render() {
|
|
|
@@ -670,19 +187,20 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
|
|
|
const wrapperClassName = classnames('slate-query-field__wrapper', {
|
|
|
'slate-query-field__wrapper--disabled': disabled,
|
|
|
});
|
|
|
+
|
|
|
return (
|
|
|
<div className={wrapperClassName}>
|
|
|
<div className="slate-query-field">
|
|
|
- {this.renderMenu()}
|
|
|
<Editor
|
|
|
+ ref={editor => (this.editor = editor)}
|
|
|
+ schema={SCHEMA}
|
|
|
autoCorrect={false}
|
|
|
readOnly={this.props.disabled}
|
|
|
onBlur={this.handleBlur}
|
|
|
- onKeyDown={this.onKeyDown}
|
|
|
- onChange={this.onChange}
|
|
|
- onCopy={this.handleCopy}
|
|
|
- onPaste={this.handlePaste}
|
|
|
- onCut={this.handleCut}
|
|
|
+ // onKeyDown={this.onKeyDown}
|
|
|
+ onChange={(change: { value: Value }) => {
|
|
|
+ this.onChange(change.value, false);
|
|
|
+ }}
|
|
|
placeholder={this.props.placeholder}
|
|
|
plugins={this.plugins}
|
|
|
spellCheck={false}
|
|
|
@@ -694,29 +212,4 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-interface PortalProps {
|
|
|
- index?: number;
|
|
|
- origin: string;
|
|
|
-}
|
|
|
-
|
|
|
-class Portal extends React.PureComponent<PortalProps, {}> {
|
|
|
- node: HTMLElement;
|
|
|
-
|
|
|
- constructor(props: PortalProps) {
|
|
|
- super(props);
|
|
|
- const { index = 0, origin = 'query' } = props;
|
|
|
- this.node = document.createElement('div');
|
|
|
- this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
|
|
|
- document.body.appendChild(this.node);
|
|
|
- }
|
|
|
-
|
|
|
- componentWillUnmount() {
|
|
|
- document.body.removeChild(this.node);
|
|
|
- }
|
|
|
-
|
|
|
- render() {
|
|
|
- return ReactDOM.createPortal(this.props.children, this.node);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
export default QueryField;
|