@samitouri / QOSami-HFS / commits / 9d5d1e68

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