feat: Add code block wrapping functionality (#2411)

Signed-off-by: suyao <sy20010504@gmail.com>
This commit is contained in:
SuYao 2025-03-04 12:30:22 +08:00 committed by GitHub
parent 309b66e4df
commit 93c2a94658
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 153 additions and 9 deletions

View File

@ -0,0 +1,17 @@
const UnWrapIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
className="unwrap_svg__lucide unwrap_svg__lucide-text unwrap_svg__size-4"
viewBox="0 0 24 24"
{...props}>
<path d="M17 6.1H3M21 12.1H3M15.1 18H3" />
</svg>
)
export default UnWrapIcon

View File

@ -0,0 +1,20 @@
import React from 'react'
const WrapIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
className="wrap_svg__lucide wrap_svg__lucide-wrap-text wrap_svg__size-4"
viewBox="0 0 24 24"
{...props}>
<path d="M3 6h18M3 12h15a3 3 0 1 1 0 6h-4" />
<path d="m16 16-2 2 2 2M3 18h7" />
</svg>
)
export default WrapIcon

View File

@ -114,6 +114,7 @@
"resend": "Resend",
"save": "Save",
"settings.code_collapsible": "Code block collapsible",
"settings.code_wrappable": "Code block wrappable",
"settings.context_count": "Context",
"settings.context_count.tip": "The number of previous messages to keep in the context.",
"settings.max": "Max",
@ -890,6 +891,10 @@
"show_window": "Show Window",
"visualization": "Visualization"
},
"code_block": {
"enable_wrap": "Wrap",
"disable_wrap": "Unwrap"
},
"backup": {
"title": "Data Backup",
"confirm": "Are you sure you want to backup data?",

View File

@ -113,7 +113,8 @@
"message.quote": "引用",
"resend": "再送信",
"save": "保存",
"settings.code_collapsible": "コードブロックを折りたたむ",
"settings.code_collapsible": "コードブロック折り畳み",
"settings.code_wrappable": "コードブロック折り返し",
"settings.context_count": "コンテキスト",
"settings.context_count.tip": "コンテキストに保持する以前のメッセージの数",
"settings.max": "最大",
@ -890,6 +891,10 @@
"show_window": "ウィンドウを表示",
"visualization": "可視化"
},
"code_block": {
"enable_wrap": "改行",
"disable_wrap": "改行解除"
},
"backup": {
"title": "データバックアップ",
"confirm": "データをバックアップしますか?",
@ -920,3 +925,4 @@
}
}
}

View File

