mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 21:42:27 +08:00
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:
parent
4f620aed8d
commit
d4e024f42d
@ -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`
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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')};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user