refactor(Preview,CodeBlock): preview components and tools (#8565)

* refactor(CodeBlockView): generalize tool and preview

Generalize code tool to action tool
- CodeTool -> ActionTool
- usePreviewTools -> useImagePreview
- rename code tool classname from icon to tool-icon

Generalize preview
- move image preview components to Preview dir
- simplify file names

* refactor(useImageTools): simplify implementation, add pan

* refactor(Preview): move image tools to floating toolbar

* refactor: add enableDrag, enable zooming for SvgPreview

* test: add tests for preview components

* feat(Preview): add download buttons to dropdown

* refactor(Preview): remove setTools from preview, improve SvgPreview

* refactor: add useTemporaryValue

* test: add tests for hooks

* test: add tests for CodeToolButton and CodeToolbar

* refactor(PreviewTool): add a setting item to enable preview tools

* test: update snapshot

* refactor: extract more code tools to hooks, add tests

* refactor: extract tools from CodeEditor and CodeViewer

* test: add tests for new tool hooks

* refactor(CodeEditor): change collapsible to expanded, change wrappable to unwrapped

* refactor: migrate codePreview to codeViewer

* docs: CodeBlockView

* refactor: add custom file icons, center the reset button

* refactor: improve code quality

* refactor: improve migration by deprecating codePreview

* refactor: improve PlantUml and svgToCanvas

* fix: plantuml style

* test: fix tests

* fix: button icon

* refactor(SvgPreview): debounce rendering

* feat(PreviewTool): add a dialog tool

* fix: remove isValidPlantUML, improve plantuml rendering

* refactor: extract shadow dom renderer

* refactor: improve plantuml error messages

* test: add tests for ImageToolbar and ImageToolButton

* refactor: add ImagePreviewLayout to simplify layout and tests

* refactor: add useDebouncedRender, update docs

* chore: clean up unused props

* refactor: clean transformation before copy/download/preview

* refactor: update migrate version

* refactor: style refactoring and fixes

- show header background in split view
- fix status bar radius
- reset special view transformation on theme change
- fix wrap tool icon
- add a divider to split view
- improve split view toggling (switch back to previous view)
- revert copy tool to separate tools
- fix top border radius for special views

* refactor: move GraphvizPreview to shadow DOM

- use renderString
- keep renderSvgInShadowHost api consistent with others

* fix: tests, icons, deleted files

* refactor: use ResetIcon in ImageToolbar

* test: remove unnecessary tests

* fix: min height for special preview

* fix: update migrate
This commit is contained in:
one
2025-08-06 20:09:49 +08:00
committed by GitHub
parent 39d96a63ac
commit d05d1309ca
105 changed files with 7964 additions and 1627 deletions
+189 -104
View File
@@ -1,19 +1,30 @@
import { loggerService } from '@logger'
import CodeEditor from '@renderer/components/CodeEditor'
import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
import { LoadingIcon } from '@renderer/components/Icons'
import { ActionTool } from '@renderer/components/ActionTools'
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
import {
CodeToolbar,
useCopyTool,
useDownloadTool,
useExpandTool,
useRunTool,
useSaveTool,
useSplitViewTool,
useViewSourceTool,
useWrapTool
} from '@renderer/components/CodeToolbar'
import CodeViewer from '@renderer/components/CodeViewer'
import ImageViewer from '@renderer/components/ImageViewer'
import { BasicPreviewHandles } from '@renderer/components/Preview'
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings'
import { pyodideService } from '@renderer/services/PyodideService'
import { extractTitle } from '@renderer/utils/formats'
import { getExtensionByLanguage, isHtmlCode, isValidPlantUML } from '@renderer/utils/markdown'
import { getExtensionByLanguage, isHtmlCode } from '@renderer/utils/markdown'
import dayjs from 'dayjs'
import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react'
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import styled, { css } from 'styled-components'
import ImageViewer from '../ImageViewer'
import CodePreview from './CodePreview'
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
import HtmlArtifactsCard from './HtmlArtifactsCard'
import StatusBar from './StatusBar'
@@ -45,31 +56,83 @@ interface Props {
*/
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
const { t } = useTranslation()
const { codeEditor, codeExecution } = useSettings()
const { codeEditor, codeExecution, codeImageTools, codeCollapsible, codeWrappable } = useSettings()
const [viewState, setViewState] = useState({
mode: 'special' as ViewMode,
previousMode: 'special' as ViewMode
})
const { mode: viewMode } = viewState
const setViewMode = useCallback((newMode: ViewMode) => {
setViewState((current) => ({
mode: newMode,
// 当新模式不是 'split' 时才更新
previousMode: newMode !== 'split' ? newMode : current.previousMode
}))
}, [])
const toggleSplitView = useCallback(() => {
setViewState((current) => {
// 如果当前是 split 模式,恢复到上一个模式
if (current.mode === 'split') {
return { ...current, mode: current.previousMode }
}
return { mode: 'split', previousMode: current.mode }
})
}, [])
const [viewMode, setViewMode] = useState<ViewMode>('special')
const [isRunning, setIsRunning] = useState(false)
const [executionResult, setExecutionResult] = useState<{ text: string; image?: string } | null>(null)
const [tools, setTools] = useState<CodeTool[]>([])
const { registerTool, removeTool } = useCodeTool(setTools)
const [tools, setTools] = useState<ActionTool[]>([])
const isExecutable = useMemo(() => {
return codeExecution.enabled && language === 'python'
}, [codeExecution.enabled, language])
const sourceViewRef = useRef<CodeEditorHandles>(null)
const specialViewRef = useRef<BasicPreviewHandles>(null)
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
const isInSpecialView = useMemo(() => {
return hasSpecialView && viewMode === 'special'
}, [hasSpecialView, viewMode])
const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable)
// 重置用户操作
useEffect(() => {
setExpandOverride(!codeCollapsible)
}, [codeCollapsible])
// 重置用户操作
useEffect(() => {
setUnwrapOverride(!codeWrappable)
}, [codeWrappable])
const shouldExpand = useMemo(() => !codeCollapsible || expandOverride, [codeCollapsible, expandOverride])
const shouldUnwrap = useMemo(() => !codeWrappable || unwrapOverride, [codeWrappable, unwrapOverride])
const [sourceScrollHeight, setSourceScrollHeight] = useState(0)
const expandable = useMemo(() => {
return codeCollapsible && sourceScrollHeight > MAX_COLLAPSED_CODE_HEIGHT
}, [codeCollapsible, sourceScrollHeight])
const handleHeightChange = useCallback((height: number) => {
startTransition(() => {
setSourceScrollHeight((prev) => (prev === height ? prev : height))
})
}, [])
const handleCopySource = useCallback(() => {
navigator.clipboard.writeText(children)
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
}, [children, t])
const handleDownloadSource = useCallback(async () => {
const handleDownloadSource = useCallback(() => {
let fileName = ''
// 尝试提取 HTML 标题
@@ -82,7 +145,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
fileName = `${dayjs().format('YYYYMMDDHHmm')}`
}
const ext = await getExtensionByLanguage(language)
const ext = getExtensionByLanguage(language)
window.api.file.save(`${fileName}${ext}`, children)
}, [children, language])
@@ -106,101 +169,103 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
})
}, [children, codeExecution.timeoutMinutes])
useEffect(() => {
// 复制按钮
registerTool({
...TOOL_SPECS.copy,
icon: <Copy className="icon" />,
tooltip: t('code_block.copy.source'),
onClick: handleCopySource
})
const showPreviewTools = useMemo(() => {
return viewMode !== 'source' && hasSpecialView
}, [hasSpecialView, viewMode])
// 下载按钮
registerTool({
...TOOL_SPECS.download,
icon: <Download className="icon" />,
tooltip: t('code_block.download.source'),
onClick: handleDownloadSource
})
return () => {
removeTool(TOOL_SPECS.copy.id)
removeTool(TOOL_SPECS.download.id)
}
}, [handleCopySource, handleDownloadSource, registerTool, removeTool, t])
// 复制按钮
useCopyTool({
showPreviewTools,
previewRef: specialViewRef,
onCopySource: handleCopySource,
setTools
})
// 特殊视图的编辑按钮,在分屏模式下不可用
useEffect(() => {
if (!hasSpecialView || viewMode === 'split') return
// 下载按钮
useDownloadTool({
showPreviewTools,
previewRef: specialViewRef,
onDownloadSource: handleDownloadSource,
setTools
})
const viewSourceToolSpec = codeEditor.enabled ? TOOL_SPECS.edit : TOOL_SPECS['view-source']
// 特殊视图的编辑/查看源码按钮,在分屏模式下不可用
useViewSourceTool({
enabled: hasSpecialView,
editable: codeEditor.enabled,
viewMode,
onViewModeChange: setViewMode,
setTools
})
if (codeEditor.enabled) {
registerTool({
...viewSourceToolSpec,
icon: viewMode === 'source' ? <Eye className="icon" /> : <SquarePen className="icon" />,
tooltip: viewMode === 'source' ? t('code_block.preview.label') : t('code_block.edit.label'),
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
})
} else {
registerTool({
...viewSourceToolSpec,
icon: viewMode === 'source' ? <Eye className="icon" /> : <CodeXml className="icon" />,
tooltip: viewMode === 'source' ? t('code_block.preview.label') : t('code_block.preview.source'),
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
})
}
return () => removeTool(viewSourceToolSpec.id)
}, [codeEditor.enabled, hasSpecialView, viewMode, registerTool, removeTool, t])
// 特殊视图的分屏按钮
useEffect(() => {
if (!hasSpecialView) return
registerTool({
...TOOL_SPECS['split-view'],
icon: viewMode === 'split' ? <Square className="icon" /> : <SquareSplitHorizontal className="icon" />,
tooltip: viewMode === 'split' ? t('code_block.split.restore') : t('code_block.split.label'),
onClick: () => setViewMode(viewMode === 'split' ? 'special' : 'split')
})
return () => removeTool(TOOL_SPECS['split-view'].id)
}, [hasSpecialView, viewMode, registerTool, removeTool, t])
// 特殊视图存在时的分屏按钮
useSplitViewTool({
enabled: hasSpecialView,
viewMode,
onToggleSplitView: toggleSplitView,
setTools
})
// 运行按钮
useEffect(() => {
if (!isExecutable) return
useRunTool({
enabled: isExecutable,
isRunning,
onRun: handleRunScript,
setTools
})
registerTool({
...TOOL_SPECS.run,
icon: isRunning ? <LoadingIcon /> : <CirclePlay className="icon" />,
tooltip: t('code_block.run'),
onClick: () => !isRunning && handleRunScript()
})
// 源代码视图的展开/折叠按钮
useExpandTool({
enabled: !isInSpecialView,
expanded: shouldExpand,
expandable,
toggle: useCallback(() => setExpandOverride((prev) => !prev), []),
setTools
})
return () => isExecutable && removeTool(TOOL_SPECS.run.id)
}, [isExecutable, isRunning, handleRunScript, registerTool, removeTool, t])
// 源代码视图的自动换行按钮
useWrapTool({
enabled: !isInSpecialView,
unwrapped: shouldUnwrap,
wrappable: codeWrappable,
toggle: useCallback(() => setUnwrapOverride((prev) => !prev), []),
setTools
})
// 代码编辑器的保存按钮
useSaveTool({
enabled: codeEditor.enabled && !isInSpecialView,
sourceViewRef,
setTools
})
// 源代码视图组件
const sourceView = useMemo(() => {
if (codeEditor.enabled) {
return (
const sourceView = useMemo(
() =>
codeEditor.enabled ? (
<CodeEditor
className="source-view"
ref={sourceViewRef}
value={children}
language={language}
onSave={onSave}
onHeightChange={handleHeightChange}
options={{ stream: true }}
setTools={setTools}
expanded={shouldExpand}
unwrapped={shouldUnwrap}
/>
)
} else {
return (
<CodePreview language={language} setTools={setTools}>
) : (
<CodeViewer
className="source-view"
language={language}
expanded={shouldExpand}
unwrapped={shouldUnwrap}
onHeightChange={handleHeightChange}>
{children}
</CodePreview>
)
}
}, [children, codeEditor.enabled, language, onSave, setTools])
</CodeViewer>
),
[children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldUnwrap]
)
// 特殊视图组件映射
const specialView = useMemo(() => {
@@ -208,13 +273,12 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
if (!SpecialView) return null
// PlantUML 语法验证
if (language === 'plantuml' && !isValidPlantUML(children)) {
return null
}
return <SpecialView setTools={setTools}>{children}</SpecialView>
}, [children, language])
return (
<SpecialView ref={specialViewRef} enableToolbar={codeImageTools}>
{children}
</SpecialView>
)
}, [children, codeImageTools, language])
const renderHeader = useMemo(() => {
const langTag = '<' + language.toUpperCase() + '>'
@@ -227,7 +291,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
const showSourceView = !specialView || viewMode !== 'special'
return (
<SplitViewWrapper className="split-view-wrapper">
<SplitViewWrapper className="split-view-wrapper" $viewMode={viewMode}>
{showSpecialView && specialView}
{showSourceView && sourceView}
</SplitViewWrapper>
@@ -260,7 +324,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
position: relative;
width: 100%;
/* FIXME: 最小宽度用于解决两个问题。
* 一是 CodePreview 在气泡样式下的用户消息中无法撑开气泡,
* 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡,
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
*/
min-width: 45ch;
@@ -295,9 +359,10 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
border-top-right-radius: 8px;
margin-top: ${(props) => (props.$isInSpecialView ? '6px' : '0')};
height: ${(props) => (props.$isInSpecialView ? '16px' : '34px')};
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
`
const SplitViewWrapper = styled.div`
const SplitViewWrapper = styled.div<{ $viewMode?: ViewMode }>`
display: flex;
> * {
@@ -306,7 +371,27 @@ const SplitViewWrapper = styled.div`
}
&:not(:has(+ [class*='Container'])) {
border-radius: 0 0 8px 8px;
// 特殊视图的 header 会隐藏,所以全都使用圆角
border-radius: ${(props) => (props.$viewMode === 'special' ? '8px' : '0 0 8px 8px')};
overflow: hidden;
}
// 在 split 模式下添加中间分隔线
${(props) =>
props.$viewMode === 'split' &&
css`
position: relative;
&:before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
background-color: var(--color-background-mute);
transform: translateX(-50%);
z-index: 1;
}
`}
`