@ -114,6 +114,7 @@
"resend": "Переотправить",
"save": "Сохранить",
"settings.code_collapsible": "Блок кода свернут",
"settings.code_wrappable": "Блок кода можно переносить",
"settings.context_count": "Контекст",
"settings.context_count.tip": "Количество предыдущих сообщений, которые нужно сохранить в контексте.",
"settings.max": "Максимум",
@ -890,6 +891,10 @@
"show_window": "Показать окно",
"visualization": "Визуализация"
},
"code_block": {
"enable_wrap": "Перенос строки",
"disable_wrap": "Отменить перенос строки"
},
"backup": {
"title": "Резервное копирование данных",
"confirm": "Вы уверены, что хотите создать резервную копию?",

View File

@ -114,6 +114,7 @@
"resend": "重新发送",
"save": "保存",
"settings.code_collapsible": "代码块可折叠",
"settings.code_wrappable": "代码块可换行",
"settings.context_count": "上下文数",
"settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10",
"settings.max": "不限",
@ -890,6 +891,10 @@
"show_window": "显示窗口",
"visualization": "可视化"
},
"code_block": {
"enable_wrap": "换行",
"disable_wrap": "取消换行"
},
"backup": {
"title": "数据备份",
"confirm": "确定要备份数据吗?",

View File

@ -113,7 +113,8 @@
"message.quote": "引用",
"resend": "重新發送",
"save": "保存",
"settings.code_collapsible": "代码块可折叠",
"settings.code_collapsible": "代碼區塊可折疊",
"settings.code_wrappable": "代碼區塊可換行",
"settings.context_count": "上下文",
"settings.context_count.tip": "在上下文中保留的前幾則訊息。",
"settings.max": "最大",
@ -890,6 +891,10 @@
"show_window": "顯示視窗",
"visualization": "可視化"
},
"code_block": {
"enable_wrap": "換行",
"disable_wrap": "取消換行"
},
"backup": {
"title": "資料備份",
"confirm": "確定要備份資料嗎?",

View File

@ -1,8 +1,11 @@
import { CheckOutlined, DownloadOutlined, DownOutlined, RightOutlined } from '@ant-design/icons'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import UnWrapIcon from '@renderer/components/Icons/UnWrapIcon'
import WrapIcon from '@renderer/components/Icons/WrapIcon'
import { HStack } from '@renderer/components/Layout'
import { useSyntaxHighlighter } from '@renderer/context/SyntaxHighlighterProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { Tooltip } from 'antd'
import dayjs from 'dayjs'
import React, { memo, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -20,12 +23,13 @@ interface CodeBlockProps {
}
const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
const match = /language-(\w+)/.exec(className || '')
const { codeShowLineNumbers, fontSize, codeCollapsible } = useSettings()
const match = /language-(\w+)/.exec(className || '') || children?.includes('\n')
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
const language = match?.[1] ?? 'text'
const [html, setHtml] = useState<string>('')
const { codeToHtml } = useSyntaxHighlighter()
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false)
const codeContentRef = useRef<HTMLDivElement>(null)
@ -59,6 +63,15 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
}
}, [codeCollapsible])
useEffect(() => {
if (!codeWrappable) {
// 如果未启动代码块换行功能
setIsUnwrapped(true)
} else {
setIsUnwrapped(!codeWrappable) // 被换行
}
}, [codeWrappable])
if (language === 'mermaid') {
return <Mermaid chart={children} />
}
@ -86,16 +99,19 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
{codeCollapsible && shouldShowExpandButton && (
<CollapseIcon expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)} />
)}
<CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage>
<CodeLanguage>{'<' + language.toUpperCase() + '>'}</CodeLanguage>
</div>
<HStack gap={12} alignItems="center">
{showDownloadButton && <DownloadButton language={language} data={children} />}
{codeWrappable && <UnwrapButton unwrapped={isUnwrapped} onClick={() => setIsUnwrapped(!isUnwrapped)} />}
<CopyButton text={children} />
</HStack>
</CodeHeader>
<CodeContent
ref={codeContentRef}
isShowLineNumbers={codeShowLineNumbers}
isUnwrapped={isUnwrapped}
isCodeWrappable={codeWrappable}
dangerouslySetInnerHTML={{ __html: html }}
style={{
border: '0.5px solid var(--color-code-background)',
@ -149,6 +165,22 @@ const ExpandButton: React.FC<{
)
}
const UnwrapButton: React.FC<{ unwrapped: boolean; onClick: () => void }> = ({ unwrapped, onClick }) => {
const { t } = useTranslation()
const unwrapLabel = unwrapped ? t('code_block.enable_wrap') : t('code_block.disable_wrap')
return (
<Tooltip title={unwrapLabel}>
<UnwrapButtonWrapper onClick={onClick} title={unwrapLabel}>
{unwrapped ? (
<UnWrapIcon style={{ width: '100%', height: '100%' }} />
) : (
<WrapIcon style={{ width: '100%', height: '100%' }} />
)}
</UnwrapButtonWrapper>
</Tooltip>
)
}
const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ text, style }) => {
const [copied, setCopied] = useState(false)
const { t } = useTranslation()
@ -183,9 +215,19 @@ const DownloadButton = ({ language, data }: { language: string; data: string })
const CodeBlockWrapper = styled.div``
const CodeContent = styled.div<{ isShowLineNumbers: boolean }>`
const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>`
.shiki {
padding: 1em;
code {
display: table;
width: 100%;
.line {
display: table-row;
height: 1.3rem;
}
}
}
${(props) =>
@ -200,14 +242,23 @@ const CodeContent = styled.div<{ isShowLineNumbers: boolean }>`
content: counter(step);
counter-increment: step;
width: 1rem;
margin-right: 1rem;
display: inline-block;
padding-right: 1rem;
display: table-cell;
text-align: right;
opacity: 0.35;
}
`}
`
${(props) =>
props.isCodeWrappable &&
!props.isUnwrapped &&
`
code .line * {
word-wrap: break-word;
white-space: pre-wrap;
}
`}
`
const CodeHeader = styled.div`
display: flex;
align-items: center;
@ -290,6 +341,23 @@ const CollapseIconWrapper = styled.div`
}
`
const UnwrapButtonWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
cursor: pointer;
color: var(--color-text-3);
transition: all 0.2s ease;
&:hover {
background-color: var(--color-background-soft);
color: var(--color-text-1);
}
`
const DownloadWrapper = styled.div`
display: flex;
align-items: center;

View File

@ -19,6 +19,7 @@ import {
setCodeCollapsible,
setCodeShowLineNumbers,
setCodeStyle,
setCodeWrappable,
setFontSize,
setMathEngine,
setMessageFont,
@ -69,6 +70,7 @@ const SettingsTab: FC<Props> = (props) => {
renderInputMessageAsMarkdown,
codeShowLineNumbers,
codeCollapsible,
codeWrappable,
mathEngine,
autoTranslateWithSpace,
pasteLongTextThreshold,
@ -315,6 +317,11 @@ const SettingsTab: FC<Props> = (props) => {
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('chat.settings.code_wrappable')}</SettingRowTitleSmall>
<Switch size="small" checked={codeWrappable} onChange={(checked) => dispatch(setCodeWrappable(checked))} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.thought_auto_collapse')}

View File

@ -42,6 +42,7 @@ export interface SettingsState {
renderInputMessageAsMarkdown: boolean
codeShowLineNumbers: boolean
codeCollapsible: boolean
codeWrappable: boolean
mathEngine: 'MathJax' | 'KaTeX'
messageStyle: 'plain' | 'bubble'
codeStyle: CodeStyleVarious
@ -107,6 +108,7 @@ const initialState: SettingsState = {
renderInputMessageAsMarkdown: false,
codeShowLineNumbers: false,
codeCollapsible: false,
codeWrappable: false,
mathEngine: 'KaTeX',
messageStyle: 'plain',
codeStyle: 'auto',
@ -243,6 +245,9 @@ const settingsSlice = createSlice({
setCodeCollapsible: (state, action: PayloadAction<boolean>) => {
state.codeCollapsible = action.payload
},
setCodeWrappable: (state, action: PayloadAction<boolean>) => {
state.codeWrappable = action.payload
},
setMathEngine: (state, action: PayloadAction<'MathJax' | 'KaTeX'>) => {
state.mathEngine = action.payload
},
@ -359,6 +364,7 @@ export const {
setWebdavSyncInterval,
setCodeShowLineNumbers,
setCodeCollapsible,
setCodeWrappable,
setMathEngine,
setGridColumns,
setGridPopoverTrigger,