- 基于 Electron + React + TypeScript + Vite - Fabric.js 画布编辑引擎 - Ant Design UI 组件 + Zustand 状态管理 - 支持文字添加、拖拽、字体/颜色/特效编辑 - 支持 PNG / JPEG / WebP 导出
208 lines
6.4 KiB
TypeScript
208 lines
6.4 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
||
import {
|
||
Modal,
|
||
Select,
|
||
Slider,
|
||
InputNumber,
|
||
Button,
|
||
message,
|
||
Typography,
|
||
Space,
|
||
QRCode,
|
||
Divider,
|
||
} from 'antd'
|
||
import { DownloadOutlined, SettingOutlined } from '@ant-design/icons'
|
||
import { useEditorStore } from '@/stores/editorStore'
|
||
import { exportAndSave, formatFileSize } from '@/utils/imageExporter'
|
||
|
||
const { Text } = Typography
|
||
|
||
interface ExportDialogProps {
|
||
open: boolean
|
||
onClose: () => void
|
||
}
|
||
|
||
export default function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||
const canvas = useEditorStore((s) => s.canvas)
|
||
const exportConfig = useEditorStore((s) => s.exportConfig)
|
||
const setExportConfig = useEditorStore((s) => s.setExportConfig)
|
||
const [estimatedSize, setEstimatedSize] = useState<string>('')
|
||
const [exporting, setExporting] = useState(false)
|
||
|
||
// Update size estimate when config changes
|
||
useEffect(() => {
|
||
if (!canvas || !open) return
|
||
// Estimate from canvas dimensions
|
||
const w = canvas.width || 800
|
||
const h = canvas.height || 600
|
||
const exportW = Math.round(w * exportConfig.scaleFactor)
|
||
const exportH = Math.round(h * exportConfig.scaleFactor)
|
||
// Rough size estimation
|
||
let bytes = exportW * exportH * 4 // RGBA
|
||
if (exportConfig.format === 'jpeg' || exportConfig.format === 'webp') {
|
||
bytes = Math.round(bytes * (exportConfig.quality / 100) * 0.3)
|
||
} else {
|
||
bytes = Math.round(bytes * 0.5)
|
||
}
|
||
setEstimatedSize(formatFileSize(bytes))
|
||
}, [canvas, open, exportConfig])
|
||
|
||
const handleExport = useCallback(async () => {
|
||
if (!canvas) return
|
||
|
||
setExporting(true)
|
||
try {
|
||
const success = await exportAndSave(canvas, exportConfig)
|
||
if (success) {
|
||
message.success('图片导出成功!')
|
||
onClose()
|
||
}
|
||
} catch (err) {
|
||
message.error('导出失败,请重试')
|
||
console.error(err)
|
||
} finally {
|
||
setExporting(false)
|
||
}
|
||
}, [canvas, exportConfig, onClose])
|
||
|
||
const canvasW = canvas?.width || 800
|
||
const canvasH = canvas?.height || 600
|
||
const exportW = Math.round(canvasW * exportConfig.scaleFactor)
|
||
const exportH = Math.round(canvasH * exportConfig.scaleFactor)
|
||
|
||
return (
|
||
<Modal
|
||
title={
|
||
<Space>
|
||
<DownloadOutlined />
|
||
<span>导出图片</span>
|
||
</Space>
|
||
}
|
||
open={open}
|
||
onCancel={onClose}
|
||
width={520}
|
||
footer={null}
|
||
destroyOnClose
|
||
centered
|
||
>
|
||
<div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||
{/* Format */}
|
||
<div>
|
||
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 8 }}>
|
||
图片格式
|
||
</Text>
|
||
<Select
|
||
value={exportConfig.format}
|
||
onChange={(v) => setExportConfig({ format: v })}
|
||
style={{ width: '100%' }}
|
||
options={[
|
||
{ value: 'png', label: 'PNG(无损,支持透明)' },
|
||
{ value: 'jpeg', label: 'JPEG(有损,体积小)' },
|
||
{ value: 'webp', label: 'WebP(现代格式,高效)' },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
{/* Quality (for JPEG/WebP) */}
|
||
{(exportConfig.format === 'jpeg' || exportConfig.format === 'webp') && (
|
||
<div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||
<Text style={{ color: 'var(--text-secondary)', fontSize: 12 }}>图片质量</Text>
|
||
<Text style={{ color: 'var(--accent)', fontSize: 12, fontWeight: 600 }}>
|
||
{exportConfig.quality}%
|
||
</Text>
|
||
</div>
|
||
<Slider
|
||
value={exportConfig.quality}
|
||
onChange={(v) => setExportConfig({ quality: v })}
|
||
min={10}
|
||
max={100}
|
||
marks={{ 10: '低', 50: '中', 80: '高', 100: '最佳' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Scale / Size */}
|
||
<div>
|
||
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 8 }}>
|
||
导出尺寸
|
||
</Text>
|
||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 8 }}>
|
||
<InputNumber
|
||
value={exportW}
|
||
disabled
|
||
size="small"
|
||
addonBefore="宽"
|
||
style={{ flex: 1 }}
|
||
/>
|
||
<span style={{ color: 'var(--text-secondary)' }}>×</span>
|
||
<InputNumber
|
||
value={exportH}
|
||
disabled
|
||
size="small"
|
||
addonBefore="高"
|
||
style={{ flex: 1 }}
|
||
/>
|
||
</div>
|
||
<Slider
|
||
value={exportConfig.scaleFactor}
|
||
onChange={(v) => setExportConfig({ scaleFactor: v })}
|
||
min={0.25}
|
||
max={4}
|
||
step={0.25}
|
||
marks={{
|
||
0.25: '0.25×',
|
||
0.5: '0.5×',
|
||
1: '1×',
|
||
2: '2×',
|
||
4: '4×',
|
||
}}
|
||
tooltip={{ formatter: (v) => `${v}×` }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Info */}
|
||
<div style={{
|
||
background: 'var(--bg-canvas)',
|
||
borderRadius: 6,
|
||
padding: '12px 16px',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
}}>
|
||
<Space direction="vertical" size={2}>
|
||
<Text style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
|
||
导出分辨率
|
||
</Text>
|
||
<Text style={{ color: 'var(--text-primary)', fontSize: 14, fontWeight: 500 }}>
|
||
{exportW} × {exportH}
|
||
</Text>
|
||
</Space>
|
||
<Space direction="vertical" size={2} style={{ textAlign: 'right' }}>
|
||
<Text style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
|
||
预估文件大小
|
||
</Text>
|
||
<Text style={{ color: 'var(--accent)', fontSize: 14, fontWeight: 500 }}>
|
||
{estimatedSize}
|
||
</Text>
|
||
</Space>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 8 }}>
|
||
<Button onClick={onClose}>取消</Button>
|
||
<Button
|
||
type="primary"
|
||
icon={<DownloadOutlined />}
|
||
onClick={handleExport}
|
||
loading={exporting}
|
||
size="large"
|
||
>
|
||
导出图片
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|