fix(ui): respect editorOptions, prevent field from flashing on save (#13848)
Fixes https://github.com/payloadcms/payload/issues/13774 EditorOptions were not being respected properly. The fix for this was to set the following on the Editor component: ```ts detectIndentation: false, insertSpaces: undefined, tabSize: undefined, ``` ### Other fixes This PR also fixed the flash when JSON fields were saved. It removed the need for the `editorKey` which was causing the entire field to re-mount when the json value changed. We had this work around so data could be set externally and the height would be automatically calculated when the editor mounted. But since the JSON value did not have a stable reference there was no way for react to memoize it, so the key would change every time the document was saved. Now we pass down a `recalculatedHeightAt` which allows data to be edited externally still, but tells the component to recalculate its height without forcing the component to re-mount.
This commit is contained in:
@@ -6,6 +6,7 @@ import type { Props } from './types.js'
|
||||
|
||||
import { useTheme } from '../../providers/Theme/index.js'
|
||||
import { ShimmerEffect } from '../ShimmerEffect/index.js'
|
||||
import { defaultGlobalEditorOptions, defaultOptions } from './constants.js'
|
||||
import './index.scss'
|
||||
|
||||
const Editor = 'default' in EditorImport ? EditorImport.default : EditorImport
|
||||
@@ -13,11 +14,21 @@ const Editor = 'default' in EditorImport ? EditorImport.default : EditorImport
|
||||
const baseClass = 'code-editor'
|
||||
|
||||
const CodeEditor: React.FC<Props> = (props) => {
|
||||
const { className, maxHeight, minHeight, options, readOnly, ...rest } = props
|
||||
const {
|
||||
className,
|
||||
maxHeight,
|
||||
minHeight,
|
||||
options,
|
||||
readOnly,
|
||||
recalculatedHeightAt,
|
||||
value,
|
||||
...rest
|
||||
} = props
|
||||
const MIN_HEIGHT = minHeight ?? 56 // equivalent to 3 lines
|
||||
const prevCalculatedHeightAt = React.useRef<number | undefined>(recalculatedHeightAt)
|
||||
|
||||
// Extract per-model settings to avoid global conflicts
|
||||
const { insertSpaces, tabSize, trimAutoWhitespace, ...editorOptions } = options || {}
|
||||
const { insertSpaces, tabSize, trimAutoWhitespace, ...globalEditorOptions } = options || {}
|
||||
const paddingFromProps = options?.padding
|
||||
? (options.padding.top || 0) + (options.padding?.bottom || 0)
|
||||
: 0
|
||||
@@ -34,28 +45,34 @@ const CodeEditor: React.FC<Props> = (props) => {
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (recalculatedHeightAt && recalculatedHeightAt > prevCalculatedHeightAt.current) {
|
||||
setDynamicHeight(Math.max(MIN_HEIGHT, value.split('\n').length * 18 + 2 + paddingFromProps))
|
||||
prevCalculatedHeightAt.current = recalculatedHeightAt
|
||||
}
|
||||
}, [value, MIN_HEIGHT, paddingFromProps, recalculatedHeightAt])
|
||||
|
||||
return (
|
||||
<Editor
|
||||
className={classes}
|
||||
loading={<ShimmerEffect height={dynamicHeight} />}
|
||||
options={{
|
||||
detectIndentation: false, // use the tabSize on the model, set onMount
|
||||
hideCursorInOverviewRuler: true,
|
||||
insertSpaces: false,
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
overviewRulerBorder: false,
|
||||
...defaultGlobalEditorOptions,
|
||||
...globalEditorOptions,
|
||||
readOnly: Boolean(readOnly),
|
||||
scrollbar: {
|
||||
alwaysConsumeMouseWheel: false,
|
||||
},
|
||||
scrollBeyondLastLine: false,
|
||||
trimAutoWhitespace: false,
|
||||
wordWrap: 'on',
|
||||
...editorOptions,
|
||||
/**
|
||||
* onMount the model will set:
|
||||
* - insertSpaces
|
||||
* - tabSize
|
||||
* - trimAutoWhitespace
|
||||
*/
|
||||
detectIndentation: false,
|
||||
insertSpaces: undefined,
|
||||
tabSize: undefined,
|
||||
trimAutoWhitespace: undefined,
|
||||
}}
|
||||
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
|
||||
value={value}
|
||||
{...rest}
|
||||
// Since we are not building an IDE and the container
|
||||
// can already have scrolling, we want the height of the
|
||||
@@ -73,9 +90,9 @@ const CodeEditor: React.FC<Props> = (props) => {
|
||||
const model = editor.getModel()
|
||||
if (model) {
|
||||
model.updateOptions({
|
||||
insertSpaces: insertSpaces ?? true,
|
||||
tabSize: tabSize ?? 4,
|
||||
trimAutoWhitespace: trimAutoWhitespace ?? true,
|
||||
insertSpaces: insertSpaces ?? defaultOptions.insertSpaces,
|
||||
tabSize: tabSize ?? defaultOptions.tabSize,
|
||||
trimAutoWhitespace: trimAutoWhitespace ?? defaultOptions.trimAutoWhitespace,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
27
packages/ui/src/elements/CodeEditor/constants.ts
Normal file
27
packages/ui/src/elements/CodeEditor/constants.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { EditorProps } from '@monaco-editor/react'
|
||||
|
||||
export const defaultOptions: Pick<
|
||||
EditorProps['options'],
|
||||
'insertSpaces' | 'tabSize' | 'trimAutoWhitespace'
|
||||
> = {
|
||||
insertSpaces: false,
|
||||
tabSize: 4,
|
||||
trimAutoWhitespace: false,
|
||||
}
|
||||
|
||||
export const defaultGlobalEditorOptions: Omit<
|
||||
EditorProps['options'],
|
||||
'detectIndentation' | 'insertSpaces' | 'tabSize' | 'trimAutoWhitespace'
|
||||
> = {
|
||||
hideCursorInOverviewRuler: true,
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
overviewRulerBorder: false,
|
||||
readOnly: false,
|
||||
scrollbar: {
|
||||
alwaysConsumeMouseWheel: false,
|
||||
},
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
}
|
||||
@@ -7,4 +7,5 @@ export type Props = {
|
||||
*/
|
||||
minHeight?: number
|
||||
readOnly?: boolean
|
||||
recalculatedHeightAt?: number
|
||||
} & EditorProps
|
||||
|
||||
@@ -11,8 +11,8 @@ import { FieldLabel } from '../../fields/FieldLabel/index.js'
|
||||
import { useField } from '../../forms/useField/index.js'
|
||||
import { withCondition } from '../../forms/withCondition/index.js'
|
||||
import { mergeFieldStyles } from '../mergeFieldStyles.js'
|
||||
import './index.scss'
|
||||
import { fieldBaseClass } from '../shared/index.js'
|
||||
import './index.scss'
|
||||
|
||||
const prismToMonacoLanguageMap = {
|
||||
js: 'javascript',
|
||||
@@ -25,7 +25,7 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
|
||||
const {
|
||||
field,
|
||||
field: {
|
||||
admin: { className, description, editorOptions = {}, language = 'javascript' } = {},
|
||||
admin: { className, description, editorOptions, language = 'javascript' } = {},
|
||||
label,
|
||||
localized,
|
||||
required,
|
||||
@@ -36,8 +36,8 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
|
||||
validate,
|
||||
} = props
|
||||
|
||||
const inputChangeFromRef = React.useRef<'system' | 'user'>('system')
|
||||
const [editorKey, setEditorKey] = useState<string>('')
|
||||
const inputChangeFromRef = React.useRef<'formState' | 'internalEditor'>('formState')
|
||||
const [recalculatedHeightAt, setRecalculatedHeightAt] = useState<number | undefined>(Date.now())
|
||||
|
||||
const memoizedValidate = useCallback(
|
||||
(value, options) => {
|
||||
@@ -61,35 +61,36 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
const [initialStringValue, setInitialStringValue] = useState<string | undefined>(() =>
|
||||
const stringValueRef = React.useRef<string>(
|
||||
(value || initialValue) !== undefined ? (value ?? initialValue) : undefined,
|
||||
)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(val) => {
|
||||
(val: string) => {
|
||||
if (readOnly || disabled) {
|
||||
return
|
||||
}
|
||||
inputChangeFromRef.current = 'user'
|
||||
inputChangeFromRef.current = 'internalEditor'
|
||||
|
||||
try {
|
||||
setValue(val ? val : null)
|
||||
stringValueRef.current = val
|
||||
} catch (e) {
|
||||
setValue(val ? val : null)
|
||||
stringValueRef.current = val
|
||||
}
|
||||
},
|
||||
[readOnly, disabled, setValue],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (inputChangeFromRef.current === 'system') {
|
||||
setInitialStringValue(
|
||||
(value || initialValue) !== undefined ? (value ?? initialValue) : undefined,
|
||||
)
|
||||
setEditorKey(`${path}-${new Date().toString()}`)
|
||||
if (inputChangeFromRef.current === 'formState') {
|
||||
stringValueRef.current =
|
||||
(value || initialValue) !== undefined ? (value ?? initialValue) : undefined
|
||||
setRecalculatedHeightAt(Date.now())
|
||||
}
|
||||
|
||||
inputChangeFromRef.current = 'system'
|
||||
inputChangeFromRef.current = 'formState'
|
||||
}, [initialValue, path, value])
|
||||
|
||||
const styles = useMemo(() => mergeFieldStyles(field), [field])
|
||||
@@ -121,12 +122,12 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
|
||||
{BeforeInput}
|
||||
<CodeEditor
|
||||
defaultLanguage={prismToMonacoLanguageMap[language] || language}
|
||||
key={editorKey}
|
||||
onChange={handleChange}
|
||||
onMount={onMount}
|
||||
options={editorOptions}
|
||||
readOnly={readOnly || disabled}
|
||||
value={initialStringValue}
|
||||
recalculatedHeightAt={recalculatedHeightAt}
|
||||
value={stringValueRef.current}
|
||||
wrapperProps={{
|
||||
id: `field-${path?.replace(/\./g, '__')}`,
|
||||
}}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
import type { JSONFieldClientComponent } from 'payload'
|
||||
import type { JSONFieldClientComponent, JsonObject } from 'payload'
|
||||
|
||||
import { type OnMount } from '@monaco-editor/react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { defaultOptions } from '../../elements/CodeEditor/constants.js'
|
||||
import { CodeEditor } from '../../elements/CodeEditor/index.js'
|
||||
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
|
||||
import { useField } from '../../forms/useField/index.js'
|
||||
@@ -33,11 +34,25 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
|
||||
validate,
|
||||
} = props
|
||||
|
||||
const { tabSize = 2 } = editorOptions || {}
|
||||
const { insertSpaces = defaultOptions.insertSpaces, tabSize = defaultOptions.tabSize } =
|
||||
editorOptions || {}
|
||||
|
||||
const formatJSON = useCallback(
|
||||
(value: JsonObject | undefined): string | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (value === null) {
|
||||
return ''
|
||||
}
|
||||
return insertSpaces ? JSON.stringify(value, null, tabSize) : JSON.stringify(value, null, '\t')
|
||||
},
|
||||
[tabSize, insertSpaces],
|
||||
)
|
||||
|
||||
const [jsonError, setJsonError] = useState<string>()
|
||||
const inputChangeFromRef = React.useRef<'system' | 'user'>('system')
|
||||
const [editorKey, setEditorKey] = useState<string>('')
|
||||
const inputChangeFromRef = React.useRef<'formState' | 'internalEditor'>('formState')
|
||||
const [recalculatedHeightAt, setRecalculatedHeightAt] = useState<number | undefined>(Date.now())
|
||||
|
||||
const memoizedValidate = useCallback(
|
||||
(value, options) => {
|
||||
@@ -56,16 +71,12 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
|
||||
setValue,
|
||||
showError,
|
||||
value,
|
||||
} = useField<string>({
|
||||
} = useField<JsonObject>({
|
||||
potentiallyStalePath: pathFromProps,
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
const [initialStringValue, setInitialStringValue] = useState<string | undefined>(() =>
|
||||
(value || initialValue) !== undefined
|
||||
? JSON.stringify(value ?? initialValue, null, tabSize)
|
||||
: undefined,
|
||||
)
|
||||
const stringValueRef = React.useRef<string>(formatJSON(value ?? initialValue))
|
||||
|
||||
const handleMount = useCallback<OnMount>(
|
||||
(editor, monaco) => {
|
||||
@@ -88,28 +99,26 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
|
||||
: `${uri}?${crypto.randomUUID ? crypto.randomUUID() : uuidv4()}`
|
||||
|
||||
editor.setModel(
|
||||
monaco.editor.createModel(
|
||||
JSON.stringify(value, null, tabSize),
|
||||
'json',
|
||||
monaco.Uri.parse(newUri),
|
||||
),
|
||||
monaco.editor.createModel(formatJSON(value) || '', 'json', monaco.Uri.parse(newUri)),
|
||||
)
|
||||
},
|
||||
[jsonSchema, tabSize, value],
|
||||
[jsonSchema, formatJSON, value],
|
||||
)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(val) => {
|
||||
(val: string) => {
|
||||
if (readOnly || disabled) {
|
||||
return
|
||||
}
|
||||
inputChangeFromRef.current = 'user'
|
||||
inputChangeFromRef.current = 'internalEditor'
|
||||
|
||||
try {
|
||||
setValue(val ? JSON.parse(val) : null)
|
||||
stringValueRef.current = val
|
||||
setJsonError(undefined)
|
||||
} catch (e) {
|
||||
setValue(val ? val : null)
|
||||
stringValueRef.current = val
|
||||
setJsonError(e)
|
||||
}
|
||||
},
|
||||
@@ -117,17 +126,16 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (inputChangeFromRef.current === 'system') {
|
||||
setInitialStringValue(
|
||||
(value || initialValue) !== undefined
|
||||
? JSON.stringify(value ?? initialValue, null, tabSize)
|
||||
: undefined,
|
||||
)
|
||||
setEditorKey(`${path}-${new Date().toString()}`)
|
||||
if (inputChangeFromRef.current === 'formState') {
|
||||
const newStringValue = formatJSON(value ?? initialValue)
|
||||
if (newStringValue !== stringValueRef.current) {
|
||||
stringValueRef.current = newStringValue
|
||||
setRecalculatedHeightAt(Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
inputChangeFromRef.current = 'system'
|
||||
}, [initialValue, path, tabSize, value])
|
||||
inputChangeFromRef.current = 'formState'
|
||||
}, [initialValue, path, formatJSON, value])
|
||||
|
||||
const styles = useMemo(() => mergeFieldStyles(field), [field])
|
||||
|
||||
@@ -158,13 +166,13 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
|
||||
{BeforeInput}
|
||||
<CodeEditor
|
||||
defaultLanguage="json"
|
||||
key={editorKey}
|
||||
maxHeight={maxHeight}
|
||||
onChange={handleChange}
|
||||
onMount={handleMount}
|
||||
options={editorOptions}
|
||||
readOnly={readOnly || disabled}
|
||||
value={initialStringValue}
|
||||
recalculatedHeightAt={recalculatedHeightAt}
|
||||
value={stringValueRef.current}
|
||||
wrapperProps={{
|
||||
id: `field-${path?.replace(/\./g, '__')}`,
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user