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"
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`

View File

@ -100,7 +100,7 @@ export const CodeBlockView: React.FC<Props> = 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<Props> = 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<Props> = 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<Props> = 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}
/>
) : (
<CodeViewer
className="source-view"
language={language}
expanded={shouldExpand}
unwrapped={shouldUnwrap}
wrapped={shouldWrap}
onHeightChange={handleHeightChange}>
{children}
</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'])) {
// 特殊视图的 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;
}
}

View File

@ -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<CodeEditorHandles | null>
/** 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}

View File

@ -50,7 +50,7 @@ describe('useWrapTool', () => {
const createMockProps = (overrides: Partial<Parameters<typeof useWrapTool>[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)

View File

@ -5,13 +5,13 @@ import { useTranslation } from 'react-i18next'
interface UseWrapToolProps {
enabled?: boolean
unwrapped?: boolean
wrapped?: boolean
wrappable?: boolean
toggle: () => void
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 { registerTool, removeTool } = useToolManager(setTools)
@ -23,13 +23,13 @@ export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }:
if (enabled) {
registerTool({
...TOOL_SPECS.wrap,
icon: unwrapped ? <WrapIcon className="tool-icon" /> : <UnWrapIcon className="tool-icon" />,
tooltip: unwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
icon: wrapped ? <UnWrapIcon className="tool-icon" /> : <WrapIcon className="tool-icon" />,
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])
}

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

@ -78,8 +78,9 @@ const PopupContainer: React.FC<Props> = ({ 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,