feat: Add code block wrapping functionality (#2411)
Signed-off-by: suyao <sy20010504@gmail.com>
This commit is contained in:
parent
309b66e4df
commit
93c2a94658
17
src/renderer/src/components/Icons/UnWrapIcon.tsx
Normal file
17
src/renderer/src/components/Icons/UnWrapIcon.tsx
Normal 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
|
||||
20
src/renderer/src/components/Icons/WrapIcon.tsx
Normal file
20
src/renderer/src/components/Icons/WrapIcon.tsx
Normal 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
|
||||
@ -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?",
|
||||
|
||||
@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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": "Вы уверены, что хотите создать резервную копию?",
|
||||
|
||||
@ -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": "确定要备份数据吗?",
|
||||
|
||||
@ -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": "確定要備份資料嗎?",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user