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:
@@ -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;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user