fix: improve CodeEditor controlled mode, prevent unnecessary trimming (#6221)

* fix: improve CodeEditor controlled mode, prevent unnecessary trimming

* fix: useEffect dependency
This commit is contained in:
one 2025-05-21 03:51:30 +08:00 committed by GitHub
parent 6cd324f8ba
commit 75901e2714
5 changed files with 112 additions and 87 deletions

View File

@ -199,12 +199,11 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
// 源代码视图组件 // 源代码视图组件
const sourceView = useMemo(() => { const sourceView = useMemo(() => {
const SourceView = codeEditor.enabled ? CodeEditor : CodePreview if (codeEditor.enabled) {
return ( return <CodeEditor value={children} language={language} onSave={onSave} options={{ stream: true }} />
<SourceView language={language} onSave={onSave}> } else {
{children} return <CodePreview language={language}>{children}</CodePreview>
</SourceView> }
)
}, [children, codeEditor.enabled, language, onSave]) }, [children, codeEditor.enabled, language, onSave])
// 特殊视图组件映射 // 特殊视图组件映射

View File

@ -0,0 +1,65 @@
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { Extension } from '@uiw/react-codemirror'
import { useEffect, useState } from 'react'
let linterPromise: Promise<any> | null = null
function importLintPackage() {
if (!linterPromise) {
linterPromise = import('@codemirror/lint').then((mod) => mod.linter)
}
return linterPromise
}
// 语言对应的 linter 加载器
const linterLoaders: Record<string, () => Promise<any>> = {
json: async () => {
const [linter, jsonParseLinter] = await Promise.all([
importLintPackage(),
import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
])
return linter(jsonParseLinter())
}
}
export const useLanguageExtensions = (language: string, lint?: boolean) => {
const { languageMap } = useCodeStyle()
const [extensions, setExtensions] = useState<Extension[]>([])
// 加载语言
useEffect(() => {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
import('@uiw/codemirror-extensions-langs')
.then(({ loadLanguage }) => {
const extension = loadLanguage(normalizedLang as any)
if (extension) {
setExtensions((prev) => [...prev, extension])
}
})
.catch((error) => {
console.debug(`Failed to load language: ${normalizedLang}`, error)
})
}, [language, languageMap])
useEffect(() => {
if (!lint) return
const loader = linterLoaders[language]
if (loader) {
loader()
.then((extension) => {
setExtensions((prev) => [...prev, extension])
})
.catch((error) => {
console.error(`Failed to load linter for ${language}`, error)
})
}
}, [language, lint])
return extensions
}

View File

@ -14,17 +14,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo } from 'react' import { memo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useLanguageExtensions } from './hook'
// 标记非用户编辑的变更 // 标记非用户编辑的变更
const External = Annotation.define<boolean>() const External = Annotation.define<boolean>()
interface Props { interface Props {
children: string value: string
placeholder?: string | HTMLElement
language: string language: string
onSave?: (newContent: string) => void onSave?: (newContent: string) => void
onChange?: (newContent: string) => void onChange?: (newContent: string) => void
minHeight?: string
maxHeight?: string maxHeight?: string
/** 用于覆写编辑器的某些设置 */ /** 用于覆写编辑器的某些设置 */
options?: { options?: {
stream?: boolean // 用于流式响应场景,默认 false
lint?: boolean
collapsible?: boolean collapsible?: boolean
wrappable?: boolean wrappable?: boolean
keymap?: boolean keymap?: boolean
@ -36,11 +42,22 @@ interface Props {
} }
/** /**
* CodeMirror * CodeMirror ReactCodeMirror
* *
* CodeToolbar 使 * CodeToolbar 使
*/ */
const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options, extensions, style }: Props) => { const CodeEditor = ({
value,
placeholder,
language,
onSave,
onChange,
minHeight,
maxHeight,
options,
extensions,
style
}: Props) => {
const { const {
fontSize, fontSize,
codeShowLineNumbers: _lineNumbers, codeShowLineNumbers: _lineNumbers,
@ -61,38 +78,18 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
} }
}, [codeEditor, _lineNumbers, options]) }, [codeEditor, _lineNumbers, options])
const { activeCmTheme, languageMap } = useCodeStyle() const { activeCmTheme } = useCodeStyle()
const [isExpanded, setIsExpanded] = useState(!collapsible) const [isExpanded, setIsExpanded] = useState(!collapsible)
const [isUnwrapped, setIsUnwrapped] = useState(!wrappable) const [isUnwrapped, setIsUnwrapped] = useState(!wrappable)
const initialContent = useRef(children?.trimEnd() ?? '') const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
const [langExtension, setLangExtension] = useState<Extension[]>([])
const [editorReady, setEditorReady] = useState(false) const [editorReady, setEditorReady] = useState(false)
const editorViewRef = useRef<EditorView | null>(null) const editorViewRef = useRef<EditorView | null>(null)
const { t } = useTranslation() const { t } = useTranslation()
const langExtensions = useLanguageExtensions(language, options?.lint)
const { registerTool, removeTool } = useCodeToolbar() const { registerTool, removeTool } = useCodeToolbar()
// 加载语言
useEffect(() => {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
import('@uiw/codemirror-extensions-langs')
.then(({ loadLanguage }) => {
const extension = loadLanguage(normalizedLang as any)
if (extension) {
setLangExtension([extension])
}
})
.catch((error) => {
console.debug(`Failed to load language: ${normalizedLang}`, error)
})
}, [language, languageMap])
// 展开/折叠工具 // 展开/折叠工具
useEffect(() => { useEffect(() => {
registerTool({ registerTool({
@ -144,7 +141,7 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
useEffect(() => { useEffect(() => {
if (!editorViewRef.current) return if (!editorViewRef.current) return
const newContent = children?.trimEnd() ?? '' const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '')
const currentDoc = editorViewRef.current.state.doc.toString() const currentDoc = editorViewRef.current.state.doc.toString()
const changes = prepareCodeChanges(currentDoc, newContent) const changes = prepareCodeChanges(currentDoc, newContent)
@ -155,7 +152,7 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
annotations: [External.of(true)] annotations: [External.of(true)]
}) })
} }
}, [children]) }, [options?.stream, value])
useEffect(() => { useEffect(() => {
setIsExpanded(!collapsible) setIsExpanded(!collapsible)
@ -182,17 +179,19 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
const customExtensions = useMemo(() => { const customExtensions = useMemo(() => {
return [ return [
...(extensions ?? []), ...(extensions ?? []),
...langExtension, ...langExtensions,
...(isUnwrapped ? [] : [EditorView.lineWrapping]), ...(isUnwrapped ? [] : [EditorView.lineWrapping]),
...(enableKeymap ? [saveKeymap] : []) ...(enableKeymap ? [saveKeymap] : [])
] ]
}, [extensions, langExtension, isUnwrapped, enableKeymap, saveKeymap]) }, [extensions, langExtensions, isUnwrapped, enableKeymap, saveKeymap])
return ( return (
<CodeMirror <CodeMirror
// 维持一个稳定值,避免触发 CodeMirror 重置 // 维持一个稳定值,避免触发 CodeMirror 重置
value={initialContent.current} value={initialContent.current}
placeholder={placeholder}
width="100%" width="100%"
minHeight={minHeight}
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'} maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
editable={true} editable={true}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx // @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx

View File

@ -4,9 +4,8 @@ import { CodeToolbarProvider } from '@renderer/components/CodeToolbar'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setMCPServerActive } from '@renderer/store/mcp' import { setMCPServerActive } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types' import { MCPServer } from '@renderer/types'
import { Extension } from '@uiw/react-codemirror'
import { Form, Modal } from 'antd' import { Form, Modal } from 'antd'
import { FC, useCallback, useEffect, useState } from 'react' import { FC, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface AddMcpServerModalProps { interface AddMcpServerModalProps {
@ -57,23 +56,6 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({ visible, onClose, onSuc
const [form] = Form.useForm() const [form] = Form.useForm()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const [editorExtensions, setEditorExtensions] = useState<Extension[]>([]) // 新增 editorExtensions 狀態
// 載入 CodeMirror JSON Linter 擴充功能
useEffect(() => {
let isMounted = true
Promise.all([
import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter),
import('@codemirror/lint').then((mod) => mod.linter)
]).then(([jsonParseLinter, linter]) => {
if (isMounted) {
setEditorExtensions([linter(jsonParseLinter())])
}
})
return () => {
isMounted = false
}
}, [])
const handleOk = async () => { const handleOk = async () => {
try { try {
@ -177,10 +159,14 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({ visible, onClose, onSuc
rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}> rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}>
<CodeToolbarProvider> <CodeToolbarProvider>
<CodeEditor <CodeEditor
// 如果表單值為空,顯示範例 JSON否則顯示表單值
value={serverConfigValue}
placeholder={initialJsonExample}
language="json" language="json"
onChange={handleEditorChange} onChange={handleEditorChange}
maxHeight="300px" maxHeight="300px"
options={{ options={{
lint: true,
collapsible: true, collapsible: true,
wrappable: true, wrappable: true,
lineNumbers: true, lineNumbers: true,
@ -188,11 +174,7 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({ visible, onClose, onSuc
highlightActiveLine: true, highlightActiveLine: true,
keymap: true keymap: true
}} }}
extensions={editorExtensions} />
// 如果表單值為空,顯示範例 JSON否則顯示表單值
>
{serverConfigValue ?? initialJsonExample}
</CodeEditor>
</CodeToolbarProvider> </CodeToolbarProvider>
</Form.Item> </Form.Item>
</Form> </Form>

View File

@ -4,17 +4,16 @@ import { TopView } from '@renderer/components/TopView'
import { useAppDispatch, useAppSelector } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setMCPServers } from '@renderer/store/mcp' import { setMCPServers } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types' import { MCPServer } from '@renderer/types'
import { Extension } from '@uiw/react-codemirror'
import { Modal, Typography } from 'antd' import { Modal, Typography } from 'antd'
import { useCallback, useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface Props { interface Props {
resolve: (data: any) => void resolve: (data: any) => void
} }
const PopupContainer: React.FC<Props> = ({ resolve }) => { const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
const [editorExtensions, setEditorExtensions] = useState<Extension[]>([])
const [jsonConfig, setJsonConfig] = useState('') const [jsonConfig, setJsonConfig] = useState('')
const [jsonSaving, setJsonSaving] = useState(false) const [jsonSaving, setJsonSaving] = useState(false)
const [jsonError, setJsonError] = useState('') const [jsonError, setJsonError] = useState('')
@ -23,21 +22,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { t } = useTranslation() const { t } = useTranslation()
useEffect(() => {
let isMounted = true
Promise.all([
import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter),
import('@codemirror/lint').then((mod) => mod.linter)
]).then(([jsonParseLinter, linter]) => {
if (isMounted) {
setEditorExtensions([linter(jsonParseLinter())])
}
})
return () => {
isMounted = false
}
}, [])
useEffect(() => { useEffect(() => {
try { try {
const mcpServersObj: Record<string, any> = {} const mcpServersObj: Record<string, any> = {}
@ -116,10 +100,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
resolve({}) resolve({})
} }
const handleChange = useCallback((newContent: string) => {
setJsonConfig(newContent)
}, [])
EditMcpJsonPopup.hide = onCancel EditMcpJsonPopup.hide = onCancel
return ( return (
@ -143,10 +123,12 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<CodeToolbarProvider> <CodeToolbarProvider>
<CodeEditor <CodeEditor
value={jsonConfig}
language="json" language="json"
onChange={handleChange} onChange={(value) => setJsonConfig(value)}
maxHeight="60vh" maxHeight="60vh"
options={{ options={{
lint: true,
collapsible: true, collapsible: true,
wrappable: true, wrappable: true,
lineNumbers: true, lineNumbers: true,
@ -154,9 +136,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
highlightActiveLine: true, highlightActiveLine: true,
keymap: true keymap: true
}} }}
extensions={editorExtensions}> />
{jsonConfig}
</CodeEditor>
</CodeToolbarProvider> </CodeToolbarProvider>
</div> </div>
)} )}