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
This commit is contained in:
one 2025-08-30 15:11:29 +08:00 committed by GitHub
parent 4f620aed8d
commit d4e024f42d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 102 additions and 60 deletions

View File

@ -145,9 +145,10 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
language="html" language="html"
editable={true} editable={true}
onSave={onSave} onSave={onSave}
style={{ height: '100%' }} height="100%"
expanded expanded={false}
unwrapped={false} wrapped
style={{ minHeight: 0 }}
options={{ options={{
stream: true, // FIXME: 避免多余空行 stream: true, // FIXME: 避免多余空行
lineNumbers: true, lineNumbers: true,
@ -388,12 +389,8 @@ const CodeSection = styled.div`
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
display: grid;
.monaco-editor, grid-template-rows: 1fr auto;
.cm-editor,
.cm-scroller {
height: 100% !important;
}
` `
const PreviewSection = styled.div` const PreviewSection = styled.div`

View File

@ -100,7 +100,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
}, [hasSpecialView, viewMode]) }, [hasSpecialView, viewMode])
const [expandOverride, setExpandOverride] = useState(!codeCollapsible) const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable) const [wrapOverride, setWrapOverride] = useState(codeWrappable)
// 重置用户操作 // 重置用户操作
useEffect(() => { useEffect(() => {
@ -109,11 +109,11 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
// 重置用户操作 // 重置用户操作
useEffect(() => { useEffect(() => {
setUnwrapOverride(!codeWrappable) setWrapOverride(codeWrappable)
}, [codeWrappable]) }, [codeWrappable])
const shouldExpand = useMemo(() => !codeCollapsible || expandOverride, [codeCollapsible, expandOverride]) 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 [sourceScrollHeight, setSourceScrollHeight] = useState(0)
const expandable = useMemo(() => { const expandable = useMemo(() => {
@ -225,9 +225,9 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
// 源代码视图的自动换行按钮 // 源代码视图的自动换行按钮
useWrapTool({ useWrapTool({
enabled: !isInSpecialView, enabled: !isInSpecialView,
unwrapped: shouldUnwrap, wrapped: shouldWrap,
wrappable: codeWrappable, wrappable: codeWrappable,
toggle: useCallback(() => setUnwrapOverride((prev) => !prev), []), toggle: useCallback(() => setWrapOverride((prev) => !prev), []),
setTools setTools
}) })
@ -249,21 +249,22 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
language={language} language={language}
onSave={onSave} onSave={onSave}
onHeightChange={handleHeightChange} onHeightChange={handleHeightChange}
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
options={{ stream: true }} options={{ stream: true }}
expanded={shouldExpand} expanded={shouldExpand}
unwrapped={shouldUnwrap} wrapped={shouldWrap}
/> />
) : ( ) : (
<CodeViewer <CodeViewer
className="source-view" className="source-view"
language={language} language={language}
expanded={shouldExpand} expanded={shouldExpand}
unwrapped={shouldUnwrap} wrapped={shouldWrap}
onHeightChange={handleHeightChange}> onHeightChange={handleHeightChange}>
{children} {children}
</CodeViewer> </CodeViewer>
), ),
[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'])) { &:not(:has(+ [class*='Container'])) {
// 特殊视图的 header 会隐藏,所以全都使用圆角 // 特殊视图的 header 会隐藏,所以全都使用圆角
border-radius: ${(props) => (props.$isSpecialView ? '8px' : '0 0 8px 8px')}; border-radius: ${(props) => (props.$isSpecialView ? '8px' : '0 0 8px 8px')};
// FIXME: 滚动条边缘会溢出,可以考虑增加 padding但是要保证代码主题颜色铺满容器。
// overflow: hidden;
.code-viewer { .code-viewer {
border-radius: 0 0 8px 8px; border-radius: inherit;
overflow: hidden;
} }
} }

View File

@ -1,4 +1,3 @@
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror' import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror'
@ -15,39 +14,81 @@ export interface CodeEditorHandles {
save?: () => void save?: () => void
} }
interface CodeEditorProps { export interface CodeEditorProps {
ref?: React.RefObject<CodeEditorHandles | null> ref?: React.RefObject<CodeEditorHandles | null>
/** Value used in controlled mode, e.g., code blocks. */
value: string value: string
/** Placeholder when the editor content is empty. */
placeholder?: string | HTMLElement placeholder?: string | HTMLElement
/** Code language, supports aliases. */
language: string language: string
/** Fired when ref.save() is called or the save shortcut is triggered. */
onSave?: (newContent: string) => void onSave?: (newContent: string) => void
/** Fired when the editor content changes. */
onChange?: (newContent: string) => void onChange?: (newContent: string) => void
/** Fired when the editor loses focus. */
onBlur?: (newContent: string) => void onBlur?: (newContent: string) => void
/** Fired when the editor height changes. */
onHeightChange?: (scrollHeight: number) => void onHeightChange?: (scrollHeight: number) => void
/**
* Fixed editor height, not exceeding maxHeight.
* Only works when expanded is false.
*/
height?: string height?: string
minHeight?: string /**
* Maximum editor height.
* Only works when expanded is false.
*/
maxHeight?: string maxHeight?: string
/** Minimum editor height. */
minHeight?: string
/** Font size that overrides the app setting. */
fontSize?: string fontSize?: string
/** 用于覆写编辑器的某些设置 */ /** Editor options that extend BasicSetupOptions. */
options?: { options?: {
stream?: boolean // 用于流式响应场景,默认 false /**
* Whether to enable special treatment for stream response.
* @default false
*/
stream?: boolean
/**
* Whether to enable linting.
* @default false
*/
lint?: boolean lint?: boolean
/**
* Whether to enable keymap.
* @default false
*/
keymap?: boolean keymap?: boolean
} & BasicSetupOptions } & BasicSetupOptions
/** 用于追加 extensions */ /** Additional extensions for CodeMirror. */
extensions?: Extension[] extensions?: Extension[]
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */ /** Style overrides for the editor, passed directly to CodeMirror's style property. */
style?: React.CSSProperties style?: React.CSSProperties
/** CSS class name appended to the default `code-editor` class. */
className?: string className?: string
/**
* Whether the editor is editable.
* @default true
*/
editable?: boolean editable?: boolean
/**
* Whether the editor is expanded.
* If true, the height and maxHeight props are ignored.
* @default true
*/
expanded?: boolean expanded?: boolean
unwrapped?: boolean /**
* Whether the code lines are wrapped.
* @default true
*/
wrapped?: boolean
} }
/** /**
* CodeMirror ReactCodeMirror * A code editor component based on CodeMirror.
* * This is a wrapper of ReactCodeMirror.
* CodeToolbar 使
*/ */
const CodeEditor = ({ const CodeEditor = ({
ref, ref,
@ -59,8 +100,8 @@ const CodeEditor = ({
onBlur, onBlur,
onHeightChange, onHeightChange,
height, height,
minHeight,
maxHeight, maxHeight,
minHeight,
fontSize, fontSize,
options, options,
extensions, extensions,
@ -68,7 +109,7 @@ const CodeEditor = ({
className, className,
editable = true, editable = true,
expanded = true, expanded = true,
unwrapped = false wrapped = true
}: CodeEditorProps) => { }: CodeEditorProps) => {
const { fontSize: _fontSize, codeShowLineNumbers: _lineNumbers, codeEditor } = useSettings() const { fontSize: _fontSize, codeShowLineNumbers: _lineNumbers, codeEditor } = useSettings()
const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap]) const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap])
@ -121,12 +162,12 @@ const CodeEditor = ({
return [ return [
...(extensions ?? []), ...(extensions ?? []),
...langExtensions, ...langExtensions,
...(unwrapped ? [] : [EditorView.lineWrapping]), ...(wrapped ? [EditorView.lineWrapping] : []),
saveKeymapExtension, saveKeymapExtension,
blurExtension, blurExtension,
heightListenerExtension heightListenerExtension
].flat() ].flat()
}, [extensions, langExtensions, unwrapped, saveKeymapExtension, blurExtension, heightListenerExtension]) }, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
save: handleSave save: handleSave
@ -138,9 +179,9 @@ const CodeEditor = ({
value={initialContent.current} value={initialContent.current}
placeholder={placeholder} placeholder={placeholder}
width="100%" width="100%"
height={height} height={expanded ? undefined : height}
maxHeight={expanded ? undefined : maxHeight}
minHeight={minHeight} minHeight={minHeight}
maxHeight={expanded ? 'none' : (maxHeight ?? `${MAX_COLLAPSED_CODE_HEIGHT}px`)}
editable={editable} editable={editable}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx // @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme} theme={activeCmTheme}

View File

@ -50,7 +50,7 @@ describe('useWrapTool', () => {
const createMockProps = (overrides: Partial<Parameters<typeof useWrapTool>[0]> = {}) => { const createMockProps = (overrides: Partial<Parameters<typeof useWrapTool>[0]> = {}) => {
const defaultProps = { const defaultProps = {
enabled: true, enabled: true,
unwrapped: false, wrapped: true,
wrappable: true, wrappable: true,
toggle: vi.fn(), toggle: vi.fn(),
setTools: vi.fn() setTools: vi.fn()
@ -90,8 +90,8 @@ describe('useWrapTool', () => {
expect(mockRegisterTool).not.toHaveBeenCalled() expect(mockRegisterTool).not.toHaveBeenCalled()
}) })
it('should re-register tool when unwrapped changes', () => { it('should re-register tool when wrapped changes', () => {
const props = createMockProps({ unwrapped: false }) const props = createMockProps({ wrapped: true })
const { rerender } = renderHook((hookProps) => useWrapTool(hookProps), { const { rerender } = renderHook((hookProps) => useWrapTool(hookProps), {
initialProps: props initialProps: props
}) })
@ -100,8 +100,8 @@ describe('useWrapTool', () => {
const firstCall = mockRegisterTool.mock.calls[0][0] const firstCall = mockRegisterTool.mock.calls[0][0]
expect(firstCall.tooltip).toBe('code_block.wrap.off') expect(firstCall.tooltip).toBe('code_block.wrap.off')
// Change unwrapped to true and rerender // Change wrapped to false and rerender
const newProps = { ...props, unwrapped: true } const newProps = { ...props, wrapped: false }
rerender(newProps) rerender(newProps)
expect(mockRegisterTool).toHaveBeenCalledTimes(2) expect(mockRegisterTool).toHaveBeenCalledTimes(2)

View File

@ -5,13 +5,13 @@ import { useTranslation } from 'react-i18next'
interface UseWrapToolProps { interface UseWrapToolProps {
enabled?: boolean enabled?: boolean
unwrapped?: boolean wrapped?: boolean
wrappable?: boolean wrappable?: boolean
toggle: () => void toggle: () => void
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>> setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
} }
export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }: UseWrapToolProps) => { export const useWrapTool = ({ enabled, wrapped, wrappable, toggle, setTools }: UseWrapToolProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerTool, removeTool } = useToolManager(setTools) const { registerTool, removeTool } = useToolManager(setTools)
@ -23,13 +23,13 @@ export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }:
if (enabled) { if (enabled) {
registerTool({ registerTool({
...TOOL_SPECS.wrap, ...TOOL_SPECS.wrap,
icon: unwrapped ? <WrapIcon className="tool-icon" /> : <UnWrapIcon className="tool-icon" />, icon: wrapped ? <UnWrapIcon className="tool-icon" /> : <WrapIcon className="tool-icon" />,
tooltip: unwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'), tooltip: wrapped ? t('code_block.wrap.off') : t('code_block.wrap.on'),
visible: () => wrappable ?? false, visible: () => wrappable ?? false,
onClick: handleToggle onClick: handleToggle
}) })
} }
return () => removeTool(TOOL_SPECS.wrap.id) return () => removeTool(TOOL_SPECS.wrap.id)
}, [enabled, handleToggle, registerTool, removeTool, t, unwrapped, wrappable]) }, [enabled, handleToggle, registerTool, removeTool, t, wrapped, wrappable])
} }

View File

@ -14,7 +14,7 @@ interface CodeViewerProps {
language: string language: string
children: string children: string
expanded?: boolean expanded?: boolean
unwrapped?: boolean wrapped?: boolean
onHeightChange?: (scrollHeight: number) => void onHeightChange?: (scrollHeight: number) => void
className?: string 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 { codeShowLineNumbers, fontSize } = useSettings()
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle() const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
const shikiThemeRef = useRef<HTMLDivElement>(null) const shikiThemeRef = useRef<HTMLDivElement>(null)
@ -108,7 +108,7 @@ const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, c
<ScrollContainer <ScrollContainer
ref={scrollerRef} ref={scrollerRef}
className="shiki-scroller" className="shiki-scroller"
$wrap={!unwrapped} $wrap={wrapped}
$expanded={expanded} $expanded={expanded}
$lineHeight={estimateSize()} $lineHeight={estimateSize()}
style={ style={
@ -257,6 +257,7 @@ const ScrollContainer = styled.div<{
.line-content { .line-content {
flex: 1; flex: 1;
padding-right: 1em; padding-right: 1em;
white-space: pre;
* { * {
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')}; white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};

View File

@ -132,8 +132,8 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
onBlur={onUpdate} onBlur={onUpdate}
height="calc(80vh - 202px)" height="calc(80vh - 202px)"
fontSize="var(--ant-font-size)" fontSize="var(--ant-font-size)"
expanded expanded={false}
unwrapped={false} wrapped
options={{ options={{
autocompletion: false, autocompletion: false,
keymap: true, keymap: true,

View File

@ -338,8 +338,8 @@ const DisplaySettings: FC = () => {
placeholder={t('settings.display.custom.css.placeholder')} placeholder={t('settings.display.custom.css.placeholder')}
onChange={(value) => dispatch(setCustomCss(value))} onChange={(value) => dispatch(setCustomCss(value))}
height="60vh" height="60vh"
expanded expanded={false}
unwrapped={false} wrapped
options={{ options={{
autocompletion: true, autocompletion: true,
lineNumbers: true, lineNumbers: true,

View File

@ -296,9 +296,9 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
placeholder={initialJsonExample} placeholder={initialJsonExample}
language="json" language="json"
onChange={handleEditorChange} onChange={handleEditorChange}
maxHeight="300px" height="60vh"
expanded expanded={false}
unwrapped={false} wrapped
options={{ options={{
lint: true, lint: true,
lineNumbers: true, lineNumbers: true,

View File

@ -134,8 +134,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
language="json" language="json"
onChange={(value) => setJsonConfig(value)} onChange={(value) => setJsonConfig(value)}
height="60vh" height="60vh"
expanded expanded={false}
unwrapped={false} wrapped
options={{ options={{
lint: true, lint: true,
lineNumbers: true, lineNumbers: true,

View File

@ -78,8 +78,9 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
language="json" language="json"
onChange={(value) => setHeaderText(value)} onChange={(value) => setHeaderText(value)}
placeholder={`{\n "Header-Name": "Header-Value"\n}`} placeholder={`{\n "Header-Name": "Header-Value"\n}`}
expanded height="60vh"
unwrapped={false} expanded={false}
wrapped
options={{ options={{
lint: true, lint: true,
lineNumbers: true, lineNumbers: true,