| 1 | import { ComponentProps, createElement as h, forwardRef, useState } from 'react' |
| 2 | import Editor from 'react-simple-code-editor' |
| 3 | import { FieldProps } from '@hfs/mui-grid-form' |
| 4 | import { Box, TextField, TextFieldProps } from '@mui/material' |
| 5 | import { focusableSelector, isCtrlKey, Optional, try_ } from './misc' |
| 6 | import _ from 'lodash' |
| 7 | import { highlight, languages } from 'prismjs' |
| 8 | import 'prismjs/components/prism-markup' |
| 9 | import 'prismjs/components/prism-javascript' |
| 10 | import 'prismjs/components/prism-yaml' |
| 11 | import 'prismjs/components/prism-css' |
| 12 | import 'prismjs/components/prism-json' |
| 13 | import 'prismjs/themes/prism-solarizedlight.css' // looks good both with light and dark theme |
| 14 | |
| 15 | type OP = ComponentProps<typeof Editor> |
| 16 | type Already = 'highlight' | 'padding' | 'tabSize' | 'insertSpaces' | 'ignoreTabKey' |
| 17 | type TextEditorProps = Optional<OP, Already> & { lang?: 'plain' | 'js' | 'html' | 'css' | 'json' | 'yaml' } |
| 18 | export const TextEditor = forwardRef(({ style, lang='plain', ...props }: TextEditorProps, _ref) => h(Editor, { |
| 19 | // Editor component doesn't seem to support ref, but it didn't cause any problem yet |
| 20 | highlight: s => highlight(s, languages[lang], lang), |
| 21 | padding: 10, |
| 22 | tabSize: 4, |
| 23 | insertSpaces: true, |
| 24 | ignoreTabKey: false, |
| 25 | style: { |
| 26 | fontFamily: 'ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace', |
| 27 | fontSize: '1em', |
| 28 | flex: 1, |
| 29 | background: '#8883', |
| 30 | borderBottom: '1px solid #bbb', |
| 31 | ...style, |
| 32 | }, |
| 33 | ...props, |
| 34 | })) |
| 35 | |
| 36 | export function TextEditorField({ onChange, value, onBlur, setApi, lang, ...props }: FieldProps<string> & Omit<TextFieldProps, 'onChange'>) { |
| 37 | setApi?.({ |
| 38 | getError() { |
| 39 | return lang === 'js' && value ? try_(() => new Function(value) && null, e => e.message) |
| 40 | : false |
| 41 | } |
| 42 | }) |
| 43 | const [state, setState] = useState(value || '') |
| 44 | return h(TextField, { |
| 45 | multiline: true, |
| 46 | fullWidth: true, |
| 47 | value: state, |
| 48 | slotProps: { |
| 49 | ...props.slotProps, |
| 50 | input: { ...props.slotProps?.input, inputComponent: TextEditorAsInput }, |
| 51 | htmlInput: { ...props.slotProps?.htmlInput, lang }, |
| 52 | }, |
| 53 | onChange(event) { setState(event.target.value) }, |
| 54 | onBlur(event) { |
| 55 | onBlur?.(event) |
| 56 | onChange(event.target.value, { was: value, event }) |
| 57 | }, |
| 58 | ...props, |
| 59 | onKeyDown(ev) { |
| 60 | props.onKeyDown?.(ev) |
| 61 | if (isCtrlKey(ev) === 'Enter') |
| 62 | return onChange(state, { was: value, event: ev }) |
| 63 | if (!ev.altKey || ev.key !== 'Tab') return |
| 64 | ev.preventDefault() |
| 65 | const focusable = document.querySelectorAll(focusableSelector) |
| 66 | const i = _.indexOf(focusable, ev.target) |
| 67 | const n = focusable.length |
| 68 | const next = (i + (ev.shiftKey ? -1 : 1) + n) % n |
| 69 | ;(focusable[next] as HTMLElement).focus() |
| 70 | console.debug(focusable[next]) |
| 71 | }, |
| 72 | }) |
| 73 | } |
| 74 | |
| 75 | const TextEditorAsInput = forwardRef<HTMLInputElement, any>(({ onChange, ...rest }: any, ref) => |
| 76 | h(Box, { sx: { width: '100%', textarea: { outline: 0 } } }, |
| 77 | h(TextEditor, { |
| 78 | ref, |
| 79 | padding: 2, |
| 80 | style: { background: 'initial', borderBottom: 'initial' }, |
| 81 | ...rest, |
| 82 | onValueChange: value => onChange({ target: { value } }), |
| 83 | onKeyDown(ev) { |
| 84 | if (isCtrlKey(ev) === 'Enter') |
| 85 | ev.preventDefault() |
| 86 | }, |
| 87 | }) |
| 88 | )) |