query_field.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import PluginPrism from './slate-plugins/prism';
  2. // import PluginPrism from 'slate-prism';
  3. // import Prism from 'prismjs';
  4. import BracesPlugin from 'app/features/explore/slate-plugins/braces';
  5. import ClearPlugin from 'app/features/explore/slate-plugins/clear';
  6. // Custom plugins (new line on Enter and run on Shift+Enter)
  7. import NewlinePlugin from './slate-plugins/newline';
  8. import RunnerPlugin from './slate-plugins/runner';
  9. import Typeahead from './typeahead';
  10. import { getKeybindingSrv, KeybindingSrv } from 'app/core/services/keybindingSrv';
  11. import { Block, Document, Text, Value } from 'slate';
  12. import { Editor } from 'slate-react';
  13. import Plain from 'slate-plain-serializer';
  14. import ReactDOM from 'react-dom';
  15. import React from 'react';
  16. import _ from 'lodash';
  17. function flattenSuggestions(s) {
  18. return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
  19. }
  20. export const makeFragment = text => {
  21. const lines = text.split('\n').map(line =>
  22. Block.create({
  23. type: 'paragraph',
  24. nodes: [Text.create(line)],
  25. })
  26. );
  27. const fragment = Document.create({
  28. nodes: lines,
  29. });
  30. return fragment;
  31. };
  32. export const getInitialValue = query => Value.create({ document: makeFragment(query) });
  33. class Portal extends React.Component<any, any> {
  34. node: any;
  35. constructor(props) {
  36. super(props);
  37. const { index = 0, prefix = 'query' } = props;
  38. this.node = document.createElement('div');
  39. this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
  40. document.body.appendChild(this.node);
  41. }
  42. componentWillUnmount() {
  43. document.body.removeChild(this.node);
  44. }
  45. render() {
  46. return ReactDOM.createPortal(this.props.children, this.node);
  47. }
  48. }
  49. class QueryField extends React.Component<any, any> {
  50. menuEl: any;
  51. plugins: any;
  52. resetTimer: any;
  53. keybindingSrv: KeybindingSrv = getKeybindingSrv();
  54. constructor(props, context) {
  55. super(props, context);
  56. const { prismDefinition = {}, prismLanguage = 'kusto' } = props;
  57. this.plugins = [
  58. BracesPlugin(),
  59. ClearPlugin(),
  60. RunnerPlugin({ handler: props.onPressEnter }),
  61. NewlinePlugin(),
  62. PluginPrism({ definition: prismDefinition, language: prismLanguage }),
  63. ];
  64. this.state = {
  65. labelKeys: {},
  66. labelValues: {},
  67. suggestions: [],
  68. typeaheadIndex: 0,
  69. typeaheadPrefix: '',
  70. value: getInitialValue(props.initialQuery || ''),
  71. };
  72. }
  73. componentDidMount() {
  74. this.updateMenu();
  75. }
  76. componentWillUnmount() {
  77. this.restoreEscapeKeyBinding();
  78. clearTimeout(this.resetTimer);
  79. }
  80. componentDidUpdate() {
  81. this.updateMenu();
  82. }
  83. onChange = ({ value }) => {
  84. const changed = value.document !== this.state.value.document;
  85. this.setState({ value }, () => {
  86. if (changed) {
  87. // call typeahead only if query changed
  88. window.requestAnimationFrame(this.onTypeahead);
  89. this.onChangeQuery();
  90. }
  91. });
  92. };
  93. request = (url?) => {
  94. if (this.props.request) {
  95. return this.props.request(url);
  96. }
  97. return fetch(url);
  98. };
  99. onChangeQuery = () => {
  100. // Send text change to parent
  101. const { onQueryChange } = this.props;
  102. if (onQueryChange) {
  103. onQueryChange(Plain.serialize(this.state.value));
  104. }
  105. };
  106. onKeyDown = (event, change) => {
  107. const { typeaheadIndex, suggestions } = this.state;
  108. switch (event.key) {
  109. case 'Escape': {
  110. if (this.menuEl) {
  111. event.preventDefault();
  112. event.stopPropagation();
  113. this.resetTypeahead();
  114. return true;
  115. }
  116. break;
  117. }
  118. case ' ': {
  119. if (event.ctrlKey) {
  120. event.preventDefault();
  121. this.onTypeahead(true);
  122. return true;
  123. }
  124. break;
  125. }
  126. case 'Tab':
  127. case 'Enter': {
  128. if (this.menuEl) {
  129. // Dont blur input
  130. event.preventDefault();
  131. if (!suggestions || suggestions.length === 0) {
  132. return undefined;
  133. }
  134. // Get the currently selected suggestion
  135. const flattenedSuggestions = flattenSuggestions(suggestions);
  136. const selected = Math.abs(typeaheadIndex);
  137. const selectedIndex = selected % flattenedSuggestions.length || 0;
  138. const suggestion = flattenedSuggestions[selectedIndex];
  139. this.applyTypeahead(change, suggestion);
  140. return true;
  141. }
  142. break;
  143. }
  144. case 'ArrowDown': {
  145. if (this.menuEl) {
  146. // Select next suggestion
  147. event.preventDefault();
  148. this.setState({ typeaheadIndex: typeaheadIndex + 1 });
  149. }
  150. break;
  151. }
  152. case 'ArrowUp': {
  153. if (this.menuEl) {
  154. // Select previous suggestion
  155. event.preventDefault();
  156. this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
  157. }
  158. break;
  159. }
  160. default: {
  161. // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
  162. break;
  163. }
  164. }
  165. return undefined;
  166. };
  167. onTypeahead = (change?, item?) => {
  168. return change || this.state.value.change();
  169. };
  170. applyTypeahead(change?, suggestion?): { value: object } {
  171. return { value: {} };
  172. }
  173. resetTypeahead = () => {
  174. this.setState({
  175. suggestions: [],
  176. typeaheadIndex: 0,
  177. typeaheadPrefix: '',
  178. typeaheadContext: null,
  179. });
  180. };
  181. handleBlur = () => {
  182. const { onBlur } = this.props;
  183. // If we dont wait here, menu clicks wont work because the menu
  184. // will be gone.
  185. this.resetTimer = setTimeout(this.resetTypeahead, 100);
  186. if (onBlur) {
  187. onBlur();
  188. }
  189. this.restoreEscapeKeyBinding();
  190. };
  191. handleFocus = () => {
  192. const { onFocus } = this.props;
  193. if (onFocus) {
  194. onFocus();
  195. }
  196. // Don't go back to dashboard if Escape pressed inside the editor.
  197. this.removeEscapeKeyBinding();
  198. };
  199. removeEscapeKeyBinding() {
  200. this.keybindingSrv.unbind('esc', 'keydown');
  201. }
  202. restoreEscapeKeyBinding() {
  203. this.keybindingSrv.setupGlobal();
  204. }
  205. onClickItem = item => {
  206. const { suggestions } = this.state;
  207. if (!suggestions || suggestions.length === 0) {
  208. return;
  209. }
  210. // Get the currently selected suggestion
  211. const flattenedSuggestions = flattenSuggestions(suggestions);
  212. const suggestion = _.find(
  213. flattenedSuggestions,
  214. suggestion => suggestion.display === item || suggestion.text === item
  215. );
  216. // Manually triggering change
  217. const change = this.applyTypeahead(this.state.value.change(), suggestion);
  218. this.onChange(change);
  219. };
  220. updateMenu = () => {
  221. const { suggestions } = this.state;
  222. const menu = this.menuEl;
  223. const selection = window.getSelection();
  224. const node = selection.anchorNode;
  225. // No menu, nothing to do
  226. if (!menu) {
  227. return;
  228. }
  229. // No suggestions or blur, remove menu
  230. const hasSuggesstions = suggestions && suggestions.length > 0;
  231. if (!hasSuggesstions) {
  232. menu.removeAttribute('style');
  233. return;
  234. }
  235. // Align menu overlay to editor node
  236. if (node && node.parentElement) {
  237. // Read from DOM
  238. const rect = node.parentElement.getBoundingClientRect();
  239. const scrollX = window.scrollX;
  240. const scrollY = window.scrollY;
  241. // Write DOM
  242. requestAnimationFrame(() => {
  243. menu.style.opacity = 1;
  244. menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
  245. menu.style.left = `${rect.left + scrollX - 2}px`;
  246. });
  247. }
  248. };
  249. menuRef = el => {
  250. this.menuEl = el;
  251. };
  252. renderMenu = () => {
  253. const { portalPrefix } = this.props;
  254. const { suggestions } = this.state;
  255. const hasSuggesstions = suggestions && suggestions.length > 0;
  256. if (!hasSuggesstions) {
  257. return null;
  258. }
  259. // Guard selectedIndex to be within the length of the suggestions
  260. let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
  261. const flattenedSuggestions = flattenSuggestions(suggestions);
  262. selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
  263. const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map(
  264. i => (typeof i === 'object' ? i.text : i)
  265. );
  266. // Create typeahead in DOM root so we can later position it absolutely
  267. return (
  268. <Portal prefix={portalPrefix}>
  269. <Typeahead
  270. menuRef={this.menuRef}
  271. selectedItems={selectedKeys}
  272. onClickItem={this.onClickItem}
  273. groupedItems={suggestions}
  274. />
  275. </Portal>
  276. );
  277. };
  278. render() {
  279. return (
  280. <div className="slate-query-field">
  281. {this.renderMenu()}
  282. <Editor
  283. autoCorrect={false}
  284. onBlur={this.handleBlur}
  285. onKeyDown={this.onKeyDown}
  286. onChange={this.onChange}
  287. onFocus={this.handleFocus}
  288. placeholder={this.props.placeholder}
  289. plugins={this.plugins}
  290. spellCheck={false}
  291. value={this.state.value}
  292. />
  293. </div>
  294. );
  295. }
  296. }
  297. export default QueryField;