🎉 初始提交:TinyLabel 图像文字编辑桌面工具

- 基于 Electron + React + TypeScript + Vite
- Fabric.js 画布编辑引擎
- Ant Design UI 组件 + Zustand 状态管理
- 支持文字添加、拖拽、字体/颜色/特效编辑
- 支持 PNG / JPEG / WebP 导出
This commit is contained in:
2026-07-15 11:49:12 +08:00
commit 08cfcb50b0
26 changed files with 9057 additions and 0 deletions
@@ -0,0 +1,207 @@
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>
)
}