TextEditorField
Massimo Melina committed
Sep 1, 2023 at 22:49 UTC
9d5d1e68260346d363df40ffac166c6f60046bfc
4 files changed
+64
-36
admin/src/CustomHtmlPage.ts
+59
-31
@@ -1,11 +1,11 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { ComponentProps, createElement as h, Fragment, useEffect, useMemo, useState } from 'react';
3
+import { ComponentProps, createElement as h, forwardRef, Fragment, useEffect, useMemo, useState } from 'react';
4
import { Field, FieldProps, SelectField } from '@hfs/mui-grid-form'
5
import { apiCall, useApiEx } from './api'
6
-import { Alert, Box, FormHelperText, FormLabel } from '@mui/material'
6
+import { Alert, Box, TextField } from '@mui/material'
7
import Editor from 'react-simple-code-editor'
8
-import { Dict, IconBtn, isCtrlKey, modifiedSx, reloadBtn, wikiLink } from './misc';
8
+import { Dict, focusableSelector, IconBtn, isCtrlKey, modifiedSx, reloadBtn, wikiLink } from './misc';
9
import { Save } from '@mui/icons-material'
10
import _ from 'lodash'
11
import { useDebounce } from 'usehooks-ts'
@@ -44,8 +44,7 @@ export default function CustomHtmlPage() {
44
h(TextEditor, {
45
value: all?.[section] || '',
46
style: { background: '#8881' },
47
- // @ts-ignore TODO
48
- onChange(v: string) {
47
+ onValueChange(v: string) {
48
setAll(all => ({ ...all, [section]: v }))
49
},
50
onKeyDown(ev) {
@@ -68,30 +67,59 @@ function escapeHTML(unsafe: string) {
67
}
68
69
type OP = ComponentProps<typeof Editor>
71
-type Already = 'highlight' | 'padding' | 'tabSize' | 'insertSpaces' | 'ignoreTabKey' | 'onValueChange'
72
-type TextEditorProps = FieldProps<string> & Omit<OP, Already> & Partial<Pick<OP, Already>>
73
-export function TextEditor({ label, helperText, onChange, setApi, style, ...props }: TextEditorProps) {
74
- return h(Fragment, {},
75
- label && h(FormLabel, { sx: { ml: 1 } }, label),
76
- helperText && h(FormHelperText, {}, helperText),
77
- h(Editor, {
78
- highlight: escapeHTML,
79
- padding: 10,
80
- tabSize: 4,
81
- insertSpaces: true,
82
- ignoreTabKey: false,
83
- style: {
84
- fontFamily: 'ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace',
85
- fontSize: '1em',
86
- flex: 1,
87
- background: '#8883',
88
- borderBottom: '1px solid #bbb',
89
- ...style,
90
- },
91
- onValueChange(v: string) {
92
- onChange(v, { was: props.value, event: null })
93
- },
94
- ...props,
70
+type Already = 'highlight' | 'padding' | 'tabSize' | 'insertSpaces' | 'ignoreTabKey'
71
+type TextEditorProps = Omit<OP, Already> & Partial<Pick<OP, Already>>
72
+const TextEditor = forwardRef(({ style, ...props }: TextEditorProps, ref) => h(Editor, {
73
+ // Editor component doesn't seem to support ref, but I didn't cause any problem yet
74
+ highlight: escapeHTML,
75
+ padding: 10,
76
+ tabSize: 4,
77
+ insertSpaces: true,
78
+ ignoreTabKey: false,
79
+ style: {
80
+ fontFamily: 'ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace',
81
+ fontSize: '1em',
82
+ flex: 1,
83
+ background: '#8883',
84
+ borderBottom: '1px solid #bbb',
85
+ ...style,
86
+ },
87
+ ...props,
88
+}))
89
+
90
+export function TextEditorField({ onChange, value, onBlur, setApi, ...props }: FieldProps<string>) {
91
+ const [state, setState] = useState(value || '')
92
+ return h(TextField, {
93
+ multiline: true,
94
+ fullWidth: true,
95
+ value: state,
96
+ InputProps: { inputComponent: TextEditorAsInput },
97
+ onChange(event) { setState(event.target.value) },
98
+ onBlur(event) {
99
+ onBlur?.(event)
100
+ onChange(event.target.value, { was: value, event })
101
+ },
102
+ ...props,
103
+ onKeyDown(ev) {
104
+ if (!ev.altKey || ev.key !== 'Tab') return
105
+ ev.preventDefault()
106
+ const focusable = document.querySelectorAll(focusableSelector)
107
+ const i = _.indexOf(focusable, ev.target)
108
+ const n = focusable.length
109
+ const next = (i + (ev.shiftKey ? -1 : 1) + n) % n
110
+ ;(focusable[next] as HTMLElement).focus()
111
+ console.debug(focusable[next])
112
+ },
113
+ })
114
+}
115
+
116
+const TextEditorAsInput = forwardRef<HTMLInputElement, any>(({ onChange, ...rest }: any, ref) =>
117
+ h(Box, { sx: { width: '100%', textarea: { outline: 0 } } },
118
+ h(TextEditor, {
119
+ ref,
120
+ onValueChange: value => onChange({ target: { value } }),
121
+ padding: 2,
122
+ style: { background: 'initial', borderBottom: 'initial' },
123
+ ...rest
124
})
96
- )
97
-}
\ No newline at end of file
125
+ ))
\ No newline at end of file
admin/src/OptionsPage.ts
+3
-3
@@ -14,7 +14,7 @@ import { proxyWarning } from './HomePage'
14
import _ from 'lodash';
15
import { proxy, subscribe, useSnapshot } from 'valtio'
16
import md from './md'
17
-import { TextEditor } from './CustomHtmlPage'
17
+import { TextEditorField } from './CustomHtmlPage'
18
19
let loaded: Dict | undefined
20
let exposedReloadStatus: undefined | (() => void)
@@ -170,8 +170,8 @@ export default function OptionsPage() {
170
fromField: (all:string) => all.split('\n').map(s => s.trim()).filter(Boolean).map(ip => ({ ip })),
171
toField: (all: any) => !Array.isArray(all) ? '' : all.map(x => x?.ip).filter(Boolean).join('\n')
172
},
173
- { k: 'server_code', comp: TextEditor, sm: 12,
174
- helperText: md("This code works similarly to a plugin (with some limitations). Please refer to [plugin documentation]("+ REPO_URL + "blob/main/dev-plugins.md).")
173
+ { k: 'server_code', comp: TextEditorField, sm: 12,
174
+ helperText: md(`This code works similarly to [a plugin](${REPO_URL}blob/main/dev-plugins.md) (with some limitations)`)
175
}
176
]
177
})
admin/src/md.ts
+1
-1
@@ -12,7 +12,7 @@ type OnText = (s: string) => ReactNode
12
export default function md(text: string | TemplateStringsArray, { linkTarget='_blank', onText=(x=>x) as OnText }={}) {
13
if (typeof text !== 'string')
14
text = text[0]
15
- return replaceStringToReact(text, /(`|_|\*\*?)(.+)\1|(\n)|\[(.+)\]\((.+)\)|<([^ >/]+)>(.*)<\/\6>|<([^ >/]+) *\/>/g, m =>
15
+ return replaceStringToReact(text, /(`|_|\*\*?)(.+?)\1|(\n)|\[(.+?)\]\((.+?)\)|<([^ >/]+)>(.*?)<\/\6>|<([^ >/]+) *\/>/g, m =>
16
m[4] ? h(Link, { href: m[5], target: linkTarget }, onText(m[4]))
17
: m[3] ? h('br')
18
: m[1] ? h((TAGS as any)[ m[1] ] || Fragment, {}, onText(m[2]))
shared/dialogs.ts
+1
-1
@@ -29,7 +29,7 @@ export const dialogsDefaults: Partial<DialogOptions> = {
29
}
30
31
// focus trapped on current dialog (MUI already does it)
32
-const focusableSelector = ['input:not([type="hidden"])', 'button', 'select', 'textarea', 'a[href]', '[tabindex]'].map(x =>
32
+export const focusableSelector = ['input:not([type="hidden"])', 'button', 'select', 'textarea', 'a[href]', '[tabindex]'].map(x =>
33
x + ':not([disabled]):not([tabindex="-1"])').join(',')
34
window.addEventListener('keydown', ev => {
35
if (ev.key !== 'Tab') return