feat(web): Support html/svg preview (#2518)

This commit is contained in:
Fangyin Cheng
2025-03-25 11:41:18 +08:00
committed by GitHub
parent 1ec855fd79
commit 99ce1ed992
94 changed files with 897 additions and 261 deletions

View File

@@ -16,6 +16,8 @@ import VisResponse from './VisResponse';
import AgentMessages from './agent-messages';
import AgentPlans from './agent-plans';
import { CodePreview } from './code-preview';
import HtmlPreview from './html-preview';
import SvgPreview from './svg-preview';
import VisChart from './vis-chart';
import VisCode from './vis-code';
import VisConvertError from './vis-convert-error';
@@ -189,6 +191,33 @@ const codeComponents = {
const _lang = className?.replace('language-', '') || 'javascript';
return <VisThinking content={content} />;
},
// Add HTML language processor
html: ({ className, children }) => {
const content = String(children);
const _lang = className;
return <HtmlPreview code={content} language='html' />;
},
// Support for Web languages that mix HTML, CSS, and JS
web: ({ className, children }) => {
const content = String(children);
const _lang = className;
return <HtmlPreview code={content} language='html' />;
},
svg: ({ className, children }) => {
const content = String(children);
const _lang = className;
return <SvgPreview code={content} language='svg' />;
},
xml: ({ className, children }) => {
const content = String(children);
const _lang = className;
// Check if the content is SVG
if (content.includes('<svg') && content.includes('</svg>')) {
return <SvgPreview code={content} language='svg' />;
}
// If it is not SVG, use normal XML highlighting
return <CodePreview code={content} language='xml' />;
},
},
defaultRenderer({ node, className, children, style, ...props }) {
const content = String(children);

View File

@@ -0,0 +1,330 @@
import {
CopyOutlined,
DownloadOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { Button, Modal, Tabs } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CodePreview } from './code-preview';
/**
* The HTML preview component is used to display HTML code and provide run, download, and full-screen functionality
* @param {Object} props The component props
* @param {string} props.code HTML code content
* @param {string} props.language Code language, default is html
*/
const HtmlPreview = ({ code, language = 'html' }) => {
const [isModalVisible, setIsModalVisible] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const iframeRef = useRef(null);
const { t } = useTranslation();
const [parsedCode, setParsedCode] = useState({
html: '',
css: '',
js: '',
fullCode: '',
});
// Parse the code and extract the HTML, CSS, and JS parts
useEffect(() => {
const parseCode = sourceCode => {
let html = sourceCode;
let css = '';
let js = '';
// Parse the content inside the <style> tags
const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi;
const styleMatches = [...sourceCode.matchAll(styleRegex)];
if (styleMatches.length > 0) {
// Remove the style tags
styleMatches.forEach(match => {
css += match[1] + '\n';
html = html.replace(match[0], '');
});
}
// Parse the content inside the <script> tags
const scriptRegex = /<script[^>]*>([\s\S]*?)<\/script>/gi;
const scriptMatches = [...sourceCode.matchAll(scriptRegex)];
if (scriptMatches.length > 0) {
// Remove the script tags
scriptMatches.forEach(match => {
js += match[1] + '\n';
html = html.replace(match[0], '');
});
}
// Create the full HTML document
let fullCode = sourceCode;
// If it's not a complete HTML document, wrap it
if (!sourceCode.includes('<!DOCTYPE html>') && !sourceCode.includes('<html')) {
fullCode = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML Preview</title>
${styleMatches.length > 0 ? styleMatches.map(m => m[0]).join('\n') : ''}
</head>
<body>
${html}
${scriptMatches.length > 0 ? scriptMatches.map(m => m[0]).join('\n') : ''}
</body>
</html>`;
}
return {
html,
css,
js,
fullCode,
};
};
setParsedCode(parseCode(code));
}, [code]);
// Listen for fullscreen change events
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement,
);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
};
}, []);
const showModal = () => {
setIsModalVisible(true);
};
const handleCancel = () => {
// If in full screen mode, exit first
if (isFullscreen) {
exitFullscreen();
}
setIsModalVisible(false);
};
const copyToClipboard = () => {
navigator.clipboard.writeText(code).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
});
};
// Download the HTML file
const downloadHTML = () => {
// Create a Blob object
const blob = new Blob([parsedCode.fullCode], { type: 'text/html' });
// Create a URL object
const url = URL.createObjectURL(blob);
// Create an a tag
const a = document.createElement('a');
a.href = url;
a.download = 'preview.html'; // File name
// Add to body and trigger click
document.body.appendChild(a);
a.click();
// Clean up
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Toggle fullscreen mode
const toggleFullscreen = () => {
if (isFullscreen) {
exitFullscreen();
} else {
enterFullscreen();
}
};
// Enter fullscreen mode
const enterFullscreen = () => {
const elem = iframeRef.current;
if (!elem) return;
if (elem.requestFullscreen) {
elem.requestFullscreen();
} else if (elem.webkitRequestFullscreen) {
/* Safari */
elem.webkitRequestFullscreen();
} else if (elem.msRequestFullscreen) {
/* IE11 */
elem.msRequestFullscreen();
} else if (elem.mozRequestFullScreen) {
/* Firefox */
elem.mozRequestFullScreen();
}
};
// Exit fullscreen mode
const exitFullscreen = () => {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
/* Safari */
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
/* IE11 */
document.msExitFullscreen();
} else if (document.mozCancelFullScreen) {
/* Firefox */
document.mozCancelFullScreen();
}
};
// Create tab items
const getTabItems = () => {
const items = [
{
key: 'preview',
label: t('code_preview'),
children: (
<div className='relative'>
<iframe
ref={iframeRef}
srcDoc={parsedCode.fullCode}
style={{ width: '100%', height: '60vh', border: 'none' }}
sandbox='allow-scripts allow-same-origin'
title='HTML Preview'
/>
<Button
type='primary'
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={toggleFullscreen}
className='absolute top-2 right-2 z-10'
size='small'
>
{isFullscreen ? t('code_preview_exit_full_screen') : t('code_preview_full_screen')}
</Button>
</div>
),
},
];
// Only show the code tab when the code is parsed into multiple parts
if (parsedCode.html || parsedCode.css || parsedCode.js) {
items.push({
key: 'code',
label: t('code_preview_code'),
children: (
<div className='p-4'>
{parsedCode.html && (
<div className='mb-4'>
<h3 className='text-lg font-medium mb-2'>HTML</h3>
<CodePreview code={parsedCode.html} language='html' />
</div>
)}
{parsedCode.css && (
<div className='mb-4'>
<h3 className='text-lg font-medium mb-2'>CSS</h3>
<CodePreview code={parsedCode.css} language='css' />
</div>
)}
{parsedCode.js && (
<div className='mb-4'>
<h3 className='text-lg font-medium mb-2'>JavaScript</h3>
<CodePreview code={parsedCode.js} language='javascript' />
</div>
)}
</div>
),
});
}
return items;
};
return (
<div className='relative'>
{/* Code preview section */}
<CodePreview code={code} language={language} />
{/* Operation button */}
<div className='absolute bottom-2 right-2 flex gap-2'>
<Button
type='text'
icon={<CopyOutlined />}
onClick={copyToClipboard}
className='flex items-center justify-center bg-opacity-70 hover:bg-opacity-100 transition-all'
size='small'
>
{isCopied ? t('code_preview_already_copied') : t('code_preview_copy')}
</Button>
<Button
type='text'
icon={<DownloadOutlined />}
onClick={downloadHTML}
className='flex items-center justify-center bg-opacity-70 hover:bg-opacity-100 transition-all'
size='small'
>
{t('code_preview_download')}
</Button>
<Button
type='primary'
icon={<PlayCircleOutlined />}
onClick={showModal}
className='flex items-center justify-center bg-opacity-70 hover:bg-opacity-100 transition-all'
size='small'
>
{t('code_preview_run')}
</Button>
</div>
{/* Run preview modal */}
<Modal
title={'HTML ' + t('code_preview')}
open={isModalVisible}
onCancel={handleCancel}
footer={[
<Button key='download' icon={<DownloadOutlined />} onClick={downloadHTML}>
{t('code_preview_download')} HTML
</Button>,
<Button
key='fullscreen'
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={toggleFullscreen}
>
{isFullscreen ? t('code_preview_exit_full_screen') : t('code_preview_full_screen')}
</Button>,
<Button key='close' onClick={handleCancel}>
{t('code_preview_close')}
</Button>,
]}
width={800}
bodyStyle={{ padding: 0 }}
>
<Tabs defaultActiveKey='preview' items={getTabItems()} />
</Modal>
</div>
);
};
export default HtmlPreview;

View File

@@ -0,0 +1,299 @@
import {
CopyOutlined,
DownloadOutlined,
PlayCircleOutlined,
ReloadOutlined,
ZoomInOutlined,
ZoomOutOutlined,
} from '@ant-design/icons';
import { Button, Modal, Slider, Space, Tabs } from 'antd';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CodePreview } from './code-preview';
/**
* SVG preview component is used to display SVG code and provide preview and download functionality
* @param {Object} props The component props
* @param {string} props.code SVG code content
* @param {string} props.language Code language, default is svg
*/
const SvgPreview = ({ code, language = 'svg' }) => {
const [isModalVisible, setIsModalVisible] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const [zoom, setZoom] = useState(100);
const { t } = useTranslation();
// Clean up SVG code (remove XML declaration, etc.)
const cleanSvgCode = svgCode => {
// Remove XML declaration
let cleaned = svgCode.replace(/<\?xml[^>]*\?>/g, '');
// Fix incomplete SVG tags
if (!cleaned.includes('<svg')) {
cleaned = `<svg xmlns="http://www.w3.org/2000/svg">${cleaned}</svg>`;
}
// Make sure there is a correct xmlns attribute
if (!cleaned.includes('xmlns=') && cleaned.includes('<svg')) {
cleaned = cleaned.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
}
// Make sure there is a viewBox attribute (if not)
if (!cleaned.includes('viewBox') && cleaned.includes('<svg')) {
// Try to create a viewBox from width and height
const widthMatch = cleaned.match(/width=["']([^"']*)["']/);
const heightMatch = cleaned.match(/height=["']([^"']*)["']/);
if (widthMatch && heightMatch) {
const width = widthMatch[1].replace(/[^\d.]/g, '');
const height = heightMatch[1].replace(/[^\d.]/g, '');
if (width && height) {
cleaned = cleaned.replace('<svg', `<svg viewBox="0 0 ${width} ${height}"`);
}
} else {
// If no width and height, add a default viewBox
cleaned = cleaned.replace('<svg', '<svg viewBox="0 0 800 600"');
}
}
// Make sure the closing tag is complete
if (!cleaned.includes('</svg>')) {
cleaned = `${cleaned}</svg>`;
}
return cleaned;
};
// Get SVG content
const getSvgContent = () => {
// Create a regex to match the SVG tag
const svgRegex = /<svg[\s\S]*<\/svg>/im;
const match = code.match(svgRegex);
if (match) {
// If a complete SVG tag is found, use only that part
return cleanSvgCode(match[0]);
} else {
// Otherwise, try to clean up the whole code
return cleanSvgCode(code);
}
};
const showModal = () => {
setIsModalVisible(true);
};
const handleCancel = () => {
setIsModalVisible(false);
// Reset zoom
setZoom(100);
};
const copyToClipboard = () => {
navigator.clipboard.writeText(code).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
});
};
// Download SVG file
const downloadSVG = () => {
// Create a Blob object
const blob = new Blob([getSvgContent()], { type: 'image/svg+xml' });
// Create a URL object
const url = URL.createObjectURL(blob);
// Create an a tag
const a = document.createElement('a');
a.href = url;
a.download = 'image.svg'; // 文件名
// Add to body and trigger click
document.body.appendChild(a);
a.click();
// Clean up
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Download PNG file
const downloadPNG = () => {
// Create a canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Create an image element
const img = new Image();
const svgBlob = new Blob([getSvgContent()], { type: 'image/svg+xml' });
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
// Resize the canvas to match the SVG size
canvas.width = img.width;
canvas.height = img.height;
// Draw the SVG to the canvas
ctx.drawImage(img, 0, 0);
// Try to convert to PNG and download
try {
const pngUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = pngUrl;
a.download = 'image.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch (e) {
console.error('PNG export failed:', e);
}
// Clean up
URL.revokeObjectURL(url);
};
img.src = url;
};
// Control zoom
const handleZoomChange = value => {
setZoom(value);
};
const zoomIn = () => {
setZoom(Math.min(zoom + 10, 200));
};
const zoomOut = () => {
setZoom(Math.max(zoom - 10, 50));
};
const resetZoom = () => {
setZoom(100);
};
// Create tab items
const getTabItems = () => {
const items = [
{
key: 'preview',
label: t('code_preview'),
children: (
<div className='relative'>
<div className='flex justify-center items-center p-4 bg-gray-100 dark:bg-gray-800 min-h-[60vh] overflow-auto'>
<div className='relative bg-white dark:bg-gray-700 p-4 shadow-md rounded flex items-center justify-center'>
<div
className='transition-transform duration-200'
style={{
transform: `scale(${zoom / 100})`,
transformOrigin: 'center center',
maxWidth: '100%',
maxHeight: '100%',
}}
>
<div
className='svg-container'
dangerouslySetInnerHTML={{ __html: getSvgContent() }}
style={{
maxWidth: '100%',
margin: '0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
</div>
</div>
</div>
<div className='flex items-center justify-center p-4 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'>
<Space>
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} disabled={zoom <= 50} />
<Slider min={50} max={200} value={zoom} onChange={handleZoomChange} style={{ width: 200 }} />
<Button icon={<ZoomInOutlined />} onClick={zoomIn} disabled={zoom >= 200} />
<Button icon={<ReloadOutlined />} onClick={resetZoom} disabled={zoom === 100} />
<span className='text-sm text-gray-500 dark:text-gray-400 min-w-[50px]'>{zoom}%</span>
</Space>
</div>
</div>
),
},
{
key: 'code',
label: t('code_preview_code'),
children: (
<div className='p-4'>
<CodePreview code={code} language='svg' />
</div>
),
},
];
return items;
};
return (
<div className='relative'>
{/* Code preview section */}
<CodePreview code={code} language={language} />
{/* Operation button */}
<div className='absolute bottom-2 right-2 flex gap-2'>
<Button
type='text'
icon={<CopyOutlined />}
onClick={copyToClipboard}
className='flex items-center justify-center bg-opacity-70 hover:bg-opacity-100 transition-all'
size='small'
>
{isCopied ? t('code_preview_already_copied') : t('code_preview_copy')}
</Button>
<Button
type='text'
icon={<DownloadOutlined />}
onClick={downloadSVG}
className='flex items-center justify-center bg-opacity-70 hover:bg-opacity-100 transition-all'
size='small'
>
{t('code_preview_download')}
</Button>
<Button
type='primary'
icon={<PlayCircleOutlined />}
onClick={showModal}
className='flex items-center justify-center bg-opacity-70 hover:bg-opacity-100 transition-all'
size='small'
>
{t('code_preview')}
</Button>
</div>
{/* Preview modal */}
<Modal
title={'SVG ' + t('code_preview')}
open={isModalVisible}
onCancel={handleCancel}
footer={[
<Button key='svg' icon={<DownloadOutlined />} onClick={downloadSVG}>
{t('code_preview_download')} SVG
</Button>,
<Button key='png' onClick={downloadPNG}>
{t('code_preview_download')} PNG
</Button>,
<Button key='close' onClick={handleCancel}>
{t('code_preview_close')}
</Button>,
]}
width={800}
bodyStyle={{ padding: 0 }}
>
<Tabs defaultActiveKey='preview' items={getTabItems()} />
</Modal>
</div>
);
};
export default SvgPreview;