From d4e024f42d7ef6c1c1ed06bbf702c5731d1b46e5 Mon Sep 17 00:00:00 2001 From: one Date: Sat, 30 Aug 2025 15:11:29 +0800 Subject: [PATCH] refactor(CodeEditor): improve code editor props (#9653) * refactor(CodeEditor): improve props for clarity * refactor: update CodeEditor usage * refactor: change unwrapped to wrapped * fix: CodeViewer unwrap * refactor: simplify code viewer border radius, add comments --- .../CodeBlockView/HtmlArtifactsPopup.tsx | 15 ++-- .../src/components/CodeBlockView/view.tsx | 22 +++--- .../src/components/CodeEditor/index.tsx | 75 ++++++++++++++----- .../__tests__/useWrapTool.test.tsx | 10 +-- .../CodeToolbar/hooks/useWrapTool.tsx | 10 +-- src/renderer/src/components/CodeViewer.tsx | 7 +- .../AssistantPromptSettings.tsx | 4 +- .../DisplaySettings/DisplaySettings.tsx | 4 +- .../MCPSettings/AddMcpServerModal.tsx | 6 +- .../settings/MCPSettings/EditMcpJsonPopup.tsx | 4 +- .../ProviderSettings/CustomHeaderPopup.tsx | 5 +- 11 files changed, 102 insertions(+), 60 deletions(-) diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index 8cdf4e4d45..d072e88c4b 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -145,9 +145,10 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht language="html" editable={true} onSave={onSave} - style={{ height: '100%' }} - expanded - unwrapped={false} + height="100%" + expanded={false} + wrapped + style={{ minHeight: 0 }} options={{ stream: true, // FIXME: 避免多余空行 lineNumbers: true, @@ -388,12 +389,8 @@ const CodeSection = styled.div` width: 100%; overflow: hidden; position: relative; - - .monaco-editor, - .cm-editor, - .cm-scroller { - height: 100% !important; - } + display: grid; + grid-template-rows: 1fr auto; ` const PreviewSection = styled.div` diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index 93c9406ba0..4bb70e1b75 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -100,7 +100,7 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave }, [hasSpecialView, viewMode]) const [expandOverride, setExpandOverride] = useState(!codeCollapsible) - const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable) + const [wrapOverride, setWrapOverride] = useState(codeWrappable) // 重置用户操作 useEffect(() => { @@ -109,11 +109,11 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave // 重置用户操作 useEffect(() => { - setUnwrapOverride(!codeWrappable) + setWrapOverride(codeWrappable) }, [codeWrappable]) const shouldExpand = useMemo(() => !codeCollapsible || expandOverride, [codeCollapsible, expandOverride]) - const shouldUnwrap = useMemo(() => !codeWrappable || unwrapOverride, [codeWrappable, unwrapOverride]) + const shouldWrap = useMemo(() => codeWrappable && wrapOverride, [codeWrappable, wrapOverride]) const [sourceScrollHeight, setSourceScrollHeight] = useState(0) const expandable = useMemo(() => { @@ -225,9 +225,9 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave // 源代码视图的自动换行按钮 useWrapTool({ enabled: !isInSpecialView, - unwrapped: shouldUnwrap, + wrapped: shouldWrap, wrappable: codeWrappable, - toggle: useCallback(() => setUnwrapOverride((prev) => !prev), []), + toggle: useCallback(() => setWrapOverride((prev) => !prev), []), setTools }) @@ -249,21 +249,22 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave language={language} onSave={onSave} onHeightChange={handleHeightChange} + maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`} options={{ stream: true }} expanded={shouldExpand} - unwrapped={shouldUnwrap} + wrapped={shouldWrap} /> ) : ( {children} ), - [children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldUnwrap] + [children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldWrap] ) // 特殊视图组件映射 @@ -370,9 +371,10 @@ const SplitViewWrapper = styled.div<{ $isSpecialView: boolean; $isSplitView: boo &:not(:has(+ [class*='Container'])) { // 特殊视图的 header 会隐藏,所以全都使用圆角 border-radius: ${(props) => (props.$isSpecialView ? '8px' : '0 0 8px 8px')}; + // FIXME: 滚动条边缘会溢出,可以考虑增加 padding,但是要保证代码主题颜色铺满容器。 + // overflow: hidden; .code-viewer { - border-radius: 0 0 8px 8px; - overflow: hidden; + border-radius: inherit; } } diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index 3ae87ad5dd..749ec57b01 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -1,4 +1,3 @@ -import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useSettings } from '@renderer/hooks/useSettings' import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror' @@ -15,39 +14,81 @@ export interface CodeEditorHandles { save?: () => void } -interface CodeEditorProps { +export interface CodeEditorProps { ref?: React.RefObject + /** Value used in controlled mode, e.g., code blocks. */ value: string + /** Placeholder when the editor content is empty. */ placeholder?: string | HTMLElement + /** Code language, supports aliases. */ language: string + /** Fired when ref.save() is called or the save shortcut is triggered. */ onSave?: (newContent: string) => void + /** Fired when the editor content changes. */ onChange?: (newContent: string) => void + /** Fired when the editor loses focus. */ onBlur?: (newContent: string) => void + /** Fired when the editor height changes. */ onHeightChange?: (scrollHeight: number) => void + /** + * Fixed editor height, not exceeding maxHeight. + * Only works when expanded is false. + */ height?: string - minHeight?: string + /** + * Maximum editor height. + * Only works when expanded is false. + */ maxHeight?: string + /** Minimum editor height. */ + minHeight?: string + /** Font size that overrides the app setting. */ fontSize?: string - /** 用于覆写编辑器的某些设置 */ + /** Editor options that extend BasicSetupOptions. */ options?: { - stream?: boolean // 用于流式响应场景,默认 false + /** + * Whether to enable special treatment for stream response. + * @default false + */ + stream?: boolean + /** + * Whether to enable linting. + * @default false + */ lint?: boolean + /** + * Whether to enable keymap. + * @default false + */ keymap?: boolean } & BasicSetupOptions - /** 用于追加 extensions */ + /** Additional extensions for CodeMirror. */ extensions?: Extension[] - /** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */ + /** Style overrides for the editor, passed directly to CodeMirror's style property. */ style?: React.CSSProperties + /** CSS class name appended to the default `code-editor` class. */ className?: string + /** + * Whether the editor is editable. + * @default true + */ editable?: boolean + /** + * Whether the editor is expanded. + * If true, the height and maxHeight props are ignored. + * @default true + */ expanded?: boolean - unwrapped?: boolean + /** + * Whether the code lines are wrapped. + * @default true + */ + wrapped?: boolean } /** - * 源代码编辑器,基于 CodeMirror,封装了 ReactCodeMirror。 - * - * 目前必须和 CodeToolbar 配合使用。 + * A code editor component based on CodeMirror. + * This is a wrapper of ReactCodeMirror. */ const CodeEditor = ({ ref, @@ -59,8 +100,8 @@ const CodeEditor = ({ onBlur, onHeightChange, height, - minHeight, maxHeight, + minHeight, fontSize, options, extensions, @@ -68,7 +109,7 @@ const CodeEditor = ({ className, editable = true, expanded = true, - unwrapped = false + wrapped = true }: CodeEditorProps) => { const { fontSize: _fontSize, codeShowLineNumbers: _lineNumbers, codeEditor } = useSettings() const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap]) @@ -121,12 +162,12 @@ const CodeEditor = ({ return [ ...(extensions ?? []), ...langExtensions, - ...(unwrapped ? [] : [EditorView.lineWrapping]), + ...(wrapped ? [EditorView.lineWrapping] : []), saveKeymapExtension, blurExtension, heightListenerExtension ].flat() - }, [extensions, langExtensions, unwrapped, saveKeymapExtension, blurExtension, heightListenerExtension]) + }, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension]) useImperativeHandle(ref, () => ({ save: handleSave @@ -138,9 +179,9 @@ const CodeEditor = ({ value={initialContent.current} placeholder={placeholder} width="100%" - height={height} + height={expanded ? undefined : height} + maxHeight={expanded ? undefined : maxHeight} minHeight={minHeight} - maxHeight={expanded ? 'none' : (maxHeight ?? `${MAX_COLLAPSED_CODE_HEIGHT}px`)} editable={editable} // @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx theme={activeCmTheme} diff --git a/src/renderer/src/components/CodeToolbar/__tests__/useWrapTool.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/useWrapTool.test.tsx index ca601cd37f..f85bb52df2 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/useWrapTool.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/useWrapTool.test.tsx @@ -50,7 +50,7 @@ describe('useWrapTool', () => { const createMockProps = (overrides: Partial[0]> = {}) => { const defaultProps = { enabled: true, - unwrapped: false, + wrapped: true, wrappable: true, toggle: vi.fn(), setTools: vi.fn() @@ -90,8 +90,8 @@ describe('useWrapTool', () => { expect(mockRegisterTool).not.toHaveBeenCalled() }) - it('should re-register tool when unwrapped changes', () => { - const props = createMockProps({ unwrapped: false }) + it('should re-register tool when wrapped changes', () => { + const props = createMockProps({ wrapped: true }) const { rerender } = renderHook((hookProps) => useWrapTool(hookProps), { initialProps: props }) @@ -100,8 +100,8 @@ describe('useWrapTool', () => { const firstCall = mockRegisterTool.mock.calls[0][0] expect(firstCall.tooltip).toBe('code_block.wrap.off') - // Change unwrapped to true and rerender - const newProps = { ...props, unwrapped: true } + // Change wrapped to false and rerender + const newProps = { ...props, wrapped: false } rerender(newProps) expect(mockRegisterTool).toHaveBeenCalledTimes(2) diff --git a/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx index c0354e78fd..bea1e4a5b5 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx @@ -5,13 +5,13 @@ import { useTranslation } from 'react-i18next' interface UseWrapToolProps { enabled?: boolean - unwrapped?: boolean + wrapped?: boolean wrappable?: boolean toggle: () => void setTools: React.Dispatch> } -export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }: UseWrapToolProps) => { +export const useWrapTool = ({ enabled, wrapped, wrappable, toggle, setTools }: UseWrapToolProps) => { const { t } = useTranslation() const { registerTool, removeTool } = useToolManager(setTools) @@ -23,13 +23,13 @@ export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }: if (enabled) { registerTool({ ...TOOL_SPECS.wrap, - icon: unwrapped ? : , - tooltip: unwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'), + icon: wrapped ? : , + tooltip: wrapped ? t('code_block.wrap.off') : t('code_block.wrap.on'), visible: () => wrappable ?? false, onClick: handleToggle }) } return () => removeTool(TOOL_SPECS.wrap.id) - }, [enabled, handleToggle, registerTool, removeTool, t, unwrapped, wrappable]) + }, [enabled, handleToggle, registerTool, removeTool, t, wrapped, wrappable]) } diff --git a/src/renderer/src/components/CodeViewer.tsx b/src/renderer/src/components/CodeViewer.tsx index c3783fd2c4..87a33534b9 100644 --- a/src/renderer/src/components/CodeViewer.tsx +++ b/src/renderer/src/components/CodeViewer.tsx @@ -14,7 +14,7 @@ interface CodeViewerProps { language: string children: string expanded?: boolean - unwrapped?: boolean + wrapped?: boolean onHeightChange?: (scrollHeight: number) => void className?: string } @@ -25,7 +25,7 @@ interface CodeViewerProps { * - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应 * - 并发安全 */ -const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, className }: CodeViewerProps) => { +const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className }: CodeViewerProps) => { const { codeShowLineNumbers, fontSize } = useSettings() const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle() const shikiThemeRef = useRef(null) @@ -108,7 +108,7 @@ const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, c (props.$wrap ? 'pre-wrap' : 'pre')}; overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx index fdc60341cb..387945077f 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx @@ -132,8 +132,8 @@ const AssistantPromptSettings: React.FC = ({ assistant, updateAssistant } onBlur={onUpdate} height="calc(80vh - 202px)" fontSize="var(--ant-font-size)" - expanded - unwrapped={false} + expanded={false} + wrapped options={{ autocompletion: false, keymap: true, diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index 1c4749aebd..2bedfbbd8b 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -338,8 +338,8 @@ const DisplaySettings: FC = () => { placeholder={t('settings.display.custom.css.placeholder')} onChange={(value) => dispatch(setCustomCss(value))} height="60vh" - expanded - unwrapped={false} + expanded={false} + wrapped options={{ autocompletion: true, lineNumbers: true, diff --git a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx index 60f9d26d56..2e87204cdb 100644 --- a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx @@ -296,9 +296,9 @@ const AddMcpServerModal: FC = ({ placeholder={initialJsonExample} language="json" onChange={handleEditorChange} - maxHeight="300px" - expanded - unwrapped={false} + height="60vh" + expanded={false} + wrapped options={{ lint: true, lineNumbers: true, diff --git a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx index f648c492ee..fd02d9ae45 100644 --- a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx @@ -134,8 +134,8 @@ const PopupContainer: React.FC = ({ resolve }) => { language="json" onChange={(value) => setJsonConfig(value)} height="60vh" - expanded - unwrapped={false} + expanded={false} + wrapped options={{ lint: true, lineNumbers: true, diff --git a/src/renderer/src/pages/settings/ProviderSettings/CustomHeaderPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/CustomHeaderPopup.tsx index 3541aa64d3..6280167714 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/CustomHeaderPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/CustomHeaderPopup.tsx @@ -78,8 +78,9 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { language="json" onChange={(value) => setHeaderText(value)} placeholder={`{\n "Header-Name": "Header-Value"\n}`} - expanded - unwrapped={false} + height="60vh" + expanded={false} + wrapped options={{ lint: true, lineNumbers: true,