From 33ec5c5c6b6dd754c139313dd922ec1632fe5a1e Mon Sep 17 00:00:00 2001 From: one Date: Sun, 17 Aug 2025 19:42:40 +0800 Subject: [PATCH] refactor: improve html artifact style (#9242) * refactor: use code font family in HtmlArtifactsCard * fix: pass onSave to HtmlArtifactsPopup * feat: add a save button * fix: avoid extra blank lines * feat: make split view resizable * refactor: improve streaming check, simplify Markdown component * refactor: improve button style and icons * test: update snapshots, add tests * refactor: move font family to TerminalPreview * test: update * refactor: add explicit type for Node * refactor: remove min-height * fix: type * refactor: improve scrollbar and splitter style --- src/renderer/src/assets/styles/ant.scss | 25 +++ .../CodeBlockView/HtmlArtifactsCard.tsx | 110 ++-------- .../CodeBlockView/HtmlArtifactsPopup.tsx | 206 ++++++++++-------- .../src/components/CodeBlockView/index.ts | 1 + .../src/components/CodeBlockView/view.tsx | 8 +- src/renderer/src/context/AntdProvider.tsx | 5 + .../src/pages/home/Markdown/CodeBlock.tsx | 51 ++++- src/renderer/src/pages/home/Markdown/Link.tsx | 3 +- .../src/pages/home/Markdown/Markdown.tsx | 20 +- .../src/pages/home/Markdown/Table.tsx | 3 +- .../Markdown/__tests__/CodeBlock.test.tsx | 147 +++++++++++++ .../home/Markdown/__tests__/Markdown.test.tsx | 29 +-- .../home/Markdown/__tests__/Table.test.tsx | 4 +- .../__snapshots__/CodeBlock.test.tsx.snap | 16 ++ .../__snapshots__/Markdown.test.tsx.snap | 7 +- .../src/utils/__tests__/markdown.test.ts | 4 +- src/renderer/src/utils/markdown.ts | 54 +++-- 17 files changed, 414 insertions(+), 279 deletions(-) create mode 100644 src/renderer/src/pages/home/Markdown/__tests__/CodeBlock.test.tsx create mode 100644 src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CodeBlock.test.tsx.snap diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index 7a2a2ce271..9ebc658010 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -184,3 +184,28 @@ box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important; } } + +.ant-splitter-bar { + .ant-splitter-bar-dragger { + &::before { + background-color: var(--color-border) !important; + transition: + background-color 0.15s ease, + width 0.15s ease; + } + &:hover { + &::before { + width: 4px !important; + background-color: var(--color-primary) !important; + transition-delay: 0.15s; + } + } + } + + .ant-splitter-bar-dragger-active { + &::before { + width: 4px !important; + background-color: var(--color-primary) !important; + } + } +} diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx index 67b583e9e6..3a82db90fa 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx @@ -1,11 +1,11 @@ -import { CodeOutlined, LinkOutlined } from '@ant-design/icons' +import { CodeOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { useTheme } from '@renderer/context/ThemeProvider' import { ThemeMode } from '@renderer/types' import { extractTitle } from '@renderer/utils/formats' import { Button } from 'antd' -import { Code, Download, Globe, Sparkles } from 'lucide-react' -import { FC, useMemo, useState } from 'react' +import { Code, DownloadIcon, Globe, LinkIcon, Sparkles } from 'lucide-react' +import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' import { ClipLoader } from 'react-spinners' import styled, { keyframes } from 'styled-components' @@ -14,92 +14,10 @@ import HtmlArtifactsPopup from './HtmlArtifactsPopup' const logger = loggerService.withContext('HtmlArtifactsCard') -const HTML_VOID_ELEMENTS = new Set([ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr' -]) - -const HTML_COMPLETION_PATTERNS = [ - /<\/html\s*>/i, - //i, - /<\/div\s*>/i, - /<\/script\s*>/i, - /<\/style\s*>/i -] - interface Props { html: string -} - -function hasUnmatchedTags(html: string): boolean { - const stack: string[] = [] - const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g - let match - - while ((match = tagRegex.exec(html)) !== null) { - const [fullTag, tagName] = match - const isClosing = fullTag.startsWith('') || HTML_VOID_ELEMENTS.has(tagName.toLowerCase()) - - if (isSelfClosing) continue - - if (isClosing) { - if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) { - return true - } - } else { - stack.push(tagName.toLowerCase()) - } - } - - return stack.length > 0 -} - -function checkIsStreaming(html: string): boolean { - if (!html?.trim()) return false - - const trimmed = html.trim() - - // 快速检查:如果有明显的完成标志,直接返回false - for (const pattern of HTML_COMPLETION_PATTERNS) { - if (pattern.test(trimmed)) { - // 特殊情况:同时有DOCTYPE和 - if (trimmed.includes('/i.test(trimmed)) { - return false - } - // 如果只是以结尾,也认为是完成的 - if (/<\/html\s*>$/i.test(trimmed)) { - return false - } - } - } - - // 检查未完成的标志 - const hasIncompleteTag = /<[^>]*$/.test(trimmed) - const hasUnmatched = hasUnmatchedTags(trimmed) - - if (hasIncompleteTag || hasUnmatched) return true - - // 对于简单片段,如果长度较短且没有明显结束标志,可能还在生成 - const hasStructureTags = /<(html|body|head)[^>]*>/i.test(trimmed) - if (!hasStructureTags && trimmed.length < 500) { - return !HTML_COMPLETION_PATTERNS.some((pattern) => pattern.test(trimmed)) - } - - return false + onSave?: (html: string) => void + isStreaming?: boolean } const getTerminalStyles = (theme: ThemeMode) => ({ @@ -108,7 +26,7 @@ const getTerminalStyles = (theme: ThemeMode) => ({ promptColor: theme === 'dark' ? '#00ff00' : '#007700' }) -const HtmlArtifactsCard: FC = ({ html }) => { +const HtmlArtifactsCard: FC = ({ html, onSave, isStreaming = false }) => { const { t } = useTranslation() const title = extractTitle(html) || 'HTML Artifacts' const [isPopupOpen, setIsPopupOpen] = useState(false) @@ -116,7 +34,6 @@ const HtmlArtifactsCard: FC = ({ html }) => { const htmlContent = html || '' const hasContent = htmlContent.trim().length > 0 - const isStreaming = useMemo(() => checkIsStreaming(htmlContent), [htmlContent]) const handleOpenExternal = async () => { const path = await window.api.file.createTempFile('artifacts-preview.html') @@ -181,10 +98,10 @@ const HtmlArtifactsCard: FC = ({ html }) => { - - @@ -192,7 +109,13 @@ const HtmlArtifactsCard: FC = ({ html }) => { - setIsPopupOpen(false)} /> + setIsPopupOpen(false)} + /> ) } @@ -286,7 +209,6 @@ const ButtonContainer = styled.div` margin: 10px 16px !important; display: flex; flex-direction: row; - gap: 8px; ` const TerminalPreview = styled.div<{ $theme: ThemeMode }>` @@ -294,7 +216,7 @@ const TerminalPreview = styled.div<{ $theme: ThemeMode }>` background: ${(props) => getTerminalStyles(props.$theme).background}; border-radius: 8px; overflow: hidden; - font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + font-family: var(--code-font-family); ` const TerminalContent = styled.div<{ $theme: ThemeMode }>` diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index dba34f29a7..a548d5e163 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -1,8 +1,8 @@ -import CodeEditor from '@renderer/components/CodeEditor' +import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor' import { isLinux, isMac, isWin } from '@renderer/config/constant' import { classNames } from '@renderer/utils' -import { Button, Modal } from 'antd' -import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react' +import { Button, Modal, Splitter, Tooltip } from 'antd' +import { Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -11,60 +11,17 @@ interface HtmlArtifactsPopupProps { open: boolean title: string html: string + onSave?: (html: string) => void onClose: () => void } type ViewMode = 'split' | 'code' | 'preview' -const HtmlArtifactsPopup: React.FC = ({ open, title, html, onClose }) => { +const HtmlArtifactsPopup: React.FC = ({ open, title, html, onSave, onClose }) => { const { t } = useTranslation() const [viewMode, setViewMode] = useState('split') - const [currentHtml, setCurrentHtml] = useState(html) const [isFullscreen, setIsFullscreen] = useState(false) - - // Preview refresh related state - const [previewHtml, setPreviewHtml] = useState(html) - const intervalRef = useRef(null) - const latestHtmlRef = useRef(html) - const currentPreviewHtmlRef = useRef(html) - - // Sync internal state when external html updates - useEffect(() => { - setCurrentHtml(html) - latestHtmlRef.current = html - }, [html]) - - // Update reference when internally edited html changes - useEffect(() => { - latestHtmlRef.current = currentHtml - }, [currentHtml]) - - // Update reference when preview content changes - useEffect(() => { - currentPreviewHtmlRef.current = previewHtml - }, [previewHtml]) - - // Check and refresh preview every 2 seconds (only when content changes) - useEffect(() => { - if (!open) return - - // Set initial preview content immediately - setPreviewHtml(latestHtmlRef.current) - - // Set timer to check for content changes every 2 seconds - intervalRef.current = setInterval(() => { - if (latestHtmlRef.current !== currentPreviewHtmlRef.current) { - setPreviewHtml(latestHtmlRef.current) - } - }, 2000) - - // Cleanup function - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current) - } - } - }, [open]) + const codeEditorRef = useRef(null) // Prevent body scroll when fullscreen useEffect(() => { @@ -79,8 +36,9 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht } }, [isFullscreen, open]) - const showCode = viewMode === 'split' || viewMode === 'code' - const showPreview = viewMode === 'split' || viewMode === 'preview' + const handleSave = () => { + codeEditorRef.current?.save?.() + } const renderHeader = () => ( setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}> @@ -93,7 +51,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht } + icon={} onClick={() => setViewMode('split')}> {t('html_artifacts.split')} @@ -107,7 +65,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht } + icon={} onClick={() => setViewMode('preview')}> {t('html_artifacts.preview')} @@ -126,6 +84,75 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht ) + const renderContent = () => { + const codePanel = ( + + + + +