diff --git a/examples/esql_ast_inspector/.storybook/main.js b/examples/esql_ast_inspector/.storybook/main.js new file mode 100644 index 0000000000000..4c71be3362b05 --- /dev/null +++ b/examples/esql_ast_inspector/.storybook/main.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/examples/esql_ast_inspector/public/app.tsx b/examples/esql_ast_inspector/public/app.tsx index 80dee5776ce31..82292945f2ab3 100644 --- a/examples/esql_ast_inspector/public/app.tsx +++ b/examples/esql_ast_inspector/public/app.tsx @@ -7,84 +7,21 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useRef, useState } from 'react'; -import { - EuiPage, - EuiPageBody, - EuiPageSection, - EuiPageHeader, - EuiSpacer, - EuiForm, - EuiTextArea, - EuiFormRow, - EuiButton, -} from '@elastic/eui'; +import * as React from 'react'; +import { EuiPage, EuiPageBody, EuiPageSection, EuiPageHeader, EuiSpacer } from '@elastic/eui'; import { EuiProvider } from '@elastic/eui'; +import { EsqlInspector } from './components/esql_inspector'; -import type { CoreStart } from '@kbn/core/public'; - -import { EditorError, ESQLAst, getAstAndSyntaxErrors } from '@kbn/esql-ast'; -import { CodeEditor } from '@kbn/code-editor'; -import type { StartDependencies } from './plugin'; - -export const App = (props: { core: CoreStart; plugins: StartDependencies }) => { - const [currentErrors, setErrors] = useState([]); - const [currentQuery, setQuery] = useState( - 'from index1 | eval var0 = round(numberField, 2) | stats by stringField' - ); - - const inputRef = useRef(null); - - const [ast, setAST] = useState(getAstAndSyntaxErrors(currentQuery).ast); - - const parseQuery = (query: string) => { - const { ast: _ast, errors } = getAstAndSyntaxErrors(query); - setErrors(errors); - setAST(_ast); - }; - +export const App = () => { return ( - +

This app gives you the AST for a particular ES|QL query.

- - - - - error.message)} - > - { - inputRef.current = node; - }} - isInvalid={Boolean(currentErrors.length)} - fullWidth - value={currentQuery} - onChange={(e) => setQuery(e.target.value)} - css={{ - height: '5em', - }} - /> - - - parseQuery(inputRef.current?.value ?? '')}> - Parse - - - - +
diff --git a/examples/esql_ast_inspector/public/components/annotations/annotations.stories.tsx b/examples/esql_ast_inspector/public/components/annotations/annotations.stories.tsx new file mode 100644 index 0000000000000..389527ce01d6c --- /dev/null +++ b/examples/esql_ast_inspector/public/components/annotations/annotations.stories.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { Annotations } from './annotations'; + +export default { + title: '', + parameters: {}, +}; + +export const Default = () => ( + {text}], + [5, 10, (text) => {text}], + [13, 18, (text) => {text}], + [19, 21, (text) => {text}], + ]} + /> +); diff --git a/examples/esql_ast_inspector/public/components/annotations/annotations.tsx b/examples/esql_ast_inspector/public/components/annotations/annotations.tsx new file mode 100644 index 0000000000000..b76af4e3b0acc --- /dev/null +++ b/examples/esql_ast_inspector/public/components/annotations/annotations.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import type { Annotation } from './types'; + +export interface AnnotationsProps { + value: string; + annotations?: Annotation[]; +} + +export const Annotations: React.FC = (props) => { + const { value, annotations = [] } = props; + const annotationNodes: React.ReactNode[] = []; + + let pos = 0; + + for (const [start, end, render] of annotations) { + if (start > pos) { + const text = value.slice(pos, start); + + annotationNodes.push({text}); + } + + const text = value.slice(start, end); + + pos = end; + annotationNodes.push(render(text)); + } + + if (pos < value.length) { + const text = value.slice(pos); + annotationNodes.push({text}); + } + + return React.createElement('span', {}, ...annotationNodes); +}; diff --git a/examples/esql_ast_inspector/public/components/annotations/index.ts b/examples/esql_ast_inspector/public/components/annotations/index.ts new file mode 100644 index 0000000000000..afba4341dcf35 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/annotations/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { Annotations, type AnnotationsProps } from './annotations'; +export type { Annotation } from './types'; diff --git a/packages/kbn-esql-ast/src/ast_position_utils.ts b/examples/esql_ast_inspector/public/components/annotations/types.ts similarity index 50% rename from packages/kbn-esql-ast/src/ast_position_utils.ts rename to examples/esql_ast_inspector/public/components/annotations/types.ts index ab4603ee0a7d0..b62da2740c174 100644 --- a/packages/kbn-esql-ast/src/ast_position_utils.ts +++ b/examples/esql_ast_inspector/public/components/annotations/types.ts @@ -7,19 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { Token } from 'antlr4'; +import * as React from 'react'; -export function getPosition( - token: Pick | null, - lastToken?: Pick | undefined -) { - if (!token || token.start < 0) { - return { min: 0, max: 0 }; - } - const endFirstToken = token.stop > -1 ? Math.max(token.stop + 1, token.start) : undefined; - const endLastToken = lastToken?.stop; - return { - min: token.start, - max: endLastToken ?? endFirstToken ?? Infinity, - }; -} +export type Annotation = [ + start: number, + end: number, + annotation: (text: string) => React.ReactNode +]; diff --git a/examples/esql_ast_inspector/public/components/esql_editor/esql_editor.tsx b/examples/esql_ast_inspector/public/components/esql_editor/esql_editor.tsx new file mode 100644 index 0000000000000..e1d6a2418b0c9 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_editor/esql_editor.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { css } from '@emotion/react'; +import { Annotations, type Annotation } from '../annotations'; +import { FlexibleInput } from '../flexible_input/flexible_input'; + +const blockCss = css({ + display: 'inline-block', + position: 'relative', + width: '100%', + fontSize: '18px', + lineHeight: '1.3', + fontFamily: + "'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo, Consolas, monospace", +}); + +const backdropCss = css({ + display: 'inline-block', + position: 'absolute', + left: 0, + width: '100%', + pointerEvents: 'all', + userSelect: 'none', + whiteSpace: 'pre', + color: 'rgba(255, 255, 255, 0.01)', +}); + +const inputCss = css({ + display: 'inline-block', + color: 'rgba(255, 255, 255, 0.01)', + caretColor: '#07f', +}); + +const overlayCss = css({ + display: 'inline-block', + position: 'absolute', + left: 0, + width: '100%', + pointerEvents: 'none', + userSelect: 'none', + whiteSpace: 'pre', +}); + +export interface EsqlEditorProps { + src: string; + backdrops?: Annotation[][]; + highlight?: Annotation[]; + onChange: (src: string) => void; +} + +export const EsqlEditor: React.FC = (props) => { + const { src, highlight, onChange } = props; + + const backdrops: React.ReactNode[] = []; + + if (props.backdrops) { + for (let i = 0; i < props.backdrops.length; i++) { + const backdrop = props.backdrops[i]; + + backdrops.push( +
+ +
+ ); + } + } + + const overlay = !!highlight && ( +
+ +
+ ); + + return ( +
+ {backdrops} +
+ onChange(e.target.value)} /> +
+ {overlay} +
+ ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/editor/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/editor/index.tsx new file mode 100644 index 0000000000000..dbb7bbe94693f --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/editor/index.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiButton, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { Walker } from '@kbn/esql-ast'; +import { EsqlEditor } from '../../../esql_editor/esql_editor'; +import { useEsqlInspector } from '../../context'; +import { useBehaviorSubject } from '../../../../hooks/use_behavior_subject'; +import { Annotation } from '../../../annotations'; + +export const Editor: React.FC = () => { + const state = useEsqlInspector(); + const src = useBehaviorSubject(state.src$); + const highlight = useBehaviorSubject(state.highlight$); + const focusedNode = useBehaviorSubject(state.focusedNode$); + const limit = useBehaviorSubject(state.limit$); + + const targetsBackdrop: Annotation[] = []; + const focusBackdrop: Annotation[] = []; + const query = state.query$.getValue(); + + if (focusedNode) { + const location = focusedNode.location; + + if (location) { + focusBackdrop.push([ + location.min, + location.max + 1, + (text) => ( + + {text} + + ), + ]); + } + } + + if (query) { + Walker.walk(query.ast, { + visitSource: (node) => { + const location = node.location; + if (!location) return; + targetsBackdrop.push([ + location.min, + location.max + 1, + (text) => ( + { + state.focusedNode$.next(node); + }} + > + {text} + + ), + ]); + }, + }); + } + + if (limit) { + const location = limit.location; + + if (!location) return null; + + targetsBackdrop.push([ + location.min, + location.max + 1, + (text) => ( + { + state.focusedNode$.next(limit); + }} + > + {text} + + ), + ]); + } + + return ( + <> + +
+ { + const value = state.query$.getValue(); + + if (!value) { + return; + } + + state.src$.next(value.print()); + }} + > + Re-format + +
+ + state.src$.next(newSrc)} + backdrops={[targetsBackdrop, focusBackdrop]} + highlight={highlight} + /> +
+ + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ast/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ast/index.tsx new file mode 100644 index 0000000000000..50926dc272245 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ast/index.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { CodeEditor } from '@kbn/code-editor'; +import { EuiPanel, EuiSpacer } from '@elastic/eui'; +import { useEsqlInspector } from '../../../../context'; +import { useBehaviorSubject } from '../../../../../../hooks/use_behavior_subject'; + +export const PreviewAst: React.FC = (props) => { + const state = useEsqlInspector(); + const query = useBehaviorSubject(state.queryLastValid$); + + if (!query) { + return null; + } + + return ( + <> + + + + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/basic_printer/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/basic_printer/index.tsx new file mode 100644 index 0000000000000..d20dc4e46af59 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/basic_printer/index.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + EuiSwitch, +} from '@elastic/eui'; +import { PrettyPrintBasic } from '../../../../../../../pretty_print_basic'; + +export interface BasicPrinterProps { + src: string; +} + +export const BasicPrinter: React.FC = ({ src }) => { + const [lowercase, setLowercase] = React.useState(false); + const [multiline, setMultiline] = React.useState(false); + const [pipeTab, setPipeTab] = React.useState(' '); + + return ( + + + + + + + + + setLowercase((x) => !x)} + compressed + /> + + + + + setMultiline((x) => !x)} + compressed + /> + + + + + + + {!!multiline && ( + + setPipeTab(e.target.value)} /> + + )} + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/wrapping_printer/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/wrapping_printer/index.tsx new file mode 100644 index 0000000000000..d365335029dd0 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/components/wrapping_printer/index.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiRange, + EuiSpacer, + EuiSwitch, +} from '@elastic/eui'; +import { PrettyPrint } from '../../../../../../../pretty_print'; + +export interface WrappingPrinterProps { + src: string; +} + +export const WrappingPrinter: React.FC = ({ src }) => { + const [lowercase, setLowercase] = React.useState(false); + const [multiline, setMultiline] = React.useState(false); + const [wrap, setWrap] = React.useState(80); + const [tab, setTab] = React.useState(' '); + const [pipeTab, setPipeTab] = React.useState(' '); + const [indent, setIndent] = React.useState(''); + + return ( + + + + + + + + + setLowercase((x) => !x)} + compressed + /> + + + + + setMultiline((x) => !x)} + compressed + /> + + + + + + + + setWrap(Number(e.currentTarget.value))} + showInput + aria-label="Wrapping line width" + /> + + + + setIndent(e.target.value)} /> + + + + setTab(e.target.value)} /> + + + + setPipeTab(e.target.value)} /> + + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/index.tsx new file mode 100644 index 0000000000000..70e1cf7ba9cbd --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_print/index.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiCode, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import { useEsqlInspector } from '../../../../context'; +import { useBehaviorSubject } from '../../../../../../hooks/use_behavior_subject'; +import { WrappingPrinter } from './components/wrapping_printer'; +import { BasicPrinter } from './components/basic_printer'; + +export const PreviewPrint: React.FC = (props) => { + const state = useEsqlInspector(); + const src = useBehaviorSubject(state.src$); + + return ( + <> + + + +

+ Formatted with WrappingPrettyPrinter: +

+
+ + + + + + +

+ Formatted with BasicPrettyPrinter: +

+
+ + +
+ + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_tokens/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_tokens/index.tsx new file mode 100644 index 0000000000000..024af47549e71 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_tokens/index.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiDataGrid, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { createParser } from '@kbn/esql-ast'; +import { useEsqlInspector } from '../../../../context'; +import { useBehaviorSubject } from '../../../../../../hooks/use_behavior_subject'; + +const columns = [ + { + id: 'token', + display: 'Token', + }, + { + id: 'symbol', + display: 'Symbol', + }, + { + id: 'type', + display: 'Type', + }, + { + id: 'channel', + display: 'Channel', + }, +]; + +const symbolicNames = createParser('').lexer.symbolicNames; + +export const PreviewTokens: React.FC = (props) => { + const state = useEsqlInspector(); + const query = useBehaviorSubject(state.queryLastValid$); + + const [visibleColumns, setVisibleColumns] = React.useState(columns.map(({ id }) => id)); + + if (!query) { + return null; + } + + interface Row { + token: string; + symbol: string; + type: number; + channel: number; + } + + const data: Row[] = []; + + for (const token of query.tokens) { + data.push({ + token: token.text, + symbol: symbolicNames[token.type] ?? '', + type: token.type, + channel: token.channel, + }); + } + + return ( + <> + + + (data as any)[rowIndex][columnId]} + /> + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/index.tsx new file mode 100644 index 0000000000000..58d22f5767ca6 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/index.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiButton, EuiFormRow, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { Builder, ESQLSource } from '@kbn/esql-ast'; +import { useEsqlInspector } from '../../../../../../context'; +import { useBehaviorSubject } from '../../../../../../../../hooks/use_behavior_subject'; +import { Source } from './source'; + +export const FromCommand: React.FC = () => { + const state = useEsqlInspector(); + const from = useBehaviorSubject(state.from$); + + if (!from) { + return null; + } + + const sources: React.ReactNode[] = []; + let i = 0; + + for (const arg of from.args) { + if ((arg as any).type !== 'source') continue; + sources.push(); + i++; + } + + return ( + + +

Sources

+
+
+ {sources} +
+ + + { + const length = from.args.length; + const source = Builder.expression.source({ + name: `source${length + 1}`, + sourceType: 'index', + }); + from.args.push(source); + state.reprint(); + }} + > + Add source + + +
+ ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/source.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/source.tsx new file mode 100644 index 0000000000000..0c35f11b089ab --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/from_command/source.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { + EuiButtonIcon, + EuiFieldText, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiSpacer, + EuiToolTip, +} from '@elastic/eui'; +import { ESQLSource } from '@kbn/esql-ast'; +import { ESQLAstBaseItem } from '@kbn/esql-ast/src/types'; +import { useEsqlInspector } from '../../../../../../context'; +import { useBehaviorSubject } from '../../../../../../../../hooks/use_behavior_subject'; + +const getFirstComment = (node: ESQLAstBaseItem): string | undefined => { + const list = node.formatting?.top ?? node.formatting?.left ?? node.formatting?.right; + if (list) { + for (const decoration of list) { + if (decoration.type === 'comment') { + return decoration.text; + } + } + } + return undefined; +}; + +export interface SourceProps { + node: ESQLSource; + index: number; +} + +export const Source: React.FC = ({ node, index }) => { + const state = useEsqlInspector(); + const query = useBehaviorSubject(state.queryLastValid$); + const focusedNode = useBehaviorSubject(state.focusedNode$); + + if (!query) { + return null; + } + + const comment = getFirstComment(node); + + return ( + <> + +
{ + state.focusedNode$.next(node); + }} + style={{ + background: focusedNode === node ? 'rgb(190, 237, 224)' : 'transparent', + padding: 8, + margin: -8, + borderRadius: 8, + position: 'relative', + }} + > + + + Source {index} + + + ) : ( + <>Source {index} + ) + } + > + { + node.name = e.target.value; + state.reprint(); + }} + /> + +
+ + { + if (!query) return; + const from = state.from$.getValue(); + if (!from) return; + from.args = from.args.filter((c) => c !== node); + state.reprint(); + }} + /> + +
+
+ + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/limit_command/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/limit_command/index.tsx new file mode 100644 index 0000000000000..891a571254fb4 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/components/limit_command/index.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { + EuiButton, + EuiButtonIcon, + EuiFieldText, + EuiFlexItem, + EuiFormRow, + EuiPanel, + EuiTitle, +} from '@elastic/eui'; +import { Builder } from '@kbn/esql-ast'; +import { useEsqlInspector } from '../../../../../../context'; +import { useBehaviorSubject } from '../../../../../../../../hooks/use_behavior_subject'; + +export const LimitCommand: React.FC = () => { + const state = useEsqlInspector(); + const limit = useBehaviorSubject(state.limit$); + const focusedNode = useBehaviorSubject(state.focusedNode$); + + if (!limit) { + return ( + + + { + const query = state.query$.getValue(); + if (!query) return; + const literal = Builder.expression.literal.numeric({ + value: 10, + literalType: 'integer', + }); + const command = Builder.command({ + name: 'limit', + args: [literal], + }); + query.ast.commands.push(command); + state.reprint(); + }} + > + Add limit + + + + ); + } + + const value = +(limit.args[0] as any)?.value; + + if (typeof value !== 'number') { + return null; + } + + return ( + +
{ + state.focusedNode$.next(limit); + }} + style={{ + background: focusedNode === limit ? 'rgb(190, 237, 224)' : 'transparent', + padding: 8, + margin: -8, + borderRadius: 8, + position: 'relative', + }} + > + +

Limit

+
+
+ + { + const newValue = +e.target.value; + + if (newValue !== newValue) { + return; + } + + const literal = Builder.expression.literal.numeric({ + value: newValue, + literalType: 'integer', + }); + + limit.args[0] = literal; + state.reprint(); + }} + /> + +
+
+ + { + const query = state.query$.getValue(); + if (!query) return; + query.ast.commands = query.ast.commands.filter((c) => c !== limit); + state.reprint(); + }} + /> + +
+
+
+ ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/index.tsx new file mode 100644 index 0000000000000..f046f0b89087d --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/components/preview_ui/index.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { useEsqlInspector } from '../../../../context'; +import { useBehaviorSubject } from '../../../../../../hooks/use_behavior_subject'; +import { FromCommand } from './components/from_command'; +import { LimitCommand } from './components/limit_command'; + +export const PreviewUi: React.FC = (props) => { + const state = useEsqlInspector(); + const query = useBehaviorSubject(state.queryLastValid$); + + if (!query) { + return null; + } + + return ( + <> + + + + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/index.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/index.tsx new file mode 100644 index 0000000000000..8b6811333bc6a --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/components/preview/index.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiTabbedContent, EuiTabbedContentProps } from '@elastic/eui'; +import { PreviewAst } from './components/preview_ast'; +import { PreviewTokens } from './components/preview_tokens'; +import { PreviewUi } from './components/preview_ui'; +import { PreviewPrint } from './components/preview_print'; + +export const Preview: React.FC = () => { + const tabs: EuiTabbedContentProps['tabs'] = [ + { + id: 'ui', + name: 'UI', + content: , + }, + { + id: 'formatter', + name: 'Formatter', + content: , + }, + { + id: 'ast', + name: 'AST', + content: , + }, + { + id: 'tokens', + name: 'Tokens', + content: , + }, + ]; + + return ; +}; diff --git a/packages/kbn-esql-ast/src/ast_errors.ts b/examples/esql_ast_inspector/public/components/esql_inspector/context.ts similarity index 58% rename from packages/kbn-esql-ast/src/ast_errors.ts rename to examples/esql_ast_inspector/public/components/esql_inspector/context.ts index c9099e801708b..cdb82d1ef9da2 100644 --- a/packages/kbn-esql-ast/src/ast_errors.ts +++ b/examples/esql_ast_inspector/public/components/esql_inspector/context.ts @@ -7,15 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { RecognitionException } from 'antlr4'; -import { getPosition } from './ast_position_utils'; +import * as React from 'react'; +import { EsqlInspectorState } from './esql_inspector_state'; -export function createError(exception: RecognitionException) { - const token = exception.offendingToken; +export const context = React.createContext(null); - return { - type: 'error' as const, - text: `SyntaxError: ${exception.message}`, - location: getPosition(token), - }; -} +export const useEsqlInspector = () => React.useContext(context)!; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.stories.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.stories.tsx new file mode 100644 index 0000000000000..b42e9d957f8f2 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.stories.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EsqlInspector } from './esql_inspector'; + +export default { + title: '', + parameters: {}, +}; + +export const Default = () => ; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.tsx new file mode 100644 index 0000000000000..727894e99f87b --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EsqlInspectorState } from './esql_inspector_state'; +import { context } from './context'; +import { EsqlInspectorConnected } from './esql_inspector_connected'; + +export interface EsqlInspectorProps { + state?: EsqlInspectorState; +} + +export const EsqlInspector: React.FC = (props) => { + const state = React.useMemo(() => { + return props.state ?? new EsqlInspectorState(); + }, [props.state]); + + return ( + + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_connected.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_connected.tsx new file mode 100644 index 0000000000000..aa848e691c1c2 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_connected.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EsqlInspectorState } from './esql_inspector_state'; +import { Editor } from './components/editor'; +import { Preview } from './components/preview'; + +export interface EsqlInspectorConnectedProps { + state?: EsqlInspectorState; +} + +export const EsqlInspectorConnected: React.FC = (props) => { + return ( + <> + + + + + + + + + + + + + + + ); +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_state.ts b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_state.ts new file mode 100644 index 0000000000000..4938f245bd9cf --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/esql_inspector_state.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { BehaviorSubject } from 'rxjs'; +import { ESQLCommand, EsqlQuery, Walker } from '@kbn/esql-ast'; +import { ESQLProperNode } from '@kbn/esql-ast/src/types'; +import { Annotation } from '../annotations'; +import { highlight } from './helpers'; + +const defaultSrc = `FROM kibana_sample_data_logs, another_index + | KEEP bytes, clientip, url.keyword, response.keyword + | STATS Visits = COUNT(), Unique = COUNT_DISTINCT(clientip), + p95 = PERCENTILE(bytes, 95), median = MEDIAN(bytes) + BY type, url.keyword + | EVAL total_records = TO_DOUBLE(count_4xx + count_5xx + count_rest) + | DROP count_4xx, count_rest, total_records + | LIMIT 123`; + +export class EsqlInspectorState { + public readonly src$ = new BehaviorSubject(defaultSrc); + public readonly query$ = new BehaviorSubject(null); + public readonly queryLastValid$ = new BehaviorSubject(EsqlQuery.fromSrc('')); + public readonly highlight$ = new BehaviorSubject([]); + public readonly from$ = new BehaviorSubject(null); + public readonly limit$ = new BehaviorSubject(null); + public readonly focusedNode$ = new BehaviorSubject(null); + + constructor() { + this.src$.subscribe((src) => { + this.focusedNode$.next(null); + try { + this.query$.next(EsqlQuery.fromSrc(src, { withFormatting: true })); + } catch (e) { + this.query$.next(null); + } + }); + + this.query$.subscribe((query) => { + if (query instanceof EsqlQuery) { + this.queryLastValid$.next(query); + + this.highlight$.next(highlight(query)); + + const from = Walker.match(query?.ast, { + type: 'command', + name: 'from', + }); + + if (from) { + this.from$.next(from as ESQLCommand); + } else { + this.from$.next(null); + } + + const limit = Walker.match(query?.ast, { + type: 'command', + name: 'limit', + }); + + if (limit) { + this.limit$.next(limit as ESQLCommand); + } else { + this.limit$.next(null); + } + } + }); + } + + public readonly reprint = () => { + const query = this.query$.getValue(); + + if (!query) { + return; + } + + const src = query.print(); + this.src$.next(src); + }; +} diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/helpers.tsx b/examples/esql_ast_inspector/public/components/esql_inspector/helpers.tsx new file mode 100644 index 0000000000000..a117062f7efa9 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/helpers.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { EsqlQuery, Walker } from '@kbn/esql-ast'; +import { euiPaletteColorBlind } from '@elastic/eui'; +import { Annotation } from '../annotations'; + +const palette = euiPaletteColorBlind(); + +const colors = { + command: palette[2], + literal: palette[0], + source: palette[3], + operator: palette[9], + column: palette[6], + function: palette[8], +}; + +export const highlight = (query: EsqlQuery): Annotation[] => { + const annotations: Annotation[] = []; + + Walker.walk(query.ast, { + visitCommand: (node) => { + const location = node.location; + if (!location) return; + const color = node.name === 'from' ? '#07f' : colors.command; + annotations.push([ + location.min, + location.min + node.name.length, + (text) => {text}, + ]); + }, + + visitSource: (node) => { + const location = node.location; + if (!location) return; + annotations.push([ + location.min, + location.max + 1, + (text) => {text}, + ]); + }, + + visitColumn: (node) => { + const location = node.location; + if (!location) return; + annotations.push([ + location.min, + location.max + 1, + (text) => {text}, + ]); + }, + + visitFunction: (node) => { + const location = node.location; + if (!location) return; + if (node.subtype === 'variadic-call') { + annotations.push([ + location.min, + location.min + node.name.length, + (text) => {text}, + ]); + } + }, + + visitLiteral: (node) => { + const location = node.location; + if (!location) return; + annotations.push([ + location.min, + location.max + 1, + (text) => {text}, + ]); + }, + }); + + Walker.visitComments(query.ast, (comment) => { + annotations.push([ + comment.location.min, + comment.location.max, + (text) => {text}, + ]); + }); + + for (const token of query.tokens) { + switch (token.type) { + // PIPE + case 30: { + const pos = token.start; + + annotations.push([ + pos, + pos + 1, + (text) => {text}, + ]); + + break; + } + case 34: // BY + case 78: { + // METADATA + const pos = token.start; + + annotations.push([ + pos, + pos + token.text.length, + (text) => {text}, + ]); + + break; + } + default: { + switch (token.text) { + case '+': + case '-': + case '*': + case '/': + case '%': + case '!=': + case '>': + case '>=': + case '<': + case '<=': + case 'and': + case 'AND': + case 'or': + case 'OR': + case 'not': + case 'NOT': { + annotations.push([ + token.start, + token.start + token.text.length, + (text) => {text}, + ]); + } + } + } + } + } + + annotations.sort((a, b) => a[0] - b[0]); + + return annotations; +}; diff --git a/examples/esql_ast_inspector/public/components/esql_inspector/index.ts b/examples/esql_ast_inspector/public/components/esql_inspector/index.ts new file mode 100644 index 0000000000000..b6bc9b72a7a93 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/esql_inspector/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { context, useEsqlInspector } from './context'; +export { EsqlInspectorState } from './esql_inspector_state'; +export { EsqlInspector, type EsqlInspectorProps } from './esql_inspector'; diff --git a/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.stories.tsx b/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.stories.tsx new file mode 100644 index 0000000000000..ea50b01428b74 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.stories.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { FlexibleInput, FlexibleInputProps } from './flexible_input'; + +export default { + title: '', + parameters: {}, +}; + +const Demo: React.FC = (props) => { + const [value, setValue] = React.useState(props.value); + + return ( + + { + setValue(e.target.value); + }} + /> + + ); +}; + +const src = `FROM index, index2 + | WHERE language == "esql" + | LIMIT 10 +`; + +export const Example = () => ; diff --git a/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.tsx b/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.tsx new file mode 100644 index 0000000000000..1975789cb7916 --- /dev/null +++ b/examples/esql_ast_inspector/public/components/flexible_input/flexible_input.tsx @@ -0,0 +1,218 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as React from 'react'; +import { css } from '@emotion/react'; +import { copyStyles } from './helpers'; + +const blockCss = css({ + display: 'inline-block', + position: 'relative', + width: '100%', +}); + +const inputCss = css({ + display: 'inline-block', + verticalAlign: 'bottom', + boxSizing: 'border-box', + overflow: 'hidden', + padding: 0, + margin: 0, + background: 0, + outline: '0 !important', + border: 0, + color: 'inherit', + fontWeight: 'inherit', + fontFamily: 'inherit', + fontSize: 'inherit', + lineHeight: 'inherit', + whiteSpace: 'pre', + resize: 'none', +}); + +const sizerCss = css({ + display: 'inline-block', + position: 'absolute', + pointerEvents: 'none', + userSelect: 'none', + boxSizing: 'border-box', + top: 0, + left: 0, + border: 0, + whiteSpace: 'pre', +}); + +export interface FlexibleInputProps { + /** The string to display and edit. */ + value: string; + + /** Ref to the input element. */ + inp?: (el: HTMLInputElement | HTMLTextAreaElement | null) => void; + + /** Whether the input is multiline. */ + multiline?: boolean; + + /** Whether to wrap text to a new line when it exceeds the length of current. */ + wrap?: boolean; + + /** + * Whether the input should take the full width of the parent, even when there + * is not enough text to do that naturally with content. + */ + fullWidth?: boolean; + + /** Typeahead string to add to the value. It is visible at half opacity. */ + typeahead?: string; + + /** Addition width to add, for example, to account for number stepper. */ + extraWidth?: number; + + /** Minimum width to allow. */ + minWidth?: number; + + /** Maximum width to allow. */ + maxWidth?: number; + + /** Whether the input is focused on initial render. */ + focus?: boolean; + + /** Callback for when the input value changes. */ + onChange?: React.ChangeEventHandler; + + /** Callback for when the input is focused. */ + onFocus?: React.FocusEventHandler; + + /** Callback for when the input is blurred. */ + onBlur?: React.FocusEventHandler; + + /** Callback for when a key is pressed. */ + onKeyDown?: React.KeyboardEventHandler; + + /** Callback for when the Enter key is pressed. */ + onSubmit?: React.KeyboardEventHandler; + + /** Callback for when the Escape key is pressed. */ + onCancel?: React.KeyboardEventHandler; + + /** Callback for when the Tab key is pressed. */ + onTab?: React.KeyboardEventHandler; +} + +export const FlexibleInput: React.FC = ({ + value, + inp, + multiline, + wrap, + fullWidth, + typeahead = '', + extraWidth, + minWidth = 8, + maxWidth, + focus, + onChange, + onFocus, + onBlur, + onKeyDown, + onSubmit, + onCancel, + onTab, +}) => { + const inputRef = React.useRef(null); + const sizerRef = React.useRef(null); + const sizerValueRef = React.useRef(null); + + React.useLayoutEffect(() => { + if (!inputRef.current || !sizerRef.current) return; + if (focus) inputRef.current.focus(); + copyStyles(inputRef.current, sizerRef.current!, [ + 'font', + 'fontSize', + 'fontFamily', + 'fontWeight', + 'fontStyle', + 'letterSpacing', + 'textTransform', + 'boxSizing', + ]); + }, [focus]); + + React.useLayoutEffect(() => { + const sizerValue = sizerValueRef.current; + if (sizerValue) sizerValue.textContent = value; + const input = inputRef.current; + const sizer = sizerRef.current; + if (!input || !sizer) return; + let width = sizer.scrollWidth; + if (extraWidth) width += extraWidth; + if (minWidth) width = Math.max(width, minWidth); + if (maxWidth) width = Math.min(width, maxWidth); + const style = input.style; + style.width = width + 'px'; + if (multiline) { + const height = sizer.scrollHeight; + style.height = height + 'px'; + } + }, [value, extraWidth, minWidth, maxWidth, multiline]); + + const attr: React.InputHTMLAttributes & { ref: any } = { + ref: (input: unknown) => { + (inputRef as any).current = input; + if (inp) inp(input as HTMLInputElement | HTMLTextAreaElement); + }, + value, + style: { + width: fullWidth ? '100%' : undefined, + whiteSpace: wrap ? 'pre-wrap' : 'pre', + display: fullWidth ? 'block' : 'inline-block', + }, + onChange: (e) => { + if (onChange) onChange(e); + }, + onFocus, + onBlur, + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (!multiline || e.ctrlKey)) { + if (onSubmit) onSubmit(e as any); + } else if (e.key === 'Escape') { + if (onCancel) onCancel(e as any); + } else if (e.key === 'Tab') { + if (onTab) onTab(e as any); + } + if (onKeyDown) onKeyDown(e as any); + }, + }; + + const input = multiline ? ( +